You've already forked agentic-coding-workflow
a2b6d5382e
- workspace.ResetStuckSyncStatuses now returns affected IDs so app.New can record one workspace.sync_aborted_on_restart audit per reset row - worktree_prune audit metadata gains workspace_id and path alongside existing branch - updated fakeRepo + integration test for new ResetStuckSyncStatuses signature - add TestWorktreePrune_AuditMetadataContainsWorkspaceIDAndPath
239 lines
7.2 KiB
Go
239 lines
7.2 KiB
Go
package runners_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
|
)
|
|
|
|
type recordingRecorder struct {
|
|
entries []audit.Entry
|
|
}
|
|
|
|
func (r *recordingRecorder) Record(_ context.Context, e audit.Entry) error {
|
|
r.entries = append(r.entries, e)
|
|
return nil
|
|
}
|
|
|
|
// fakeWorktreeRepo is a fresh fake distinct from fakeWorkspaceFetchRepo.
|
|
type fakeWorktreeRepo struct {
|
|
warnList []runners.WorktreeRow
|
|
pruneList []runners.WorktreeRow
|
|
warnListErr error
|
|
pruneListErr error
|
|
|
|
markedWarning []uuid.UUID
|
|
markWarningErr error
|
|
|
|
tryStartResult runners.WorktreeRow
|
|
tryStartErr error
|
|
tryStartCalled map[uuid.UUID]bool
|
|
|
|
finalized map[uuid.UUID]string // "ok" | "rollback"
|
|
finishSuccessErr error
|
|
finishRollbackErr error
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
|
|
return f.warnList, f.warnListErr
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
|
|
return f.pruneList, f.pruneListErr
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) MarkPruneWarning(_ context.Context, id uuid.UUID) error {
|
|
f.markedWarning = append(f.markedWarning, id)
|
|
return f.markWarningErr
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) TryStartPrune(_ context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
|
|
if f.tryStartCalled == nil {
|
|
f.tryStartCalled = map[uuid.UUID]bool{}
|
|
}
|
|
f.tryStartCalled[id] = true
|
|
if f.tryStartErr != nil {
|
|
return runners.WorktreeRow{}, f.tryStartErr
|
|
}
|
|
return f.tryStartResult, nil
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) FinishPruneSuccess(_ context.Context, id uuid.UUID) error {
|
|
if f.finalized == nil {
|
|
f.finalized = map[uuid.UUID]string{}
|
|
}
|
|
f.finalized[id] = "ok"
|
|
return f.finishSuccessErr
|
|
}
|
|
|
|
func (f *fakeWorktreeRepo) FinishPruneRollback(_ context.Context, id uuid.UUID) error {
|
|
if f.finalized == nil {
|
|
f.finalized = map[uuid.UUID]string{}
|
|
}
|
|
f.finalized[id] = "rollback"
|
|
return f.finishRollbackErr
|
|
}
|
|
|
|
// fakePruner records WorktreeRemove calls.
|
|
type fakePruner struct {
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (f *fakePruner) WorktreeRemove(_ context.Context, _, _ string) error {
|
|
f.calls++
|
|
return f.err
|
|
}
|
|
|
|
func TestWorktreePrune_WarningPhase(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
ownerID := uuid.New()
|
|
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-x", MainPath: "/main", Path: "/main/.worktrees/feat-x"}
|
|
|
|
repo := &fakeWorktreeRepo{warnList: []runners.WorktreeRow{row}}
|
|
disp := &recordingDispatcher{}
|
|
gitr := &fakePruner{}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
|
|
r.Run(context.Background())
|
|
|
|
require.Len(t, repo.markedWarning, 1)
|
|
assert.Equal(t, id, repo.markedWarning[0])
|
|
|
|
require.Len(t, disp.msgs, 1)
|
|
msg := disp.msgs[0]
|
|
assert.Equal(t, ownerID, msg.UserID)
|
|
assert.Equal(t, "worktree.prune_pending", msg.Topic)
|
|
assert.Equal(t, notify.SeverityWarning, msg.Severity)
|
|
|
|
// execution phase: pruneList is empty, so no prune calls.
|
|
assert.Zero(t, gitr.calls)
|
|
}
|
|
|
|
func TestWorktreePrune_ExecutionPhaseSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
ownerID := uuid.New()
|
|
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-y", MainPath: "/main", Path: "/main/.worktrees/feat-y"}
|
|
|
|
repo := &fakeWorktreeRepo{
|
|
pruneList: []runners.WorktreeRow{row},
|
|
tryStartResult: row,
|
|
}
|
|
gitr := &fakePruner{}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
|
|
r.Run(context.Background())
|
|
|
|
assert.True(t, repo.tryStartCalled[id])
|
|
assert.Equal(t, 1, gitr.calls)
|
|
require.NotNil(t, repo.finalized)
|
|
assert.Equal(t, "ok", repo.finalized[id])
|
|
}
|
|
|
|
func TestWorktreePrune_ExecutionFailureRollsBack(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
ownerID := uuid.New()
|
|
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-z", MainPath: "/main", Path: "/main/.worktrees/feat-z"}
|
|
|
|
repo := &fakeWorktreeRepo{
|
|
pruneList: []runners.WorktreeRow{row},
|
|
tryStartResult: row,
|
|
}
|
|
gitr := &fakePruner{err: errors.New("git worktree remove failed")}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
|
|
r.Run(context.Background())
|
|
|
|
assert.True(t, repo.tryStartCalled[id])
|
|
assert.Equal(t, 1, gitr.calls)
|
|
require.NotNil(t, repo.finalized)
|
|
assert.Equal(t, "rollback", repo.finalized[id])
|
|
}
|
|
|
|
func TestWorktreePrune_NoRowsIsNoOp(t *testing.T) {
|
|
t.Parallel()
|
|
repo := &fakeWorktreeRepo{} // both lists nil
|
|
gitr := &fakePruner{}
|
|
disp := &recordingDispatcher{}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
|
|
r.Run(context.Background())
|
|
|
|
assert.Empty(t, repo.markedWarning)
|
|
assert.Empty(t, repo.tryStartCalled)
|
|
assert.Empty(t, repo.finalized)
|
|
assert.Zero(t, gitr.calls)
|
|
assert.Empty(t, disp.msgs)
|
|
}
|
|
|
|
func TestWorktreePrune_AuditMetadataContainsWorkspaceIDAndPath(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
workspaceID := uuid.New()
|
|
ownerID := uuid.New()
|
|
row := runners.WorktreeRow{
|
|
ID: id, WorkspaceID: workspaceID, OwnerID: ownerID,
|
|
Branch: "feat-audit", MainPath: "/main", Path: "/main/.worktrees/feat-audit",
|
|
}
|
|
|
|
repo := &fakeWorktreeRepo{
|
|
pruneList: []runners.WorktreeRow{row},
|
|
tryStartResult: row,
|
|
}
|
|
gitr := &fakePruner{}
|
|
rec := &recordingRecorder{}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), rec)
|
|
r.Run(context.Background())
|
|
|
|
require.Len(t, rec.entries, 1)
|
|
e := rec.entries[0]
|
|
assert.Equal(t, "worktree.pruned", e.Action)
|
|
assert.Equal(t, "worktree", e.TargetType)
|
|
assert.Equal(t, id.String(), e.TargetID)
|
|
assert.Equal(t, workspaceID.String(), e.Metadata["workspace_id"])
|
|
assert.Equal(t, "feat-audit", e.Metadata["branch"])
|
|
assert.Equal(t, "/main/.worktrees/feat-audit", e.Metadata["path"])
|
|
}
|
|
|
|
func TestWorktreePrune_CASLostSkipsSilently(t *testing.T) {
|
|
t.Parallel()
|
|
id := uuid.New()
|
|
ownerID := uuid.New()
|
|
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-cas", MainPath: "/main", Path: "/main/.worktrees/feat-cas"}
|
|
|
|
repo := &fakeWorktreeRepo{
|
|
pruneList: []runners.WorktreeRow{row},
|
|
tryStartErr: errors.New("worktree no longer idle"),
|
|
}
|
|
gitr := &fakePruner{}
|
|
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
|
|
|
|
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
|
|
r.Run(context.Background())
|
|
|
|
// TryStartPrune was called but failed.
|
|
assert.True(t, repo.tryStartCalled[id])
|
|
// No git call.
|
|
assert.Zero(t, gitr.calls)
|
|
// No finalize for that ID.
|
|
assert.Empty(t, repo.finalized)
|
|
}
|