package workspace import ( "context" "log/slog" "sync" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/git" ) // CloneRunner 用 goroutine 异步跑 git clone。Schedule 是非阻塞的: // 调用方立即返回,clone 进度通过 sync_status 列轮询。 // // Plan #5(Job Runner)实现后会替换 Schedule 内部为 job dispatcher 入队, // 调用点不需要改动。 type CloneRunner struct { repo Repository rec audit.Recorder gitr git.Runner log *slog.Logger timeout time.Duration wg sync.WaitGroup resolveCred func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) } // NewCloneRunner 构造 CloneRunner。timeout==0 时使用默认 30 分钟超时。 func NewCloneRunner( repo Repository, rec audit.Recorder, gitr git.Runner, log *slog.Logger, resolveCred func(context.Context, uuid.UUID) (*git.Credential, error), timeout time.Duration, ) *CloneRunner { if timeout == 0 { timeout = 30 * time.Minute } return &CloneRunner{ repo: repo, rec: rec, gitr: gitr, log: log, timeout: timeout, resolveCred: resolveCred, } } // Schedule 非阻塞地排程一次 clone;调用方立即返回。 func (r *CloneRunner) Schedule(wsID uuid.UUID) { r.wg.Add(1) go func() { defer r.wg.Done() ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() r.run(ctx, wsID) }() } // Wait 阻塞直到所有已排程的 clone 任务完成;测试与 graceful shutdown 用。 func (r *CloneRunner) Wait() { r.wg.Wait() } func (r *CloneRunner) run(ctx context.Context, wsID uuid.UUID) { ws, err := r.repo.GetWorkspaceByID(ctx, wsID) if err != nil { r.log.Error("clone runner: get workspace", "ws_id", wsID, "err", err.Error()) return } // 状态/审计写操作与 clone 的超时 ctx 解耦:clone 超时后 ctx 已 DeadlineExceeded, // 仍需把状态写成 error,否则永久卡在 cloning。 writeCtx := context.WithoutCancel(ctx) cred, err := r.resolveCred(ctx, wsID) if err != nil { r.markError(writeCtx, wsID, "resolve credential: "+err.Error()) return } if cloneErr := r.gitr.Clone(ctx, ws.MainPath, ws.GitRemoteURL, ws.DefaultBranch, cred); cloneErr != nil { r.markError(writeCtx, wsID, summarizeErr(cloneErr)) _ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}}) return } now := time.Now().UTC() if _, err := r.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, ""); err != nil { r.log.Error("clone runner: update sync status idle", "ws_id", wsID, "err", err.Error()) return } _ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()}) } func (r *CloneRunner) markError(ctx context.Context, wsID uuid.UUID, msg string) { if _, err := r.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusError, nil, msg); err != nil { r.log.Error("clone runner: update sync status error", "ws_id", wsID, "err", err.Error()) } }