diff --git a/README.md b/README.md index 90d9975..b2192fc 100644 --- a/README.md +++ b/README.md @@ -181,12 +181,53 @@ execute-mode create 会: 当前版本支持 create/delete、`skill install` 和当前 taskflow.yaml 配置。旧 `init/start/status/validate/repo add` 命令、旧字段、state/report/inventory 文件不在运行时兼容范围内。已有任务的 `create --repo` 追加调用也不再支持;请直接编辑 taskflow.yaml。没有 ownership.json 的旧任务不能由 `delete` 自动清理。 +## 会话驱动的 AI 工作流 + +在准备好任务 worktree 后,可以为任务增加独立的 `workflow.yaml`,让 Codex 或 Claude 会话通过全局 `taskflow-workflow` Skill 和宿主 `/loop` 按阶段持续推进。`taskflow.yaml` 仍然只描述 Git worktree;`workflow.yaml` 描述阶段目标、检查命令、重试次数、预算和审批策略。 + +示例配置见 [`examples/workflow.yaml`](examples/workflow.yaml)。工作流命令使用结构化 JSON 作为 Skill 的控制协议: + +```bash +taskflow --json --tasks-root ~/tasks workflow validate DEMO-001 +taskflow --json --tasks-root ~/tasks workflow status DEMO-001 +``` + +用户在任务 worktree 中启动 Agent,而不是让 Taskflow 启动嵌套 Agent: + +```bash +cd ~/tasks/DEMO-001/worktrees/service +codex +# 或 claude +``` + +在会话中调用全局 `taskflow-workflow` Skill,然后使用宿主的 `/loop`。每个 tick 只执行一个有界迭代:读取状态、开始或恢复一个 attempt、修改代码、提交结构化 checkpoint,并执行配置中的机器检查。Skill 必须在 `completed`、`paused`、`awaiting_approval`、`needs_attention`、`cancelled` 或 `unknown` 状态停止工作。 + +用户可以在会话外进行显式控制: + +```bash +taskflow --json --tasks-root ~/tasks workflow pause DEMO-001 +taskflow --json --tasks-root ~/tasks workflow resume DEMO-001 +taskflow --json --tasks-root ~/tasks workflow approve DEMO-001 +taskflow --json --tasks-root ~/tasks workflow cancel DEMO-001 +``` + +运行时状态和证据保存在任务目录的 `.taskflow/` 下,包括 workflow snapshot、JSONL 事件、session lease、attempt 报告和检查结果。工作流只有在所有 required checks 成功后才会进入 `completed`;`needs_approval` 只有在策略允许且 action 被列入 `allowed_actions`(如果配置了)时才会建立审批请求。commit、push、PR、merge、release、deploy、删除和外部写入仍由用户单独授权并执行。 + +### 会话工作流边界 + +- `/loop` 是当前 Codex 或 Claude 会话的触发器,不是后台 daemon;关闭会话后不会继续产生新的迭代,但状态可以在下一次会话恢复。 +- 每个任务 v1 只允许一个 active attempt,阶段按 `workflow.yaml` 声明顺序串行执行,不支持并行 Agent 或 DAG。 +- Skill 指令不能拦截通用 Agent 直接发起的任意 shell 命令;需要强制的命令级审批时,应增加代理、hook、sandbox 或 App Server 层。 + +没有 `workflow.yaml` 的现有任务继续是普通的 worktree-only Taskflow 任务;workflow 命令不会修改旧的 `taskflow.yaml` 或自动迁移旧运行时文件。 + ## 非目标 - 需求、规格、角色、契约负责人或项目进度管理 -- AI session lease、对话恢复、模型或权限策略 -- commit、pull、push、PR、merge、release -- 检查脚本、validation report、状态 daemon 或 Web UI +- 启动、嵌入或监督 Codex/Claude Agent 进程 +- 后台 workflow daemon、App Server、Web UI 或远程执行 +- 任意 DAG、并行阶段或多 Agent 协调 +- 自动 commit、pull、push、PR、merge、release、deploy 或外部写入 - archive、需求归档或发布流程 ## 开发和验证 diff --git a/cmd/root.go b/cmd/root.go index b875cdf..4228186 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,9 +27,17 @@ func NewRootCommand() *cobra.Command { var tasksRoot = "." var asJSON bool svc := app.New() - root := &cobra.Command{Use: "taskflow", Short: "Create and manage Git worktrees for AI coding", SilenceUsage: true} + root := &cobra.Command{Use: "taskflow", Short: "Create and manage Git worktrees for AI coding", SilenceUsage: true, SilenceErrors: true} root.PersistentFlags().StringVar(&tasksRoot, "tasks-root", ".", "task workspace root (default: current directory)") root.PersistentFlags().BoolVar(&asJSON, "json", false, "emit JSON") + root.SetFlagErrorFunc(func(c *cobra.Command, err error) error { + result := report.New(c.CommandPath(), "") + result.Fail(report.Diagnostic{Code: "INVALID_ARGUMENT", Message: err.Error()}) + if renderErr := report.Render(c.OutOrStdout(), result, asJSON); renderErr != nil { + return renderErr + } + return &exitError{code: int(report.ExitConfig)} + }) render := func(c *cobra.Command, r report.Result, code report.ExitCode) error { if err := report.Render(c.OutOrStdout(), r, asJSON); err != nil { return err @@ -47,7 +55,7 @@ func NewRootCommand() *cobra.Command { var repositories []string var dryRun, execute bool - create := &cobra.Command{Use: "create ", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { + create := &cobra.Command{Use: "create ", Args: exactArgs(1, &asJSON), RunE: func(c *cobra.Command, args []string) error { r, code := svc.Create(context.Background(), app.CreateOptions{ TasksRoot: tasksRoot, TaskID: args[0], @@ -63,7 +71,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(create) var deleteDryRun, deleteExecute, deleteForce bool - remove := &cobra.Command{Use: "delete ", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { + remove := &cobra.Command{Use: "delete ", Args: exactArgs(1, &asJSON), RunE: func(c *cobra.Command, args []string) error { r, code := svc.Delete(context.Background(), app.DeleteOptions{ TasksRoot: tasksRoot, TaskID: args[0], @@ -81,7 +89,7 @@ func NewRootCommand() *cobra.Command { var projectSkills, forceSkills bool var skillTools []string skillCmd := &cobra.Command{Use: "skill", Short: "Install Taskflow skills for AI coding agents"} - installSkills := &cobra.Command{Use: "install", Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error { + installSkills := &cobra.Command{Use: "install", Args: exactArgs(0, &asJSON), RunE: func(c *cobra.Command, args []string) error { targets, err := skillTargets(skillTools, projectSkills) r := report.New("skill install", "") if err != nil { @@ -101,9 +109,124 @@ func NewRootCommand() *cobra.Command { installSkills.Flags().StringArrayVar(&skillTools, "tool", nil, "install for codex or claude (repeatable; defaults to both)") skillCmd.AddCommand(installSkills) root.AddCommand(skillCmd) + root.AddCommand(newWorkflowCommand(svc, &tasksRoot, &asJSON, render)) return root } +type workflowRenderer func(*cobra.Command, report.Result, report.ExitCode) error + +func newWorkflowCommand(svc app.Service, tasksRoot *string, asJSON *bool, render workflowRenderer) *cobra.Command { + workflowCmd := &cobra.Command{Use: "workflow", Short: "Run and inspect a session-driven workflow"} + + validate := &cobra.Command{Use: "validate ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + r, code := svc.WorkflowValidate(context.Background(), app.WorkflowOptions{TasksRoot: *tasksRoot, TaskID: args[0]}) + return render(c, r, code) + }} + workflowCmd.AddCommand(validate) + + status := &cobra.Command{Use: "status ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + r, code := svc.WorkflowStatus(context.Background(), app.WorkflowOptions{TasksRoot: *tasksRoot, TaskID: args[0]}) + return render(c, r, code) + }} + workflowCmd.AddCommand(status) + + var begin app.WorkflowOptions + beginCmd := &cobra.Command{Use: "begin ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + begin.TasksRoot, begin.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowBegin(context.Background(), begin) + return render(c, r, code) + }} + beginCmd.Flags().StringVar(&begin.Engine, "engine", "unknown", "active host engine: codex or claude") + beginCmd.Flags().StringVar(&begin.SessionID, "session", "", "active Agent session reference") + beginCmd.Flags().StringVar(&begin.OperationID, "operation-id", "", "stable operation identifier for retries") + beginCmd.Flags().DurationVar(&begin.LeaseTTL, "lease-ttl", 0, "workflow lease duration (default 15m)") + workflowCmd.AddCommand(beginCmd) + + var checkpoint app.WorkflowOptions + checkpointCmd := &cobra.Command{Use: "checkpoint ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + checkpoint.TasksRoot, checkpoint.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowCheckpoint(context.Background(), checkpoint) + return render(c, r, code) + }} + checkpointCmd.Flags().StringVar(&checkpoint.AttemptID, "attempt-id", "", "active attempt identifier") + checkpointCmd.Flags().StringVar(&checkpoint.OwnerToken, "owner-token", "", "active workflow lease owner token") + checkpointCmd.Flags().StringVar(&checkpoint.ReportPath, "report-file", "", "JSON checkpoint report path under the task root") + checkpointCmd.Flags().StringVar(&checkpoint.OperationID, "operation-id", "", "stable operation identifier for retries") + checkpointCmd.Flags().DurationVar(&checkpoint.LeaseTTL, "lease-ttl", 0, "workflow lease duration (default 15m)") + workflowCmd.AddCommand(checkpointCmd) + + var verify app.WorkflowOptions + verifyCmd := &cobra.Command{Use: "verify ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + verify.TasksRoot, verify.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowVerify(context.Background(), verify) + return render(c, r, code) + }} + verifyCmd.Flags().StringVar(&verify.AttemptID, "attempt-id", "", "active attempt identifier") + verifyCmd.Flags().StringVar(&verify.OwnerToken, "owner-token", "", "active workflow lease owner token") + verifyCmd.Flags().StringVar(&verify.OperationID, "operation-id", "", "stable operation identifier for retries") + workflowCmd.AddCommand(verifyCmd) + + var pause app.WorkflowOptions + pauseCmd := &cobra.Command{Use: "pause ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + pause.TasksRoot, pause.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowPause(context.Background(), pause) + return render(c, r, code) + }} + pauseCmd.Flags().StringVar(&pause.OwnerToken, "owner-token", "", "active workflow lease owner token") + pauseCmd.Flags().StringVar(&pause.OperationID, "operation-id", "", "stable operation identifier for retries") + pauseCmd.Flags().StringVar(&pause.Reason, "reason", "", "reason for pausing") + workflowCmd.AddCommand(pauseCmd) + + var resume app.WorkflowOptions + resumeCmd := &cobra.Command{Use: "resume ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + resume.TasksRoot, resume.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowResume(context.Background(), resume) + return render(c, r, code) + }} + resumeCmd.Flags().BoolVar(&resume.Recover, "recover", false, "explicitly recover unknown or attention state") + resumeCmd.Flags().StringVar(&resume.OperationID, "operation-id", "", "stable operation identifier for retries") + workflowCmd.AddCommand(resumeCmd) + + var approve app.WorkflowOptions + approveCmd := &cobra.Command{Use: "approve ", Args: exactArgs(2, asJSON), RunE: func(c *cobra.Command, args []string) error { + approve.TasksRoot, approve.TaskID, approve.ApprovalID = *tasksRoot, args[0], args[1] + r, code := svc.WorkflowApprove(context.Background(), approve) + return render(c, r, code) + }} + approveCmd.Flags().StringVar(&approve.Decision, "decision", "approve", "approval decision: approve or reject") + approveCmd.Flags().StringVar(&approve.Reason, "reason", "", "reason for the approval decision") + approveCmd.Flags().StringVar(&approve.OperationID, "operation-id", "", "stable operation identifier for retries") + workflowCmd.AddCommand(approveCmd) + + var cancel app.WorkflowOptions + cancelCmd := &cobra.Command{Use: "cancel ", Args: exactArgs(1, asJSON), RunE: func(c *cobra.Command, args []string) error { + cancel.TasksRoot, cancel.TaskID = *tasksRoot, args[0] + r, code := svc.WorkflowCancel(context.Background(), cancel) + return render(c, r, code) + }} + cancelCmd.Flags().StringVar(&cancel.OwnerToken, "owner-token", "", "active workflow lease owner token") + cancelCmd.Flags().StringVar(&cancel.OperationID, "operation-id", "", "stable operation identifier for retries") + cancelCmd.Flags().StringVar(&cancel.Reason, "reason", "", "reason for cancelling") + workflowCmd.AddCommand(cancelCmd) + + return workflowCmd +} + +func exactArgs(expected int, asJSON *bool) cobra.PositionalArgs { + return func(c *cobra.Command, args []string) error { + if len(args) == expected { + return nil + } + message := fmt.Sprintf("expected %d argument(s), got %d", expected, len(args)) + result := report.New(c.CommandPath(), "") + result.Fail(report.Diagnostic{Code: "INVALID_ARGUMENT", Message: message}) + if err := report.Render(c.OutOrStdout(), result, *asJSON); err != nil { + return err + } + return &exitError{code: int(report.ExitConfig)} + } +} + func loadDiagnostic(err error) report.Diagnostic { return report.Diagnostic{Code: "INVALID_CONFIGURATION", Message: err.Error()} } diff --git a/cmd/root_test.go b/cmd/root_test.go index 8ef45b3..ff12c14 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,11 +2,14 @@ package cmd import ( "bytes" + "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" + + "github.com/chenquan/taskflow/internal/report" ) func TestVersionUsesCobraCommand(t *testing.T) { @@ -162,6 +165,41 @@ func TestSkillScope(t *testing.T) { } } +func TestJSONArgumentFailuresRemainMachineReadable(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "missing positional argument", args: []string{"--json", "workflow", "status"}}, + {name: "unknown flag", args: []string{"--json", "workflow", "status", "TASK-1", "--not-a-flag"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + root := NewRootCommand() + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(tt.args) + if err := root.Execute(); err == nil { + t.Fatal("expected argument failure") + } + var envelope struct { + OK bool `json:"ok"` + Errors []report.Diagnostic `json:"errors"` + } + if err := json.Unmarshal(output.Bytes(), &envelope); err != nil { + t.Fatalf("argument failure is not JSON: %v: %s", err, output.String()) + } + if envelope.OK || len(envelope.Errors) != 1 || envelope.Errors[0].Code != "INVALID_ARGUMENT" { + t.Fatalf("unexpected argument envelope: %#v", envelope) + } + if strings.Contains(output.String(), "Error:") { + t.Fatalf("Cobra text leaked into JSON output: %s", output.String()) + } + }) + } +} + func TestPublicCommandsAreLimitedToCreateDeleteVersionAndSkill(t *testing.T) { root := NewRootCommand() seen := map[string]bool{} diff --git a/cmd/workflow_e2e_test.go b/cmd/workflow_e2e_test.go new file mode 100644 index 0000000..cd3577d --- /dev/null +++ b/cmd/workflow_e2e_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chenquan/taskflow/internal/workflow" +) + +func workflowConfigForCLI(taskID string) string { + return `version: 1 +task: + id: ` + taskID + ` +limits: + max_iterations: 5 + max_duration: 1h +stages: + - id: implement + objective: implement and verify + max_attempts: 2 + checks: [pass] +checks: + - id: pass + argv: ["true"] + cwd: "repo:repo" + timeout: 2s +` +} + +func decodeWorkflowData(t *testing.T, output string) map[string]any { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal([]byte(output), &envelope); err != nil { + t.Fatalf("decode workflow output: %v: %s", err, output) + } + if !envelope.OK { + t.Fatalf("workflow command failed: %s", output) + } + return envelope.Data +} + +func TestWorkflowCLIEndToEnd(t *testing.T) { + repo := e2eGitRepo(t) + tasks := t.TempDir() + if output, err := runE2E(t, tasks, "create", "CLI-FLOW", "--repo", "repo="+repo, "--execute"); err != nil { + t.Fatalf("create: %v: %s", err, output) + } + taskRoot := filepath.Join(tasks, "CLI-FLOW") + if err := os.WriteFile(filepath.Join(taskRoot, "workflow.yaml"), []byte(workflowConfigForCLI("CLI-FLOW")), 0644); err != nil { + t.Fatal(err) + } + if output, err := runE2E(t, tasks, "--json", "workflow", "validate", "CLI-FLOW"); err != nil { + t.Fatalf("workflow validate: %v: %s", err, output) + } + statusOutput, err := runE2E(t, tasks, "--json", "workflow", "status", "CLI-FLOW") + if err != nil { + t.Fatalf("workflow status: %v: %s", err, statusOutput) + } + statusData := decodeWorkflowData(t, statusOutput) + if statusData["status"] != string(workflow.StatusReady) || statusData["snapshotExists"] != false { + t.Fatalf("unexpected initial status: %#v", statusData) + } + beginOutput, err := runE2E(t, tasks, "--json", "workflow", "begin", "CLI-FLOW", "--engine", "codex", "--session", "session-1", "--operation-id", "begin-1") + if err != nil { + t.Fatalf("workflow begin: %v: %s", err, beginOutput) + } + beginData := decodeWorkflowData(t, beginOutput) + attemptID, _ := beginData["attemptID"].(string) + ownerToken, _ := beginData["ownerToken"].(string) + if attemptID == "" || ownerToken == "" { + t.Fatalf("begin data missing identity: %#v", beginData) + } + reportPath := filepath.Join(taskRoot, "cli-report.json") + reportRaw, err := json.Marshal(workflow.AgentReport{ + Version: workflow.RuntimeVersion, + TaskID: "CLI-FLOW", + StageID: "implement", + AttemptID: attemptID, + SessionID: "session-1", + Status: workflow.ReportReady, + Summary: "ready for machine verification", + Commands: []workflow.CommandRecord{}, + Risks: []string{}, + NextAction: "verify", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(reportPath, reportRaw, 0644); err != nil { + t.Fatal(err) + } + checkpointOutput, err := runE2E(t, tasks, "--json", "workflow", "checkpoint", "CLI-FLOW", "--attempt-id", attemptID, "--owner-token", ownerToken, "--report-file", reportPath, "--operation-id", "checkpoint-1") + if err != nil { + t.Fatalf("workflow checkpoint: %v: %s", err, checkpointOutput) + } + if checkpointData := decodeWorkflowData(t, checkpointOutput); checkpointData["status"] != string(workflow.StatusVerifying) { + t.Fatalf("unexpected checkpoint status: %#v", checkpointData) + } + verifyOutput, err := runE2E(t, tasks, "--json", "workflow", "verify", "CLI-FLOW", "--attempt-id", attemptID, "--owner-token", ownerToken, "--operation-id", "verify-1") + if err != nil { + t.Fatalf("workflow verify: %v: %s", err, verifyOutput) + } + verifyData := decodeWorkflowData(t, verifyOutput) + if verifyData["passed"] != true || verifyData["status"] != string(workflow.StatusCompleted) { + t.Fatalf("unexpected verification data: %#v", verifyData) + } + finalOutput, err := runE2E(t, tasks, "--json", "workflow", "status", "CLI-FLOW") + if err != nil || !strings.Contains(finalOutput, `"status": "completed"`) { + t.Fatalf("final status: %v: %s", err, finalOutput) + } +} + +func TestWorkflowCLIFailsBeforeMutationForInvalidWorkflow(t *testing.T) { + repo := e2eGitRepo(t) + tasks := t.TempDir() + if output, err := runE2E(t, tasks, "create", "CLI-BAD", "--repo", "repo="+repo, "--execute"); err != nil { + t.Fatalf("create: %v: %s", err, output) + } + taskRoot := filepath.Join(tasks, "CLI-BAD") + if err := os.WriteFile(filepath.Join(taskRoot, "workflow.yaml"), []byte(strings.Replace(workflowConfigForCLI("CLI-BAD"), "max_attempts: 2", "unknown_field: true", 1)), 0644); err != nil { + t.Fatal(err) + } + output, err := runE2E(t, tasks, "--json", "workflow", "begin", "CLI-BAD", "--operation-id", "begin-1") + if err == nil || !strings.Contains(output, "INVALID_WORKFLOW_CONFIGURATION") { + t.Fatalf("expected invalid workflow failure: %v: %s", err, output) + } + if _, err := os.Stat(filepath.Join(taskRoot, ".taskflow", "workflow-state.json")); !os.IsNotExist(err) { + t.Fatalf("invalid workflow created runtime state: %v", err) + } +} diff --git a/docs/taskflow-workflow-smoke.md b/docs/taskflow-workflow-smoke.md new file mode 100644 index 0000000..b1eebe2 --- /dev/null +++ b/docs/taskflow-workflow-smoke.md @@ -0,0 +1,51 @@ +# Taskflow workflow smoke test + +This procedure verifies the installed workflow Skill in both supported Agent +hosts after building the current Taskflow binary. It is intentionally manual: +the host owns `/global` and `/loop`, while Taskflow owns the local workflow +state and verification contract. + +## Prepare + +1. Build or install the current `taskflow` binary. +2. Install both global Skills: + + ```bash + taskflow skill install --force + ``` + +3. Create a disposable Git repository and a Taskflow task with `create + --dry-run`, review it, then run `create --execute`. +4. Copy [`examples/workflow.yaml`](../examples/workflow.yaml) into the task + root and replace the task ID and repository name. + +## Run in Codex and Claude + +For each host independently: + +1. Start the host from the primary worktree; Taskflow must not start a nested + host process. +2. Invoke the global `taskflow-workflow` Skill through the host's global Skill + mechanism (for example, `/global taskflow-workflow`). +3. Start the host's native `/loop` with an instruction to execute one bounded + workflow iteration. +4. Confirm each tick first reads `taskflow --json workflow status`. +5. Confirm a runnable tick creates one attempt, writes a checkpoint, executes + only the configured check, and records evidence under `.taskflow/`. +6. Confirm a passing final check produces `completed` and later loop ticks do + not modify the worktree. + +## Recovery checks + +- Pause the workflow and confirm a loop tick reports `paused` without editing. +- Resume it and confirm the next tick continues from the persisted stage. +- End a session after `begin` and confirm the next session reports `unknown` + after the lease expires; recover explicitly before continuing. +- Change `workflow.yaml` during an active attempt and confirm the CLI reports + `CONFIG_CHANGED` without accepting the old checkpoint. +- Submit `needs_approval` and confirm the loop stops until the user approves + or rejects the named approval request. + +Record the host version, Taskflow binary version, task root, command output, +and any host-specific `/global` or `/loop` syntax differences with the test +result. diff --git a/examples/workflow.yaml b/examples/workflow.yaml new file mode 100644 index 0000000..090751c --- /dev/null +++ b/examples/workflow.yaml @@ -0,0 +1,30 @@ +version: 1 + +task: + id: DEMO-001 + +limits: + max_iterations: 20 + max_duration: 4h + +stages: + - id: implement + objective: 实现需求、补充测试,并记录本轮修改摘要 + max_attempts: 3 + checks: [tests] + + - id: review + objective: 检查兼容性、边界条件和测试覆盖率 + max_attempts: 2 + checks: [tests] + +checks: + - id: tests + argv: ["go", "test", "./..."] + cwd: "repo:service" + timeout: 10m + output_limit: 65536 + env_allowlist: [PATH, HOME, TMPDIR, GOCACHE, GOMODCACHE, GOPATH] + +policy: + external_actions: deny diff --git a/internal/app/app.go b/internal/app/app.go index 661f939..77f3dea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -508,7 +508,7 @@ func validateDeleteDirectory(taskRoot string, targets []string) error { } for _, entry := range entries { switch entry.Name() { - case "taskflow.yaml", ".taskflow", "worktrees": + case "taskflow.yaml", "workflow.yaml", ".taskflow", "worktrees": default: return fmt.Errorf("task directory contains unmanaged entry %q", entry.Name()) } @@ -542,9 +542,24 @@ func removeTaskDirectory(taskRoot string, targets []string) error { if err := removeIfExists(filepath.Join(taskRoot, "taskflow.yaml")); err != nil { return err } + if err := removeIfExists(filepath.Join(taskRoot, "workflow.yaml")); err != nil { + return err + } if err := removeIfExists(ownership.Path(taskRoot)); err != nil { return err } + for _, path := range []string{ + filepath.Join(taskRoot, ".taskflow", "workflow-state.json"), + filepath.Join(taskRoot, ".taskflow", "workflow-events.jsonl"), + filepath.Join(taskRoot, ".taskflow", "workflow-lease.json"), + } { + if err := removeIfExists(path); err != nil { + return err + } + } + if err := os.RemoveAll(filepath.Join(taskRoot, ".taskflow", "workflow")); err != nil { + return err + } directories := map[string]bool{} worktreesRoot := filepath.Join(taskRoot, "worktrees") for _, target := range targets { @@ -718,7 +733,9 @@ func rejectLegacyRuntime(taskRoot string) error { return err } for _, entry := range entries { - if entry.Name() != "lock" && entry.Name() != "ownership.json" { + switch entry.Name() { + case "lock", "ownership.json", "workflow-state.json", "workflow-events.jsonl", "workflow-lease.json", "workflow": + default: return fmt.Errorf("legacy runtime artifact %q exists; recreate the task workspace", filepath.Join(".taskflow", entry.Name())) } } diff --git a/internal/app/workflow.go b/internal/app/workflow.go new file mode 100644 index 0000000..2b14544 --- /dev/null +++ b/internal/app/workflow.go @@ -0,0 +1,1211 @@ +package app + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/chenquan/taskflow/internal/domain" + "github.com/chenquan/taskflow/internal/fsx" + "github.com/chenquan/taskflow/internal/lock" + "github.com/chenquan/taskflow/internal/report" + "github.com/chenquan/taskflow/internal/workflow" +) + +type WorkflowOptions struct { + TasksRoot string + TaskID string + Engine string + SessionID string + OwnerToken string + AttemptID string + ReportPath string + OperationID string + ApprovalID string + Decision string + Reason string + LeaseTTL time.Duration + Recover bool +} + +type workflowIssue struct { + Code string + Repo string + Message string + Hint string + ExitCode report.ExitCode +} + +func (e *workflowIssue) Error() string { return e.Message } + +func issue(code, message string, exitCode report.ExitCode) *workflowIssue { + return &workflowIssue{Code: code, Message: message, ExitCode: exitCode} +} + +func (s Service) loadWorkflow(tasksRoot, taskID string) (domain.Task, workflow.Config, string, *workflowIssue) { + task, err := s.Load(tasksRoot, taskID) + if err != nil { + return domain.Task{}, workflow.Config{}, "", issue("INVALID_CONFIGURATION", err.Error(), report.ExitConfig) + } + path := workflow.ConfigPath(task.Task.Root) + if _, err := os.Stat(path); os.IsNotExist(err) { + return domain.Task{}, workflow.Config{}, "", &workflowIssue{ + Code: "WORKFLOW_NOT_CONFIGURED", + Message: fmt.Sprintf("task %s has no workflow.yaml", taskID), + Hint: path, + ExitCode: report.ExitConfig, + } + } else if err != nil { + return domain.Task{}, workflow.Config{}, "", issue("WORKFLOW_READ_FAILED", err.Error(), report.ExitExecution) + } + cfg, digest, err := workflow.Load(path, taskID) + if err != nil { + return domain.Task{}, workflow.Config{}, "", &workflowIssue{ + Code: "INVALID_WORKFLOW_CONFIGURATION", + Message: err.Error(), + Hint: path, + ExitCode: report.ExitConfig, + } + } + return task, cfg, digest, nil +} + +func failWorkflow(result *report.Result, problem *workflowIssue) (report.Result, report.ExitCode) { + result.Fail(report.Diagnostic{Code: problem.Code, Repo: problem.Repo, Message: problem.Message, Hint: problem.Hint}) + return *result, problem.ExitCode +} + +func (s Service) validateWorkflowWorktrees(ctx context.Context, task domain.Task, cfg workflow.Config) *workflowIssue { + for _, repository := range task.Repositories { + sourceInfo, err := s.Git.Inspect(ctx, repository.Source) + if err != nil || sourceInfo.CommonDir == "" { + return &workflowIssue{Code: "NOT_GIT_REPOSITORY", Repo: repository.Name, Message: gitErrorMessage("inspect configured source", err), ExitCode: report.ExitEnvironment} + } + target := filepath.Join(task.Task.Root, repository.Worktree) + targetInfo, err := s.Git.Inspect(ctx, target) + if err != nil { + return &workflowIssue{Code: "WORKTREE_NOT_READY", Repo: repository.Name, Message: fmt.Sprintf("worktree %s is not ready: %v", target, err), Hint: target, ExitCode: report.ExitConflict} + } + if !samePath(targetInfo.CommonDir, sourceInfo.CommonDir) || targetInfo.Branch != repository.Branch { + return &workflowIssue{ + Code: "WORKTREE_MISMATCH", + Repo: repository.Name, + Message: fmt.Sprintf("worktree %s does not match source or branch %s", target, repository.Branch), + Hint: target, + ExitCode: report.ExitConflict, + } + } + } + for _, check := range cfg.Checks { + if _, err := workflow.ResolveCWD(task, check.CWD); err != nil { + return &workflowIssue{Code: "INVALID_WORKFLOW_CONFIGURATION", Message: fmt.Sprintf("check %s cwd: %v", check.ID, err), ExitCode: report.ExitConfig} + } + } + return nil +} + +func workflowOperationID(value string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return workflow.NewID("operation") +} + +func rejectOperationReuse(snapshot workflow.Snapshot, operationID, command string) *workflowIssue { + if previous, exists := snapshot.Operations[operationID]; exists && previous.Command != command { + return &workflowIssue{ + Code: "OPERATION_CONFLICT", + Message: fmt.Sprintf("operation ID %q was already used for %s", operationID, previous.Command), + ExitCode: report.ExitConflict, + } + } + return nil +} + +func (s Service) readWorkflowState(store workflow.Store, taskID string, cfg workflow.Config, digest string) (workflow.Snapshot, bool, *workflowIssue) { + snapshot, exists, err := store.ReadSnapshot() + if err != nil { + return workflow.Snapshot{}, false, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: err.Error(), Hint: store.Paths.State, ExitCode: report.ExitConfig} + } + if !exists { + return workflow.NewSnapshot(taskID, digest, cfg, time.Now().UTC()), false, nil + } + if snapshot.TaskID != taskID { + return workflow.Snapshot{}, true, &workflowIssue{Code: "RUNTIME_TASK_MISMATCH", Message: fmt.Sprintf("runtime taskID %q does not match %q", snapshot.TaskID, taskID), Hint: store.Paths.State, ExitCode: report.ExitConfig} + } + if snapshot.ConfigDigest == digest { + if err := workflow.ValidateSnapshotForConfig(snapshot, cfg); err != nil { + return workflow.Snapshot{}, true, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: err.Error(), Hint: store.Paths.State, ExitCode: report.ExitConfig} + } + } + return snapshot, true, nil +} + +func requireWorkflowDigest(snapshot workflow.Snapshot, digest string) *workflowIssue { + if snapshot.ConfigDigest != digest { + return &workflowIssue{Code: "CONFIG_CHANGED", Message: "workflow.yaml changed since the active execution context was created; explicit resume is required", ExitCode: report.ExitConflict} + } + return nil +} + +func budgetExceeded(snapshot workflow.Snapshot, cfg workflow.Config, now time.Time) (string, bool) { + if cfg.Limits.MaxIterations > 0 && snapshot.Iteration >= cfg.Limits.MaxIterations { + return "maximum workflow iterations exhausted", true + } + if cfg.Limits.MaxUsage > 0 && snapshot.Usage >= cfg.Limits.MaxUsage { + return "maximum workflow usage exhausted", true + } + if cfg.Limits.MaxDuration > 0 && !snapshot.CreatedAt.IsZero() && now.Sub(snapshot.CreatedAt) >= cfg.Limits.MaxDuration.TimeDuration() { + return "maximum workflow duration exhausted", true + } + return "", false +} + +func workflowDurationExceeded(snapshot workflow.Snapshot, cfg workflow.Config, now time.Time) (string, bool) { + if cfg.Limits.MaxDuration > 0 && !snapshot.CreatedAt.IsZero() && now.Sub(snapshot.CreatedAt) >= cfg.Limits.MaxDuration.TimeDuration() { + return "maximum workflow duration exhausted", true + } + return "", false +} + +func checkpointBudgetExceeded(snapshot workflow.Snapshot, cfg workflow.Config, status workflow.ReportStatus, now time.Time) (string, bool) { + // A ready report belongs to the current attempt and still needs its + // machine verification. Iteration and usage limits therefore prevent a + // subsequent attempt, but do not discard the final verification of the + // attempt that consumed the budget. A wall-clock deadline is different: + // once it has elapsed, verification must not promote the attempt. + if status == workflow.ReportReady { + return workflowDurationExceeded(snapshot, cfg, now) + } + return budgetExceeded(snapshot, cfg, now) +} + +func leaseTTL(value time.Duration) time.Duration { + if value <= 0 { + return workflow.DefaultLeaseTTL + } + return value +} + +func validateLeaseTTL(value time.Duration) error { + if value < 0 { + return fmt.Errorf("lease TTL must not be negative") + } + return nil +} + +func (s Service) readLease(store workflow.Store) (workflow.Lease, bool, *workflowIssue) { + lease, exists, err := store.ReadLease() + if err != nil { + return workflow.Lease{}, false, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: err.Error(), Hint: store.Paths.Lease, ExitCode: report.ExitConfig} + } + return lease, exists, nil +} + +// recoverExpiredLocked converts an active attempt with an expired lease into +// unknown. It is intentionally called only by a mutating operation holding the +// task lock; status derives the same condition without writing files. +func recoverExpiredLocked(store workflow.Store, snapshot workflow.Snapshot, lease workflow.Lease, now time.Time) (workflow.Snapshot, bool, *workflowIssue) { + if lease.TaskID != snapshot.TaskID { + return snapshot, false, &workflowIssue{Code: "LEASE_CONFLICT", Message: "workflow lease belongs to another task", ExitCode: report.ExitConflict} + } + if lease.ExpiresAt.IsZero() || now.Before(lease.ExpiresAt) || snapshot.ActiveAttempt == nil { + return snapshot, false, nil + } + if snapshot.Status != workflow.StatusRunning && snapshot.Status != workflow.StatusVerifying { + return snapshot, false, nil + } + attempt := snapshot.ActiveAttempt + attempt.Status = "unknown" + snapshot.Status = workflow.StatusUnknown + snapshot.LastAttemptID = attempt.ID + snapshot.UpdatedAt = now + event := workflow.NewEvent(snapshot.TaskID, workflow.NewID("recovery"), "attempt_unknown", snapshot, now, map[string]any{ + "reason": "workflow lease expired", + }) + event.AttemptID = attempt.ID + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return snapshot, false, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution} + } + return snapshot, true, nil +} + +func leaseMatches(lease workflow.Lease, ownerToken, taskID string, now time.Time) *workflowIssue { + if lease.TaskID != taskID { + return &workflowIssue{Code: "LEASE_CONFLICT", Message: "workflow lease belongs to another task", ExitCode: report.ExitConflict} + } + if !now.Before(lease.ExpiresAt) { + return &workflowIssue{Code: "STALE_LEASE", Message: "workflow lease has expired; inspect the worktree and explicitly resume", ExitCode: report.ExitConflict} + } + if ownerToken == "" || ownerToken != lease.OwnerToken { + return &workflowIssue{Code: "LEASE_CONFLICT", Message: "valid workflow owner token is required", ExitCode: report.ExitConflict} + } + return nil +} + +func leaseTaskMismatch(lease workflow.Lease, exists bool, taskID string) *workflowIssue { + if exists && lease.TaskID != taskID { + return &workflowIssue{Code: "LEASE_CONFLICT", Message: "workflow lease belongs to another task", ExitCode: report.ExitConflict} + } + return nil +} + +func finishAttempt(attempt *workflow.Attempt, status string, now time.Time) { + attempt.Status = status + attempt.FinishedAt = &now +} + +func (s Service) WorkflowValidate(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow validate", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + result.Data = map[string]any{ + "taskRoot": task.Task.Root, + "workflowPath": workflow.ConfigPath(task.Task.Root), + "configDigest": digest, + "configuration": cfg, + } + return result, report.ExitOK +} + +func (s Service) WorkflowStatus(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow status", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + leaseExpired := leaseExists && !time.Now().UTC().Before(lease.ExpiresAt) + if leaseExpired && snapshot.ActiveAttempt != nil && (snapshot.Status == workflow.StatusRunning || snapshot.Status == workflow.StatusVerifying) { + snapshot.Status = workflow.StatusUnknown + snapshot.ActiveAttempt.Status = "unknown" + result.Warn(report.Diagnostic{Code: "STALE_LEASE", Message: "active lease expired; workflow requires explicit recovery before continuing"}) + } + if leaseExists && lease.TaskID != task.Task.ID { + result.Warn(report.Diagnostic{Code: "LEASE_MISMATCH", Message: "workflow lease belongs to another task", Hint: store.Paths.Lease}) + if !snapshot.IsTerminal() { + snapshot.Status = workflow.StatusUnknown + if snapshot.ActiveAttempt != nil { + snapshot.ActiveAttempt.Status = "unknown" + } + } + } + if snapshot.ActiveAttempt != nil && (snapshot.Status == workflow.StatusRunning || snapshot.Status == workflow.StatusVerifying) && !leaseExists { + result.Warn(report.Diagnostic{Code: "LEASE_MISSING", Message: "active workflow attempt has no lease; explicit recovery is required", Hint: store.Paths.Lease}) + snapshot.Status = workflow.StatusUnknown + snapshot.ActiveAttempt.Status = "unknown" + } + if snapshot.ActiveAttempt == nil && (snapshot.Status == workflow.StatusRunning || snapshot.Status == workflow.StatusVerifying) { + result.Warn(report.Diagnostic{Code: "ACTIVE_ATTEMPT_MISSING", Message: "workflow state requires an active attempt but none exists", Hint: store.Paths.State}) + snapshot.Status = workflow.StatusUnknown + } + if leaseExists && snapshot.ActiveAttempt == nil && !snapshot.IsTerminal() && snapshot.Status != workflow.StatusAwaitingApproval { + result.Warn(report.Diagnostic{Code: "ORPHAN_LEASE", Message: "workflow lease exists without an active attempt; explicit recovery is required", Hint: store.Paths.Lease}) + snapshot.Status = workflow.StatusUnknown + } + if exists && snapshot.ConfigDigest != digest && !snapshot.IsTerminal() { + result.Warn(report.Diagnostic{Code: "CONFIG_CHANGED", Message: "workflow.yaml digest differs from the active execution context"}) + snapshot.Status = workflow.StatusUnknown + if snapshot.ActiveAttempt != nil { + snapshot.ActiveAttempt.Status = "unknown" + } + } + if preflight := s.validateWorkflowWorktrees(ctx, task, cfg); preflight != nil { + result.Warn(report.Diagnostic{Code: preflight.Code, Repo: preflight.Repo, Message: preflight.Message, Hint: preflight.Hint}) + } + events, err := store.ReadEvents(20) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: err.Error(), Hint: store.Paths.Events, ExitCode: report.ExitConfig}) + } + result.Data = map[string]any{ + "configured": true, + "taskRoot": task.Task.Root, + "workflowPath": workflow.ConfigPath(task.Task.Root), + "configDigest": digest, + "status": snapshot.Status, + "stage": snapshot.StageID, + "iteration": snapshot.Iteration, + "snapshotExists": exists, + "snapshot": snapshot, + "lease": lease, + "leaseExists": leaseExists, + "leaseExpired": leaseExpired, + "events": events, + } + return result, report.ExitOK +} + +func (s Service) WorkflowBegin(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow begin", o.TaskID) + engine, err := workflowEngine(o.Engine) + if err != nil { + return failWorkflow(&result, issue("INVALID_ARGUMENT", err.Error(), report.ExitConfig)) + } + if err := validateLeaseTTL(o.LeaseTTL); err != nil { + return failWorkflow(&result, issue("INVALID_ARGUMENT", err.Error(), report.ExitConfig)) + } + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, _, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow begin"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow begin"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + now := time.Now().UTC() + if leaseExists { + var recovered bool + snapshot, recovered, problem = recoverExpiredLocked(store, snapshot, lease, now) + if problem != nil { + return failWorkflow(&result, problem) + } + if recovered { + return failWorkflow(&result, &workflowIssue{Code: "STALE_LEASE", Message: "previous workflow lease expired; inspect the worktree before explicitly resuming", ExitCode: report.ExitConflict}) + } + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "another workflow session owns this task", ExitCode: report.ExitConflict}) + } + if snapshot.PendingApproval != nil { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_REQUIRED", Message: "resolve the pending approval before beginning another attempt", ExitCode: report.ExitConflict}) + } + if snapshot.Status != workflow.StatusReady { + return failWorkflow(&result, &workflowIssue{Code: "WORKFLOW_NOT_READY", Message: fmt.Sprintf("workflow is in %s; use resume or resolve the current attention state", snapshot.Status), ExitCode: report.ExitConflict}) + } + if reason, exceeded := budgetExceeded(snapshot, cfg, now); exceeded { + return failWorkflow(&result, &workflowIssue{Code: "BUDGET_EXHAUSTED", Message: reason, ExitCode: report.ExitConflict}) + } + stage, ok := workflow.StageAt(cfg, snapshot.StageIndex) + if !ok || stage.ID != snapshot.StageID { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_STAGE_INVALID", Message: "runtime stage does not match workflow configuration", Hint: store.Paths.State, ExitCode: report.ExitConfig}) + } + if snapshot.StageAttempts[stage.ID] >= stage.MaxAttempts { + return failWorkflow(&result, &workflowIssue{Code: "ATTEMPT_LIMIT_EXHAUSTED", Message: fmt.Sprintf("stage %s attempt limit is exhausted", stage.ID), ExitCode: report.ExitConflict}) + } + attemptID := workflow.NewID("attempt") + ownerToken := workflow.NewID("owner") + attemptNumber := snapshot.StageAttempts[stage.ID] + 1 + attempt := &workflow.Attempt{ + ID: attemptID, + StageID: stage.ID, + Iteration: snapshot.Iteration + 1, + StageAttempt: attemptNumber, + SessionID: o.SessionID, + Status: "active", + StartedAt: now, + } + paths, err := store.Paths.Attempt(attemptID) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_ATTEMPT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + attempt.ReportPath, _ = filepath.Rel(task.Task.Root, paths.Report) + if err := store.SavePrompt(attemptID, fmt.Sprintf("# Workflow attempt\n\nTask: %s\nStage: %s\n\n%s\n", task.Task.ID, stage.ID, stage.Objective)); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_EVIDENCE_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + snapshot.Status = workflow.StatusRunning + snapshot.ActiveAttempt = attempt + snapshot.LastAttemptID = attemptID + snapshot.Iteration++ + snapshot.StageAttempts[stage.ID] = attemptNumber + snapshot.UpdatedAt = now + lease = workflow.Lease{ + Version: workflow.RuntimeVersion, + TaskID: task.Task.ID, + Engine: engine, + SessionID: o.SessionID, + OwnerToken: ownerToken, + CreatedAt: now, + ExpiresAt: now.Add(leaseTTL(o.LeaseTTL)), + } + data := map[string]any{ + "operationID": opID, + "attemptID": attemptID, + "stageID": stage.ID, + "objective": stage.Objective, + "iteration": snapshot.Iteration, + "stageAttempt": attemptNumber, + "ownerToken": ownerToken, + "leaseExpiresAt": lease.ExpiresAt, + "reportPath": attempt.ReportPath, + } + snapshot.RecordOperation(opID, "workflow begin", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "begin", snapshot, now, map[string]any{ + "engine": lease.Engine, + "sessionID": o.SessionID, + "objective": stage.Objective, + }) + if err := store.Commit(snapshot, event, workflow.CommitOptions{Lease: &lease}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowCheckpoint(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow checkpoint", o.TaskID) + if err := validateLeaseTTL(o.LeaseTTL); err != nil { + return failWorkflow(&result, issue("INVALID_ARGUMENT", err.Error(), report.ExitConfig)) + } + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if strings.TrimSpace(o.ReportPath) == "" { + return failWorkflow(&result, issue("INVALID_ARGUMENT", "--report-file is required", report.ExitConfig)) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has no active attempt; run workflow begin first", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow checkpoint"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow checkpoint"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.ActiveAttempt == nil || snapshot.ActiveAttempt.ID != o.AttemptID { + return failWorkflow(&result, &workflowIssue{Code: "ATTEMPT_CONFLICT", Message: "checkpoint does not reference the active attempt", ExitCode: report.ExitConflict}) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if !leaseExists { + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "active workflow lease is missing", ExitCode: report.ExitConflict}) + } + now := time.Now().UTC() + snapshot, recovered, problem := recoverExpiredLocked(store, snapshot, lease, now) + if problem != nil { + return failWorkflow(&result, problem) + } + if recovered { + return failWorkflow(&result, &workflowIssue{Code: "STALE_LEASE", Message: "workflow lease expired; checkpoint was not accepted", ExitCode: report.ExitConflict}) + } + if problem := leaseMatches(lease, o.OwnerToken, task.Task.ID, now); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.Status != workflow.StatusRunning { + return failWorkflow(&result, &workflowIssue{Code: "CHECKPOINT_NOT_ALLOWED", Message: fmt.Sprintf("checkpoint is not allowed from workflow state %s", snapshot.Status), ExitCode: report.ExitConflict}) + } + reportRaw, err := readReportFile(task.Task.Root, o.ReportPath) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_CHECKPOINT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + agentReport, err := workflow.DecodeReport(reportRaw) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_CHECKPOINT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + if err := workflow.ValidateReport(agentReport); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_CHECKPOINT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + active := snapshot.ActiveAttempt + if agentReport.TaskID != task.Task.ID || agentReport.StageID != active.StageID || agentReport.AttemptID != active.ID { + return failWorkflow(&result, &workflowIssue{Code: "CHECKPOINT_IDENTITY_MISMATCH", Message: "checkpoint task, stage, or attempt does not match the active workflow", ExitCode: report.ExitConflict}) + } + if agentReport.SessionID != "" && active.SessionID != "" && agentReport.SessionID != active.SessionID { + return failWorkflow(&result, &workflowIssue{Code: "CHECKPOINT_SESSION_MISMATCH", Message: "checkpoint session does not match the active workflow lease", ExitCode: report.ExitConflict}) + } + if agentReport.Status == workflow.ReportNeedsApproval { + if cfg.Policy.ExternalActions != "approval" { + return failWorkflow(&result, &workflowIssue{Code: "ACTION_POLICY_DENIED", Message: "workflow policy does not allow approval-gated external actions", ExitCode: report.ExitConflict}) + } + if len(cfg.Policy.AllowedActions) > 0 && !containsAction(cfg.Policy.AllowedActions, agentReport.Approval.Action) { + return failWorkflow(&result, &workflowIssue{Code: "ACTION_NOT_ALLOWED", Message: fmt.Sprintf("approval action %q is not listed in workflow policy", agentReport.Approval.Action), ExitCode: report.ExitConflict}) + } + for _, previous := range snapshot.Approvals { + if previous.ID == agentReport.Approval.ID { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_CONFLICT", Message: fmt.Sprintf("approval ID %q has already been recorded", agentReport.Approval.ID), ExitCode: report.ExitConflict}) + } + } + } + reportDigest, err := workflow.ReportDigest(agentReport) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_CHECKPOINT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + if err := store.SaveReport(active.ID, agentReport); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_EVIDENCE_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + activePaths, err := store.Paths.Attempt(active.ID) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "INVALID_ATTEMPT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + active.ReportPath, _ = filepath.Rel(task.Task.Root, activePaths.Report) + active.ReportDigest = reportDigest + snapshot.Usage += agentReport.Usage + snapshot.LastAttemptID = active.ID + nextLease := lease + nextLease.ExpiresAt = now.Add(leaseTTL(o.LeaseTTL)) + clearLease := false + switch agentReport.Status { + case workflow.ReportProgress: + snapshot.Status = workflow.StatusRunning + case workflow.ReportReady: + snapshot.Status = workflow.StatusVerifying + case workflow.ReportBlocked: + finishAttempt(active, "blocked", now) + snapshot.ActiveAttempt = nil + snapshot.Status = workflow.StatusNeedsAttention + clearLease = true + case workflow.ReportNeedsApproval: + approval := workflow.Approval{ + ID: agentReport.Approval.ID, + Action: agentReport.Approval.Action, + Description: agentReport.Approval.Description, + RequestedAt: now, + } + snapshot.Approvals = append(snapshot.Approvals, approval) + snapshot.PendingApproval = &approval + finishAttempt(active, "awaiting_approval", now) + snapshot.ActiveAttempt = nil + snapshot.Status = workflow.StatusAwaitingApproval + clearLease = true + } + if reason, exceeded := checkpointBudgetExceeded(snapshot, cfg, agentReport.Status, now); exceeded && (snapshot.Status == workflow.StatusRunning || snapshot.Status == workflow.StatusVerifying) { + finishAttempt(active, "budget_exhausted", now) + snapshot.ActiveAttempt = nil + snapshot.Status = workflow.StatusNeedsAttention + clearLease = true + result.Warn(report.Diagnostic{Code: "BUDGET_EXHAUSTED", Message: reason}) + } + snapshot.UpdatedAt = now + data := map[string]any{ + "operationID": opID, + "attemptID": active.ID, + "stageID": active.StageID, + "reportStatus": agentReport.Status, + "status": snapshot.Status, + "reportPath": active.ReportPath, + } + snapshot.RecordOperation(opID, "workflow checkpoint", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "checkpoint", snapshot, now, map[string]any{ + "reportStatus": agentReport.Status, + "summary": agentReport.Summary, + }) + event.AttemptID = active.ID + options := workflow.CommitOptions{Lease: &nextLease} + if clearLease { + options = workflow.CommitOptions{ClearLease: true} + } + if err := store.Commit(snapshot, event, options); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowVerify(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow verify", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has no active attempt", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow verify"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow verify"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.ActiveAttempt == nil { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has no active attempt", report.ExitConflict)) + } + if o.AttemptID != "" && snapshot.ActiveAttempt.ID != o.AttemptID { + return failWorkflow(&result, &workflowIssue{Code: "ATTEMPT_CONFLICT", Message: "verify does not reference the active attempt", ExitCode: report.ExitConflict}) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if !leaseExists { + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "active workflow lease is missing", ExitCode: report.ExitConflict}) + } + now := time.Now().UTC() + snapshot, recovered, problem := recoverExpiredLocked(store, snapshot, lease, now) + if problem != nil { + return failWorkflow(&result, problem) + } + if recovered { + return failWorkflow(&result, &workflowIssue{Code: "STALE_LEASE", Message: "workflow lease expired; verification was not run", ExitCode: report.ExitConflict}) + } + if problem := leaseMatches(lease, o.OwnerToken, task.Task.ID, now); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.Status != workflow.StatusVerifying { + return failWorkflow(&result, &workflowIssue{Code: "VERIFY_NOT_ALLOWED", Message: fmt.Sprintf("verify is not allowed from workflow state %s", snapshot.Status), ExitCode: report.ExitConflict}) + } + active := snapshot.ActiveAttempt + agentReport, exists, err := store.ReadReport(active.ID) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: err.Error(), ExitCode: report.ExitConfig}) + } + if !exists || agentReport.Status != workflow.ReportReady { + return failWorkflow(&result, &workflowIssue{Code: "CHECKPOINT_REQUIRED", Message: "a ready checkpoint is required before verification", ExitCode: report.ExitConflict}) + } + if err := workflow.ValidateReport(agentReport); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: fmt.Sprintf("stored checkpoint report is invalid: %v", err), Hint: active.ReportPath, ExitCode: report.ExitConfig}) + } + if agentReport.TaskID != task.Task.ID || agentReport.StageID != active.StageID || agentReport.AttemptID != active.ID { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: "stored checkpoint report identity does not match the active workflow", Hint: active.ReportPath, ExitCode: report.ExitConfig}) + } + if agentReport.SessionID != "" && active.SessionID != "" && agentReport.SessionID != active.SessionID { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: "stored checkpoint report session does not match the active workflow", Hint: active.ReportPath, ExitCode: report.ExitConfig}) + } + if active.ReportDigest == "" { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: "active attempt has no checkpoint report digest", Hint: active.ReportPath, ExitCode: report.ExitConfig}) + } + storedReportDigest, err := workflow.ReportDigest(agentReport) + if err != nil || storedReportDigest != active.ReportDigest { + message := "stored checkpoint report digest does not match the checkpoint evidence" + if err != nil { + message = err.Error() + } + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_CORRUPT", Message: message, Hint: active.ReportPath, ExitCode: report.ExitConfig}) + } + stage, ok := workflow.StageAt(cfg, snapshot.StageIndex) + if !ok || stage.ID != snapshot.StageID { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_STAGE_INVALID", Message: "runtime stage does not match workflow configuration", ExitCode: report.ExitConfig}) + } + verification, err := workflow.RunChecks(ctx, task, cfg, stage, s.Runner) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "CHECK_EXECUTION_INVALID", Message: err.Error(), ExitCode: report.ExitConfig}) + } + verification.AttemptID = active.ID + failed := make([]string, 0) + checkIDs := make([]string, 0, len(verification.Checks)) + for index := range verification.Checks { + verification.Checks[index].AttemptID = active.ID + checkIDs = append(checkIDs, verification.Checks[index].ID) + if !verification.Checks[index].Passed { + failed = append(failed, verification.Checks[index].ID) + } + if err := store.SaveCheckResult(verification.Checks[index]); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_EVIDENCE_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + } + finishedAt := time.Now().UTC() + snapshot.LastVerification = &workflow.VerificationSummary{Passed: verification.Passed, CheckIDs: checkIDs, FailedCheck: failed, CompletedAt: verification.CompletedAt} + snapshot.LastAttemptID = active.ID + durationReason, durationExceeded := workflowDurationExceeded(snapshot, cfg, finishedAt) + attemptStatus := map[bool]string{true: "verified", false: "failed"}[verification.Passed] + if durationExceeded { + attemptStatus = "budget_exhausted" + } + finishAttempt(active, attemptStatus, finishedAt) + snapshot.ActiveAttempt = nil + if durationExceeded { + snapshot.Status = workflow.StatusNeedsAttention + result.Warn(report.Diagnostic{Code: "BUDGET_EXHAUSTED", Message: durationReason}) + } else if verification.Passed { + if snapshot.StageIndex+1 >= len(cfg.Stages) { + snapshot.Status = workflow.StatusCompleted + } else { + snapshot.StageIndex++ + snapshot.StageID = cfg.Stages[snapshot.StageIndex].ID + snapshot.Status = workflow.StatusReady + } + } else { + reason, exceeded := budgetExceeded(snapshot, cfg, finishedAt) + if exceeded || snapshot.StageAttempts[stage.ID] >= stage.MaxAttempts { + snapshot.Status = workflow.StatusNeedsAttention + if reason == "" { + reason = fmt.Sprintf("stage %s attempt limit is exhausted", stage.ID) + } + result.Warn(report.Diagnostic{Code: "NEEDS_ATTENTION", Message: reason}) + } else { + snapshot.Status = workflow.StatusReady + } + } + snapshot.UpdatedAt = finishedAt + data := map[string]any{ + "operationID": opID, + "attemptID": active.ID, + "stageID": stage.ID, + "passed": verification.Passed, + "failedChecks": failed, + "status": snapshot.Status, + "checks": verification.Checks, + } + snapshot.RecordOperation(opID, "workflow verify", map[string]any{ + "operationID": opID, + "attemptID": active.ID, + "stageID": stage.ID, + "passed": verification.Passed, + "failedChecks": failed, + "status": snapshot.Status, + }, finishedAt) + event := workflow.NewEvent(task.Task.ID, opID, "verify", snapshot, finishedAt, map[string]any{ + "passed": verification.Passed, + "failedChecks": failed, + }) + event.AttemptID = active.ID + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowPause(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow pause", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has not started", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow pause"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow pause"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := leaseTaskMismatch(lease, leaseExists, task.Task.ID); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.IsTerminal() { + return failWorkflow(&result, &workflowIssue{Code: "WORKFLOW_TERMINAL", Message: fmt.Sprintf("workflow is already %s", snapshot.Status), ExitCode: report.ExitConflict}) + } + if snapshot.Status == workflow.StatusAwaitingApproval || snapshot.PendingApproval != nil { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_REQUIRED", Message: "resolve the pending approval before pausing the workflow", ExitCode: report.ExitConflict}) + } + now := time.Now().UTC() + if snapshot.ActiveAttempt != nil { + if !leaseExists { + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "active workflow lease is missing; recover before pausing the attempt", ExitCode: report.ExitConflict}) + } + if problem := leaseMatches(lease, o.OwnerToken, task.Task.ID, now); problem != nil { + return failWorkflow(&result, problem) + } + finishAttempt(snapshot.ActiveAttempt, "paused", now) + snapshot.LastAttemptID = snapshot.ActiveAttempt.ID + snapshot.ActiveAttempt = nil + } + snapshot.Status = workflow.StatusPaused + snapshot.UpdatedAt = now + data := map[string]any{"operationID": opID, "status": snapshot.Status, "reason": o.Reason} + snapshot.RecordOperation(opID, "workflow pause", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "pause", snapshot, now, map[string]any{"reason": o.Reason}) + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowResume(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow resume", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := s.validateWorkflowWorktrees(ctx, task, cfg); problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has not started", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow resume"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow resume"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + if !o.Recover { + return failWorkflow(&result, problem) + } + snapshot.ConfigDigest = digest + snapshot.StageAttempts = map[string]int{} + snapshot.Iteration = 0 + snapshot.Usage = 0 + snapshot.StageIndex = 0 + snapshot.StageID = cfg.Stages[0].ID + snapshot.LastVerification = nil + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := leaseTaskMismatch(lease, leaseExists, task.Task.ID); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.Status == workflow.StatusRunning || snapshot.Status == workflow.StatusVerifying { + if leaseExists && !time.Now().UTC().Before(lease.ExpiresAt) { + snapshot, _, problem = recoverExpiredLocked(store, snapshot, lease, time.Now().UTC()) + if problem != nil { + return failWorkflow(&result, problem) + } + } else { + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "active workflow session still owns this task", ExitCode: report.ExitConflict}) + } + } + now := time.Now().UTC() + switch snapshot.Status { + case workflow.StatusPaused: + if snapshot.PendingApproval != nil { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_REQUIRED", Message: "resolve the pending approval before resuming the workflow", ExitCode: report.ExitConflict}) + } + snapshot.Status = workflow.StatusReady + case workflow.StatusUnknown, workflow.StatusNeedsAttention: + if !o.Recover { + return failWorkflow(&result, &workflowIssue{Code: "RECOVERY_REQUIRED", Message: "explicit --recover is required for unknown or needs_attention workflow state", ExitCode: report.ExitConflict}) + } + if snapshot.ActiveAttempt != nil { + finishAttempt(snapshot.ActiveAttempt, "recovered", now) + snapshot.LastAttemptID = snapshot.ActiveAttempt.ID + snapshot.ActiveAttempt = nil + } + snapshot.Status = workflow.StatusReady + case workflow.StatusReady: + // Treat resume on an already-ready workflow as an idempotent no-op. + case workflow.StatusAwaitingApproval: + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_REQUIRED", Message: "resolve the pending approval before resuming", ExitCode: report.ExitConflict}) + case workflow.StatusCompleted, workflow.StatusCancelled: + return failWorkflow(&result, &workflowIssue{Code: "WORKFLOW_TERMINAL", Message: fmt.Sprintf("workflow is already %s", snapshot.Status), ExitCode: report.ExitConflict}) + default: + return failWorkflow(&result, &workflowIssue{Code: "WORKFLOW_STATE_INVALID", Message: fmt.Sprintf("cannot resume workflow from %s", snapshot.Status), ExitCode: report.ExitConfig}) + } + snapshot.UpdatedAt = now + data := map[string]any{"operationID": opID, "status": snapshot.Status, "recovered": o.Recover} + snapshot.RecordOperation(opID, "workflow resume", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "resume", snapshot, now, map[string]any{"recovered": o.Recover}) + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowApprove(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow approve", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has not started", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow approve"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow approve"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := leaseTaskMismatch(lease, leaseExists, task.Task.ID); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.Status != workflow.StatusAwaitingApproval || snapshot.PendingApproval == nil { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_CONFLICT", Message: "workflow has no pending approval", ExitCode: report.ExitConflict}) + } + if cfg.Policy.ExternalActions != "approval" { + return failWorkflow(&result, &workflowIssue{Code: "ACTION_POLICY_DENIED", Message: "workflow policy no longer allows approval-gated external actions", ExitCode: report.ExitConflict}) + } + if len(cfg.Policy.AllowedActions) > 0 && !containsAction(cfg.Policy.AllowedActions, snapshot.PendingApproval.Action) { + return failWorkflow(&result, &workflowIssue{Code: "ACTION_NOT_ALLOWED", Message: fmt.Sprintf("approval action %q is not listed in workflow policy", snapshot.PendingApproval.Action), ExitCode: report.ExitConflict}) + } + if o.ApprovalID == "" || o.ApprovalID != snapshot.PendingApproval.ID { + return failWorkflow(&result, &workflowIssue{Code: "APPROVAL_CONFLICT", Message: "approval ID is unknown or no longer pending", ExitCode: report.ExitConflict}) + } + decision := strings.ToLower(strings.TrimSpace(o.Decision)) + if decision == "" { + decision = "approve" + } + if decision != "approve" && decision != "reject" { + return failWorkflow(&result, issue("INVALID_ARGUMENT", "decision must be approve or reject", report.ExitConfig)) + } + now := time.Now().UTC() + for index := range snapshot.Approvals { + if snapshot.Approvals[index].ID == o.ApprovalID { + snapshot.Approvals[index].Decision = decision + snapshot.Approvals[index].Reason = o.Reason + snapshot.Approvals[index].DecidedAt = &now + } + } + snapshot.PendingApproval = nil + if decision == "approve" { + snapshot.Status = workflow.StatusReady + } else { + snapshot.Status = workflow.StatusNeedsAttention + } + snapshot.UpdatedAt = now + data := map[string]any{"operationID": opID, "approvalID": o.ApprovalID, "decision": decision, "status": snapshot.Status} + snapshot.RecordOperation(opID, "workflow approve", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "approval", snapshot, now, map[string]any{"approvalID": o.ApprovalID, "decision": decision, "reason": o.Reason}) + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func (s Service) WorkflowCancel(ctx context.Context, o WorkflowOptions) (report.Result, report.ExitCode) { + result := report.New("workflow cancel", o.TaskID) + task, cfg, digest, problem := s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + taskLock, err := lock.Acquire(task.Task.Root) + if err != nil { + return failWorkflow(&result, &workflowIssue{Code: "TASK_LOCKED", Message: err.Error(), ExitCode: report.ExitConflict}) + } + defer taskLock.Release() + task, cfg, digest, problem = s.loadWorkflow(o.TasksRoot, o.TaskID) + if problem != nil { + return failWorkflow(&result, problem) + } + store := workflow.NewStore(task.Task.Root) + snapshot, exists, problem := s.readWorkflowState(store, task.Task.ID, cfg, digest) + if problem != nil { + return failWorkflow(&result, problem) + } + if !exists { + return failWorkflow(&result, issue("WORKFLOW_NOT_STARTED", "workflow has not started", report.ExitConflict)) + } + opID := workflowOperationID(o.OperationID) + if previous, ok := snapshot.Operation(opID, "workflow cancel"); ok { + result.Data = previous.Result + return result, report.ExitOK + } + if problem := rejectOperationReuse(snapshot, opID, "workflow cancel"); problem != nil { + return failWorkflow(&result, problem) + } + if problem := requireWorkflowDigest(snapshot, digest); problem != nil { + return failWorkflow(&result, problem) + } + lease, leaseExists, problem := s.readLease(store) + if problem != nil { + return failWorkflow(&result, problem) + } + if problem := leaseTaskMismatch(lease, leaseExists, task.Task.ID); problem != nil { + return failWorkflow(&result, problem) + } + if snapshot.IsTerminal() { + return failWorkflow(&result, &workflowIssue{Code: "WORKFLOW_TERMINAL", Message: fmt.Sprintf("workflow is already %s", snapshot.Status), ExitCode: report.ExitConflict}) + } + if snapshot.ActiveAttempt != nil { + if !leaseExists { + return failWorkflow(&result, &workflowIssue{Code: "LEASE_CONFLICT", Message: "active workflow lease is missing; recover before cancelling the attempt", ExitCode: report.ExitConflict}) + } + if !time.Now().UTC().Before(lease.ExpiresAt) { + return failWorkflow(&result, &workflowIssue{Code: "STALE_LEASE", Message: "active lease expired; recover before cancelling the active attempt", ExitCode: report.ExitConflict}) + } + if problem := leaseMatches(lease, o.OwnerToken, task.Task.ID, time.Now().UTC()); problem != nil { + return failWorkflow(&result, problem) + } + now := time.Now().UTC() + finishAttempt(snapshot.ActiveAttempt, "cancelled", now) + snapshot.LastAttemptID = snapshot.ActiveAttempt.ID + snapshot.ActiveAttempt = nil + } + snapshot.PendingApproval = nil + now := time.Now().UTC() + snapshot.Status = workflow.StatusCancelled + snapshot.UpdatedAt = now + data := map[string]any{"operationID": opID, "status": snapshot.Status, "reason": o.Reason} + snapshot.RecordOperation(opID, "workflow cancel", data, now) + event := workflow.NewEvent(task.Task.ID, opID, "cancel", snapshot, now, map[string]any{"reason": o.Reason}) + if err := store.Commit(snapshot, event, workflow.CommitOptions{ClearLease: true}); err != nil { + return failWorkflow(&result, &workflowIssue{Code: "RUNTIME_COMMIT_FAILED", Message: err.Error(), ExitCode: report.ExitExecution}) + } + result.Data = data + return result, report.ExitOK +} + +func readReportFile(taskRoot, path string) ([]byte, error) { + canonical, err := fsx.CanonicalExisting(path) + if err != nil { + return nil, err + } + if !fsx.Within(taskRoot, canonical) { + return nil, fmt.Errorf("report path %q escapes task root", path) + } + file, err := os.Open(canonical) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, workflow.MaxReportBytes+1)) + if err != nil { + return nil, err + } + if len(raw) > workflow.MaxReportBytes { + return nil, fmt.Errorf("report file exceeds %d bytes", workflow.MaxReportBytes) + } + return raw, nil +} + +func workflowEngine(value string) (string, error) { + engine := strings.ToLower(strings.TrimSpace(value)) + if engine == "" { + return "unknown", nil + } + switch engine { + case "unknown", "codex", "claude": + return engine, nil + default: + return "", fmt.Errorf("unsupported workflow engine %q; choose codex, claude, or unknown", value) + } +} + +func containsAction(actions []string, wanted string) bool { + for _, action := range actions { + if action == wanted { + return true + } + } + return false +} diff --git a/internal/app/workflow_test.go b/internal/app/workflow_test.go new file mode 100644 index 0000000..da3c61d --- /dev/null +++ b/internal/app/workflow_test.go @@ -0,0 +1,730 @@ +package app + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/chenquan/taskflow/internal/report" + "github.com/chenquan/taskflow/internal/workflow" +) + +func writeWorkflowConfig(t *testing.T, taskRoot, taskID, command string, maxAttempts int) string { + t.Helper() + path := filepath.Join(taskRoot, "workflow.yaml") + raw := `version: 1 +task: + id: TASK_ID +limits: + max_iterations: 10 + max_duration: 1h +stages: + - id: implement + objective: implement and verify the change + max_attempts: MAX_ATTEMPTS + checks: [result] +checks: + - id: result + argv: ["COMMAND"] + cwd: "repo:repo" + timeout: 2s +policy: + external_actions: deny +` + raw = strings.ReplaceAll(raw, "TASK_ID", taskID) + raw = strings.ReplaceAll(raw, "MAX_ATTEMPTS", string(rune('0'+maxAttempts))) + raw = strings.ReplaceAll(raw, "COMMAND", command) + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func setupWorkflowTask(t *testing.T, taskID, command string, maxAttempts int) (Service, string, string) { + t.Helper() + repo := makeGitRepo(t) + tasks := t.TempDir() + service := New() + if result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: taskID, Repositories: []string{"repo=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("create workflow task: code=%d result=%#v", code, result) + } + task, err := service.Load(tasks, taskID) + if err != nil { + t.Fatal(err) + } + writeWorkflowConfig(t, task.Task.Root, taskID, command, maxAttempts) + return service, tasks, task.Task.Root +} + +func checkpointReport(t *testing.T, taskRoot string, report workflow.AgentReport) string { + t.Helper() + path := filepath.Join(taskRoot, "checkpoint.json") + raw, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0644); err != nil { + t.Fatal(err) + } + return path +} + +func beginData(t *testing.T, result report.Result) (string, string) { + t.Helper() + data, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected begin data: %#v", result.Data) + } + attemptID, _ := data["attemptID"].(string) + ownerToken, _ := data["ownerToken"].(string) + if attemptID == "" || ownerToken == "" { + t.Fatalf("missing begin identity: %#v", data) + } + return attemptID, ownerToken +} + +func TestWorkflowLifecycleRequiresMachineVerification(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "FLOW", "true", 2) + if result, code := service.WorkflowValidate(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FLOW"}); code != report.ExitOK || !result.OK { + t.Fatalf("validate: code=%d result=%#v", code, result) + } + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FLOW", Engine: "codex", SessionID: "session-1", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{ + Version: workflow.RuntimeVersion, + TaskID: "FLOW", + StageID: "implement", + AttemptID: attemptID, + SessionID: "session-1", + Status: workflow.ReportReady, + Summary: "implementation is ready for checks", + ChangedPaths: []string{"main.go"}, + Commands: []workflow.CommandRecord{}, + Risks: []string{}, + NextAction: "verify", + }) + checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FLOW", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}) + if code != report.ExitOK || !checkpoint.OK { + t.Fatalf("checkpoint: code=%d result=%#v", code, checkpoint) + } + verify, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FLOW", AttemptID: attemptID, OwnerToken: ownerToken, OperationID: "verify-1"}) + if code != report.ExitOK || !verify.OK { + t.Fatalf("verify: code=%d result=%#v", code, verify) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FLOW"}) + if code != report.ExitOK || !status.OK { + t.Fatalf("status: code=%d result=%#v", code, status) + } + data := status.Data.(map[string]any) + if data["status"] != workflow.StatusCompleted { + t.Fatalf("expected completed status, got %#v", data["status"]) + } + for _, path := range []string{ + filepath.Join(taskRoot, ".taskflow", "workflow-state.json"), + filepath.Join(taskRoot, ".taskflow", "workflow-events.jsonl"), + filepath.Join(taskRoot, ".taskflow", "workflow", "attempts", attemptID, "prompt.md"), + filepath.Join(taskRoot, ".taskflow", "workflow", "attempts", attemptID, "checks", "result.json"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("workflow evidence missing %s: %v", path, err) + } + } +} + +func TestWorkflowDoesNotTreatReportAsCompletion(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "REPORT", "false", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "REPORT", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "REPORT", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportReady, Summary: "done", Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "verify"}) + if result, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "REPORT", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}); code != report.ExitOK || !result.OK { + t.Fatalf("checkpoint: code=%d result=%#v", code, result) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "REPORT"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusVerifying { + t.Fatalf("report incorrectly completed workflow: code=%d status=%#v", code, status) + } + verify, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "REPORT", AttemptID: attemptID, OwnerToken: ownerToken, OperationID: "verify-1"}) + if code != report.ExitOK || !verify.OK { + t.Fatalf("verify failure should be recorded: code=%d result=%#v", code, verify) + } + status, _ = service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "REPORT"}) + if status.Data.(map[string]any)["status"] != workflow.StatusNeedsAttention { + t.Fatalf("expected needs_attention after exhausted failure, got %#v", status.Data.(map[string]any)["status"]) + } +} + +func TestWorkflowOperationAndPauseResumeAreIdempotent(t *testing.T) { + service, tasks, _ := setupWorkflowTask(t, "IDEMPOTENT", "true", 2) + first, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OperationID: "same-begin"}) + if code != report.ExitOK || !first.OK { + t.Fatalf("first begin: code=%d result=%#v", code, first) + } + second, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OperationID: "same-begin"}) + if code != report.ExitOK || !second.OK || second.Data.(map[string]any)["attemptID"] != first.Data.(map[string]any)["attemptID"] { + t.Fatalf("duplicate begin not idempotent: code=%d first=%#v second=%#v", code, first, second) + } + operationConflict, code := service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OperationID: "same-begin"}) + if code != report.ExitConflict || operationConflict.OK || !hasDiagnostic(operationConflict.Errors, "OPERATION_CONFLICT") { + t.Fatalf("operation ID was reused across commands: code=%d result=%#v", code, operationConflict) + } + attemptID, ownerToken := beginData(t, first) + paused, code := service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OwnerToken: ownerToken, OperationID: "pause-1", Reason: "manual inspection"}) + if code != report.ExitOK || !paused.OK { + t.Fatalf("pause: code=%d result=%#v", code, paused) + } + pausedAgain, code := service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OwnerToken: ownerToken, OperationID: "pause-1"}) + if code != report.ExitOK || !pausedAgain.OK { + t.Fatalf("duplicate pause: code=%d result=%#v", code, pausedAgain) + } + resumed, code := service.WorkflowResume(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OperationID: "resume-1"}) + if code != report.ExitOK || !resumed.OK || resumed.Data.(map[string]any)["status"] != workflow.StatusReady { + t.Fatalf("resume: code=%d result=%#v", code, resumed) + } + nextBegin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "IDEMPOTENT", OperationID: "begin-2"}) + if code != report.ExitOK || !nextBegin.OK || nextBegin.Data.(map[string]any)["attemptID"] == attemptID { + t.Fatalf("resume did not create a new attempt: code=%d result=%#v", code, nextBegin) + } +} + +func TestWorkflowControlsDoNotCreateRuntimeBeforeBegin(t *testing.T) { + for _, name := range []string{"pause", "cancel"} { + t.Run(name, func(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "NOT-STARTED-"+name, "true", 1) + var result report.Result + var code report.ExitCode + switch name { + case "pause": + result, code = service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "NOT-STARTED-" + name, OperationID: "control-1"}) + case "cancel": + result, code = service.WorkflowCancel(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "NOT-STARTED-" + name, OperationID: "control-1"}) + } + if code != report.ExitConflict || result.OK || !hasDiagnostic(result.Errors, "WORKFLOW_NOT_STARTED") { + t.Fatalf("%s before begin was accepted: code=%d result=%#v", name, code, result) + } + if _, err := os.Stat(filepath.Join(taskRoot, ".taskflow", "workflow-state.json")); !os.IsNotExist(err) { + t.Fatalf("%s before begin created runtime state: %v", name, err) + } + }) + } +} + +func TestWorkflowVerifyReplayIsIdempotentAfterTerminalTransition(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "VERIFY-REPLAY", "true", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "VERIFY-REPLAY", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{ + Version: workflow.RuntimeVersion, TaskID: "VERIFY-REPLAY", StageID: "implement", AttemptID: attemptID, + Status: workflow.ReportReady, Summary: "ready", Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "verify", + }) + if checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "VERIFY-REPLAY", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}); code != report.ExitOK || !checkpoint.OK { + t.Fatalf("checkpoint: code=%d result=%#v", code, checkpoint) + } + first, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "VERIFY-REPLAY", AttemptID: attemptID, OwnerToken: ownerToken, OperationID: "verify-1"}) + if code != report.ExitOK || !first.OK { + t.Fatalf("first verify: code=%d result=%#v", code, first) + } + second, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "VERIFY-REPLAY", AttemptID: "different-attempt", OwnerToken: "different-owner", OperationID: "verify-1"}) + data, _ := second.Data.(map[string]any) + if code != report.ExitOK || !second.OK || data["status"] != string(workflow.StatusCompleted) || data["passed"] != true { + t.Fatalf("terminal verify replay was not idempotent: code=%d result=%#v", code, second) + } +} + +func TestWorkflowActiveAttemptRequiresLeaseForPauseAndCancel(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "MISSING-LEASE", "true", 2) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "MISSING-LEASE", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + store := workflow.NewStore(taskRoot) + if err := os.Remove(store.Paths.Lease); err != nil { + t.Fatal(err) + } + if result, code := service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "MISSING-LEASE", OperationID: "pause-1"}); code != report.ExitConflict || result.OK || !hasDiagnostic(result.Errors, "LEASE_CONFLICT") { + t.Fatalf("pause without lease was accepted: code=%d result=%#v", code, result) + } + if result, code := service.WorkflowCancel(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "MISSING-LEASE", OperationID: "cancel-1"}); code != report.ExitConflict || result.OK || !hasDiagnostic(result.Errors, "LEASE_CONFLICT") { + t.Fatalf("cancel without lease was accepted: code=%d result=%#v", code, result) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "MISSING-LEASE"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusUnknown || !hasDiagnostic(status.Warnings, "LEASE_MISSING") { + t.Fatalf("missing lease was not surfaced safely: code=%d result=%#v", code, status) + } +} + +func TestWorkflowCannotPauseAndResumeAroundApproval(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "APPROVAL-PAUSE", "true", 2) + workflowPath := filepath.Join(taskRoot, "workflow.yaml") + raw, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatal(err) + } + raw = []byte(strings.Replace(string(raw), "external_actions: deny", "external_actions: approval\n allowed_actions: [manual-review]", 1)) + if err := os.WriteFile(workflowPath, raw, 0644); err != nil { + t.Fatal(err) + } + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL-PAUSE", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{ + Version: workflow.RuntimeVersion, TaskID: "APPROVAL-PAUSE", StageID: "implement", AttemptID: attemptID, + Status: workflow.ReportNeedsApproval, Summary: "external action requested", + Approval: &workflow.ApprovalRequest{ID: "approval-1", Action: "manual-review", Description: "review the action"}, + Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "wait", + }) + checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL-PAUSE", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}) + if code != report.ExitOK || checkpoint.Data.(map[string]any)["status"] != workflow.StatusAwaitingApproval { + t.Fatalf("approval checkpoint: code=%d result=%#v", code, checkpoint) + } + if paused, code := service.WorkflowPause(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL-PAUSE", OperationID: "pause-1"}); code != report.ExitConflict || paused.OK || !hasDiagnostic(paused.Errors, "APPROVAL_REQUIRED") { + t.Fatalf("approval was bypassed by pause: code=%d result=%#v", code, paused) + } + if resumed, code := service.WorkflowResume(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL-PAUSE", OperationID: "resume-1"}); code != report.ExitConflict || resumed.OK || !hasDiagnostic(resumed.Errors, "APPROVAL_REQUIRED") { + t.Fatalf("approval was bypassed by resume: code=%d result=%#v", code, resumed) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL-PAUSE"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusAwaitingApproval { + t.Fatalf("approval state changed unexpectedly: code=%d result=%#v", code, status) + } +} + +func TestWorkflowVerifyRejectsTamperedCheckpointReport(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "TAMPERED-REPORT", "true", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "TAMPERED-REPORT", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "TAMPERED-REPORT", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportReady, Summary: "ready", Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "verify"}) + if checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "TAMPERED-REPORT", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}); code != report.ExitOK || !checkpoint.OK { + t.Fatalf("checkpoint: code=%d result=%#v", code, checkpoint) + } + storedPath := filepath.Join(taskRoot, ".taskflow", "workflow", "attempts", attemptID, "report.json") + stored, err := os.ReadFile(storedPath) + if err != nil { + t.Fatal(err) + } + stored = []byte(strings.Replace(string(stored), `"summary": "ready"`, `"summary": "tampered after checkpoint"`, 1)) + if err := os.WriteFile(storedPath, stored, 0644); err != nil { + t.Fatal(err) + } + verified, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "TAMPERED-REPORT", AttemptID: attemptID, OwnerToken: ownerToken, OperationID: "verify-1"}) + if code != report.ExitConfig || verified.OK || !hasDiagnostic(verified.Errors, "RUNTIME_CORRUPT") { + t.Fatalf("tampered report was accepted: code=%d result=%#v", code, verified) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "TAMPERED-REPORT"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusVerifying || len(status.Data.(map[string]any)["events"].([]workflow.Event)) != 2 { + t.Fatalf("tampered verification changed state: code=%d result=%#v", code, status) + } +} + +func TestWorkflowRejectsForeignLeaseWithoutClearingIt(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "FOREIGN-LEASE", "true", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FOREIGN-LEASE", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + store := workflow.NewStore(taskRoot) + lease, exists, err := store.ReadLease() + if err != nil || !exists { + t.Fatalf("read lease: exists=%v err=%v", exists, err) + } + lease.TaskID = "OTHER-TASK" + if err := workflow.SaveLease(store.Paths.Lease, lease); err != nil { + t.Fatal(err) + } + result, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "FOREIGN-LEASE", OperationID: "begin-2"}) + if code != report.ExitConflict || result.OK || !hasDiagnostic(result.Errors, "LEASE_CONFLICT") { + t.Fatalf("foreign lease was not rejected: code=%d result=%#v", code, result) + } + if _, exists, err := store.ReadLease(); err != nil || !exists { + t.Fatalf("foreign lease was cleared: exists=%v err=%v", exists, err) + } +} + +func TestWorkflowApprovalAndConfigChangeFailClosed(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "APPROVAL", "true", 2) + workflowPath := filepath.Join(taskRoot, "workflow.yaml") + raw, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatal(err) + } + approvalConfig := strings.Replace(string(raw), "external_actions: deny", "external_actions: approval\n allowed_actions: [manual-review]", 1) + if err := os.WriteFile(workflowPath, []byte(approvalConfig), 0644); err != nil { + t.Fatal(err) + } + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "APPROVAL", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportNeedsApproval, Summary: "external action requested", Approval: &workflow.ApprovalRequest{ID: "approval-1", Action: "manual-review", Description: "review the proposed external action"}, Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "wait"}) + checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}) + if code != report.ExitOK || checkpoint.Data.(map[string]any)["status"] != workflow.StatusAwaitingApproval { + t.Fatalf("approval checkpoint: code=%d result=%#v", code, checkpoint) + } + approved, code := service.WorkflowApprove(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL", ApprovalID: "approval-1", Decision: "approve", OperationID: "approve-1"}) + if code != report.ExitOK || approved.Data.(map[string]any)["status"] != workflow.StatusReady { + t.Fatalf("approval: code=%d result=%#v", code, approved) + } + begin, code = service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL", OperationID: "begin-2"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin after approval: code=%d result=%#v", code, begin) + } + attemptID, ownerToken = beginData(t, begin) + raw, err = os.ReadFile(workflowPath) + if err != nil { + t.Fatal(err) + } + changedRaw := strings.Replace(string(raw), "implement and verify the change", "implement and verify the changed objective", 1) + if err := os.WriteFile(workflowPath, []byte(changedRaw), 0644); err != nil { + t.Fatal(err) + } + reportPath = checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "APPROVAL", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportReady, Summary: "ready", Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "verify"}) + changed, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "APPROVAL", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-2"}) + if code != report.ExitConflict || changed.OK || !hasDiagnostic(changed.Errors, "CONFIG_CHANGED") { + t.Fatalf("expected config changed refusal: code=%d result=%#v", code, changed) + } +} + +func TestWorkflowExpiredLeaseRequiresExplicitRecovery(t *testing.T) { + service, tasks, _ := setupWorkflowTask(t, "EXPIRED", "true", 2) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "EXPIRED", LeaseTTL: time.Nanosecond, OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + time.Sleep(3 * time.Millisecond) + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "EXPIRED"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusUnknown { + t.Fatalf("expected derived unknown state: code=%d status=%#v", code, status) + } + resume, code := service.WorkflowResume(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "EXPIRED", OperationID: "resume-1"}) + if code != report.ExitConflict || resume.OK || !hasDiagnostic(resume.Errors, "RECOVERY_REQUIRED") { + t.Fatalf("expected explicit recovery requirement: code=%d result=%#v", code, resume) + } + recovered, code := service.WorkflowResume(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "EXPIRED", Recover: true, OperationID: "resume-2"}) + if code != report.ExitOK || !recovered.OK || recovered.Data.(map[string]any)["status"] != workflow.StatusReady { + t.Fatalf("recovery failed: code=%d result=%#v", code, recovered) + } +} + +func TestWorkflowBeginAllowsOnlyOneConcurrentSession(t *testing.T) { + service, tasks, _ := setupWorkflowTask(t, "CONCURRENT", "true", 2) + results := make(chan report.Result, 2) + codes := make(chan report.ExitCode, 2) + var group sync.WaitGroup + for _, operationID := range []string{"begin-a", "begin-b"} { + group.Add(1) + go func(operationID string) { + defer group.Done() + result, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "CONCURRENT", OperationID: operationID}) + results <- result + codes <- code + }(operationID) + } + group.Wait() + close(results) + close(codes) + successes := 0 + conflicts := 0 + for result := range results { + if result.OK { + successes++ + } else if hasDiagnostic(result.Errors, "LEASE_CONFLICT") || hasDiagnostic(result.Errors, "TASK_LOCKED") { + conflicts++ + } else { + t.Fatalf("unexpected concurrent result: %#v", result) + } + } + for range codes { + // Drain the codes channel so the test also verifies both calls returned. + } + if successes != 1 || conflicts != 1 { + t.Fatalf("concurrent begin results: successes=%d conflicts=%d", successes, conflicts) + } +} + +func TestWorkflowRejectsUnsupportedEngineBeforeMutation(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "ENGINE", "true", 1) + result, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "ENGINE", Engine: "other", OperationID: "begin-1"}) + if code != report.ExitConfig || result.OK || !hasDiagnostic(result.Errors, "INVALID_ARGUMENT") { + t.Fatalf("expected unsupported engine rejection: code=%d result=%#v", code, result) + } + if _, err := os.Stat(filepath.Join(taskRoot, ".taskflow", "workflow-state.json")); !os.IsNotExist(err) { + t.Fatalf("unsupported engine created runtime state: %v", err) + } +} + +func TestWorkflowCanFinishTheAttemptThatReachesIterationLimit(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "ITERATION-LIMIT", "true", 1) + workflowPath := filepath.Join(taskRoot, "workflow.yaml") + raw, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(workflowPath, []byte(strings.Replace(string(raw), "max_iterations: 10", "max_iterations: 1", 1)), 0644); err != nil { + t.Fatal(err) + } + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "ITERATION-LIMIT", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "ITERATION-LIMIT", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportReady, Summary: "ready", Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "verify"}) + checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "ITERATION-LIMIT", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}) + if code != report.ExitOK || checkpoint.Data.(map[string]any)["status"] != workflow.StatusVerifying { + t.Fatalf("checkpoint at iteration limit: code=%d result=%#v", code, checkpoint) + } + verified, code := service.WorkflowVerify(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "ITERATION-LIMIT", AttemptID: attemptID, OwnerToken: ownerToken, OperationID: "verify-1"}) + if code != report.ExitOK || verified.Data.(map[string]any)["status"] != workflow.StatusCompleted { + t.Fatalf("verification at iteration limit: code=%d result=%#v", code, verified) + } +} + +func TestWorkflowApprovalMustBeAllowedByPolicy(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "POLICY", "true", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "POLICY", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + attemptID, ownerToken := beginData(t, begin) + reportPath := checkpointReport(t, taskRoot, workflow.AgentReport{Version: workflow.RuntimeVersion, TaskID: "POLICY", StageID: "implement", AttemptID: attemptID, Status: workflow.ReportNeedsApproval, Summary: "external action requested", Approval: &workflow.ApprovalRequest{ID: "approval-1", Action: "manual-review", Description: "review the proposed external action"}, Commands: []workflow.CommandRecord{}, Risks: []string{}, NextAction: "wait"}) + checkpoint, code := service.WorkflowCheckpoint(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "POLICY", AttemptID: attemptID, OwnerToken: ownerToken, ReportPath: reportPath, OperationID: "checkpoint-1"}) + if code != report.ExitConflict || checkpoint.OK || !hasDiagnostic(checkpoint.Errors, "ACTION_POLICY_DENIED") { + t.Fatalf("expected policy denial: code=%d result=%#v", code, checkpoint) + } + status, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "POLICY"}) + if code != report.ExitOK || status.Data.(map[string]any)["status"] != workflow.StatusRunning { + t.Fatalf("policy denial changed active state: code=%d result=%#v", code, status) + } +} + +func TestDeleteCleansWorkflowArtifactsWithoutChangingWorktreeSemantics(t *testing.T) { + service, tasks, taskRoot := setupWorkflowTask(t, "DELETE-WORKFLOW", "true", 1) + begin, code := service.WorkflowBegin(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "DELETE-WORKFLOW", OperationID: "begin-1"}) + if code != report.ExitOK || !begin.OK { + t.Fatalf("begin: code=%d result=%#v", code, begin) + } + _, ownerToken := beginData(t, begin) + if result, code := service.WorkflowCancel(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "DELETE-WORKFLOW", OwnerToken: ownerToken, OperationID: "cancel-1", Reason: "cleanup"}); code != report.ExitOK || !result.OK { + t.Fatalf("cancel: code=%d result=%#v", code, result) + } + for _, path := range []string{ + filepath.Join(taskRoot, "workflow.yaml"), + filepath.Join(taskRoot, ".taskflow", "workflow-state.json"), + filepath.Join(taskRoot, ".taskflow", "workflow-events.jsonl"), + } { + if _, err := os.Stat(path); err != nil { + t.Fatalf("workflow artifact missing before delete %s: %v", path, err) + } + } + if result, code := service.Delete(context.Background(), DeleteOptions{TasksRoot: tasks, TaskID: "DELETE-WORKFLOW", Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("delete: code=%d result=%#v", code, result) + } + if _, err := os.Stat(taskRoot); !os.IsNotExist(err) { + t.Fatalf("workflow task directory remains after delete: %v", err) + } +} + +func TestWorkflowWithoutConfigurationRemainsWorktreeOnly(t *testing.T) { + repo := makeGitRepo(t) + tasks := t.TempDir() + service := New() + if result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "NO-WORKFLOW", Repositories: []string{"repo=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("create: code=%d result=%#v", code, result) + } + result, code := service.WorkflowStatus(context.Background(), WorkflowOptions{TasksRoot: tasks, TaskID: "NO-WORKFLOW"}) + if code != report.ExitConfig || result.OK || !hasDiagnostic(result.Errors, "WORKFLOW_NOT_CONFIGURED") { + t.Fatalf("expected worktree-only task to have no workflow: code=%d result=%#v", code, result) + } + if _, err := os.Stat(filepath.Join(tasks, "NO-WORKFLOW", ".taskflow", "workflow-state.json")); !os.IsNotExist(err) { + t.Fatalf("status created workflow runtime: %v", err) + } +} + +func TestWorkflowDecisionHelpers(t *testing.T) { + now := time.Now().UTC() + cfg := workflow.Config{Limits: workflow.Limits{MaxIterations: 2, MaxUsage: 3, MaxDuration: workflow.Duration(time.Minute)}} + + cases := []struct { + name string + snapshot workflow.Snapshot + want string + }{ + {name: "iterations", snapshot: workflow.Snapshot{Iteration: 2}, want: "maximum workflow iterations exhausted"}, + {name: "usage", snapshot: workflow.Snapshot{Usage: 3}, want: "maximum workflow usage exhausted"}, + {name: "duration", snapshot: workflow.Snapshot{CreatedAt: now.Add(-2 * time.Minute)}, want: "maximum workflow duration exhausted"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if reason, exceeded := budgetExceeded(test.snapshot, cfg, now); !exceeded || reason != test.want { + t.Fatalf("budgetExceeded() = %q, %v; want %q, true", reason, exceeded, test.want) + } + }) + } + if reason, exceeded := budgetExceeded(workflow.Snapshot{}, cfg, now); exceeded || reason != "" { + t.Fatalf("zero snapshot unexpectedly exceeded budget: %q, %v", reason, exceeded) + } + if reason, exceeded := workflowDurationExceeded(workflow.Snapshot{CreatedAt: now.Add(-2 * time.Minute)}, cfg, now); !exceeded || reason == "" { + t.Fatalf("duration budget was not detected: %q, %v", reason, exceeded) + } + if reason, exceeded := workflowDurationExceeded(workflow.Snapshot{}, cfg, now); exceeded || reason != "" { + t.Fatalf("zero snapshot unexpectedly exceeded duration: %q, %v", reason, exceeded) + } + if reason, exceeded := checkpointBudgetExceeded(workflow.Snapshot{Usage: 3}, cfg, workflow.ReportProgress, now); !exceeded || reason == "" { + t.Fatalf("progress checkpoint did not use the full budget: %q, %v", reason, exceeded) + } + if reason, exceeded := checkpointBudgetExceeded(workflow.Snapshot{Usage: 3}, cfg, workflow.ReportReady, now); exceeded || reason != "" { + t.Fatalf("ready checkpoint incorrectly used iteration or usage budget: %q, %v", reason, exceeded) + } + + if got := workflowOperationID(" operation-1 "); got != "operation-1" { + t.Fatalf("workflowOperationID() = %q", got) + } + if got := workflowOperationID(" \t"); !strings.HasPrefix(got, "operation-") { + t.Fatalf("generated operation ID = %q", got) + } + if err := validateLeaseTTL(-time.Second); err == nil { + t.Fatal("negative lease TTL was accepted") + } + if err := validateLeaseTTL(0); err != nil { + t.Fatalf("zero lease TTL was rejected: %v", err) + } + + lease := workflow.Lease{TaskID: "TASK-1", OwnerToken: "owner", ExpiresAt: now.Add(time.Minute)} + leaseCases := []struct { + name string + lease workflow.Lease + owner string + wantErr string + }{ + {name: "foreign task", lease: workflow.Lease{TaskID: "OTHER", OwnerToken: "owner", ExpiresAt: now.Add(time.Minute)}, owner: "owner", wantErr: "LEASE_CONFLICT"}, + {name: "expired", lease: workflow.Lease{TaskID: "TASK-1", OwnerToken: "owner", ExpiresAt: now}, owner: "owner", wantErr: "STALE_LEASE"}, + {name: "missing owner", lease: lease, wantErr: "LEASE_CONFLICT"}, + {name: "wrong owner", lease: lease, owner: "other", wantErr: "LEASE_CONFLICT"}, + {name: "valid", lease: lease, owner: "owner"}, + } + for _, test := range leaseCases { + t.Run("lease/"+test.name, func(t *testing.T) { + problem := leaseMatches(test.lease, test.owner, "TASK-1", now) + if test.wantErr == "" { + if problem != nil { + t.Fatalf("leaseMatches() = %#v", problem) + } + return + } + if problem == nil || problem.Code != test.wantErr { + t.Fatalf("leaseMatches() = %#v; want %s", problem, test.wantErr) + } + }) + } + if problem := leaseTaskMismatch(lease, false, "OTHER"); problem != nil { + t.Fatalf("missing lease unexpectedly mismatched: %#v", problem) + } + if problem := leaseTaskMismatch(lease, true, "TASK-1"); problem != nil { + t.Fatalf("matching lease mismatched: %#v", problem) + } + if problem := leaseTaskMismatch(workflow.Lease{TaskID: "OTHER"}, true, "TASK-1"); problem == nil || problem.Code != "LEASE_CONFLICT" { + t.Fatalf("foreign lease mismatch = %#v", problem) + } + if !containsAction([]string{"review", "deploy"}, "deploy") || containsAction([]string{"review"}, "deploy") { + t.Fatal("containsAction returned an unexpected result") + } +} + +func TestReadWorkflowStateAndLeaseErrors(t *testing.T) { + root := t.TempDir() + store := workflow.NewStore(root) + cfg := workflow.Config{ + Version: workflow.ConfigVersion, + Task: workflow.TaskRef{ID: "TASK-1"}, + Stages: []workflow.Stage{{ID: "stage", Objective: "test", MaxAttempts: 1}}, + } + service := New() + if snapshot, exists, problem := service.readWorkflowState(store, "TASK-1", cfg, "digest"); problem != nil || exists || snapshot.TaskID != "TASK-1" { + t.Fatalf("missing state = %#v, %v, %#v", snapshot, exists, problem) + } + + now := time.Now().UTC() + other := workflow.NewSnapshot("OTHER", "digest", cfg, now) + if err := store.Commit(other, workflow.NewEvent("OTHER", "operation-1", "begin", other, now, nil), workflow.CommitOptions{}); err != nil { + t.Fatal(err) + } + if _, exists, problem := service.readWorkflowState(store, "TASK-1", cfg, "digest"); !exists || problem == nil || problem.Code != "RUNTIME_TASK_MISMATCH" { + t.Fatalf("task mismatch = exists:%v problem:%#v", exists, problem) + } + + invalid := workflow.NewSnapshot("TASK-1", "digest", cfg, now) + invalid.StageID = "unknown" + if err := store.Commit(invalid, workflow.NewEvent("TASK-1", "operation-2", "state", invalid, now, nil), workflow.CommitOptions{}); err != nil { + t.Fatal(err) + } + if _, exists, problem := service.readWorkflowState(store, "TASK-1", cfg, "digest"); !exists || problem == nil || problem.Code != "RUNTIME_CORRUPT" { + t.Fatalf("invalid configured state = exists:%v problem:%#v", exists, problem) + } + + if err := os.WriteFile(store.Paths.State, []byte("{"), 0644); err != nil { + t.Fatal(err) + } + if _, exists, problem := service.readWorkflowState(store, "TASK-1", cfg, "digest"); exists || problem == nil || problem.Code != "RUNTIME_CORRUPT" { + t.Fatalf("corrupt state = exists:%v problem:%#v", exists, problem) + } + + if lease, exists, problem := service.readLease(store); problem != nil || exists || lease.TaskID != "" { + t.Fatalf("missing lease = %#v, %v, %#v", lease, exists, problem) + } + if err := os.WriteFile(store.Paths.Lease, []byte("{"), 0644); err != nil { + t.Fatal(err) + } + if _, exists, problem := service.readLease(store); exists || problem == nil || problem.Code != "RUNTIME_CORRUPT" { + t.Fatalf("corrupt lease = exists:%v problem:%#v", exists, problem) + } +} + +func TestReadReportFileEnforcesTaskRootAndSize(t *testing.T) { + root := t.TempDir() + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + inside := filepath.Join(root, "report.json") + if err := os.WriteFile(inside, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if raw, err := readReportFile(canonicalRoot, inside); err != nil || string(raw) != "{}" { + t.Fatalf("readReportFile() = %q, %v", raw, err) + } + if _, err := readReportFile(canonicalRoot, filepath.Join(root, "missing.json")); err == nil { + t.Fatal("missing report was accepted") + } + outside := filepath.Join(t.TempDir(), "report.json") + if err := os.WriteFile(outside, []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + if _, err := readReportFile(canonicalRoot, outside); err == nil { + t.Fatal("report outside task root was accepted") + } + large := filepath.Join(root, "large.json") + if err := os.WriteFile(large, make([]byte, workflow.MaxReportBytes+1), 0644); err != nil { + t.Fatal(err) + } + if _, err := readReportFile(canonicalRoot, large); err == nil { + t.Fatal("oversized report was accepted") + } +} diff --git a/internal/execx/runner.go b/internal/execx/runner.go index 3ec2ed0..e81f60d 100644 --- a/internal/execx/runner.go +++ b/internal/execx/runner.go @@ -15,6 +15,7 @@ type CommandSpec struct { Args []string Dir string Timeout time.Duration + ClearEnv bool Stdin io.Reader Stdout io.Writer Stderr io.Writer @@ -40,7 +41,9 @@ func (OSRunner) Run(ctx context.Context, s CommandSpec) (Result, error) { c := exec.CommandContext(ctx, s.Executable, s.Args...) c.Dir = s.Dir c.Stdin, c.Stdout, c.Stderr = s.Stdin, s.Stdout, s.Stderr - if len(s.Env) > 0 { + if s.ClearEnv { + c.Env = append([]string{}, s.Env...) + } else if len(s.Env) > 0 { c.Env = mergeEnvironment(os.Environ(), s.Env, runtime.GOOS == "windows") } if s.Stdin != nil || s.Stdout != nil || s.Stderr != nil { diff --git a/internal/workflow/checks.go b/internal/workflow/checks.go new file mode 100644 index 0000000..7c760d8 --- /dev/null +++ b/internal/workflow/checks.go @@ -0,0 +1,191 @@ +package workflow + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/chenquan/taskflow/internal/domain" + "github.com/chenquan/taskflow/internal/execx" + "github.com/chenquan/taskflow/internal/fsx" +) + +type limitedBuffer struct { + buffer bytes.Buffer + limit int + truncated bool +} + +func (b *limitedBuffer) Write(data []byte) (int, error) { + if b.limit <= 0 { + b.truncated = b.truncated || len(data) > 0 + return len(data), nil + } + remaining := b.limit - b.buffer.Len() + if remaining <= 0 { + b.truncated = b.truncated || len(data) > 0 + return len(data), nil + } + if len(data) > remaining { + _, _ = b.buffer.Write(data[:remaining]) + b.truncated = true + return len(data), nil + } + _, _ = b.buffer.Write(data) + return len(data), nil +} + +func (b *limitedBuffer) String() string { return b.buffer.String() } +func (b *limitedBuffer) Len() int { return b.buffer.Len() } + +// RunChecks executes only the checks referenced by the current stage. It does +// not invoke a shell and applies cwd, timeout, output, and environment bounds. +func RunChecks(ctx context.Context, task domain.Task, cfg Config, stage Stage, runner execx.Runner) (Verification, error) { + checks := CheckMap(cfg) + verification := Verification{ + Passed: true, + StageID: stage.ID, + CompletedAt: time.Now().UTC(), + Checks: make([]CheckResult, 0, len(stage.Checks)), + } + for _, checkID := range stage.Checks { + check, ok := checks[checkID] + if !ok { + return Verification{}, fmt.Errorf("stage %q references unknown check %q", stage.ID, checkID) + } + if len(check.Argv) == 0 || strings.TrimSpace(check.Argv[0]) == "" { + return Verification{}, fmt.Errorf("check %q has no executable", check.ID) + } + cwd, err := ResolveCWD(task, check.CWD) + if err != nil { + return Verification{}, fmt.Errorf("check %q cwd: %w", check.ID, err) + } + started := time.Now().UTC() + outputLimit := check.OutputLimit + if outputLimit == 0 { + outputLimit = DefaultOutputLimit + } + stdout := &limitedBuffer{limit: outputLimit} + stderr := &limitedBuffer{limit: outputLimit} + runResult, runErr := runner.Run(ctx, execx.CommandSpec{ + Executable: check.Argv[0], + Args: append([]string(nil), check.Argv[1:]...), + Dir: cwd, + Timeout: check.Timeout.TimeDuration(), + ClearEnv: true, + Stdout: stdout, + Stderr: stderr, + Env: checkEnvironment(check), + }) + finished := time.Now().UTC() + if stdout.Len() == 0 && runResult.Stdout != "" { + _, _ = stdout.Write([]byte(runResult.Stdout)) + } + if stderr.Len() == 0 && runResult.Stderr != "" { + _, _ = stderr.Write([]byte(runResult.Stderr)) + } + exitCode := runResult.ExitCode + if runErr != nil && exitCode == 0 { + exitCode = 1 + } + if runResult.TimedOut && exitCode == 0 { + exitCode = -1 + } + result := CheckResult{ + ID: check.ID, + StageID: stage.ID, + Argv: append([]string(nil), check.Argv...), + CWD: cwd, + StartedAt: started, + FinishedAt: finished, + DurationMS: finished.Sub(started).Milliseconds(), + ExitCode: exitCode, + TimedOut: runResult.TimedOut, + Passed: runErr == nil && !runResult.TimedOut && exitCode == 0, + OutputLimit: outputLimit, + Stdout: stdout.String(), + Stderr: stderr.String(), + OutputTrunc: stdout.truncated || stderr.truncated, + } + if runErr != nil { + result.Error = runErr.Error() + } + verification.Checks = append(verification.Checks, result) + if !result.Passed { + verification.Passed = false + } + } + if len(verification.Checks) == 0 { + verification.Passed = true + } + verification.CompletedAt = time.Now().UTC() + return verification, nil +} + +func ResolveCWD(task domain.Task, cwd string) (string, error) { + canonicalRoot, err := fsx.CanonicalExisting(task.Task.Root) + if err != nil { + return "", err + } + var target string + switch { + case cwd == "task": + target = canonicalRoot + case strings.HasPrefix(cwd, "repo:"): + name := strings.TrimPrefix(cwd, "repo:") + for _, repository := range task.Repositories { + if repository.Name == name { + target = filepath.Join(canonicalRoot, repository.Worktree) + break + } + } + if target == "" { + return "", fmt.Errorf("repository %q is not configured", name) + } + default: + return "", fmt.Errorf("must be task or repo:") + } + if !fsx.Within(canonicalRoot, target) { + return "", fmt.Errorf("working directory %q escapes task root", target) + } + canonical, err := fsx.CanonicalExisting(target) + if err != nil { + return "", err + } + if !fsx.Within(canonicalRoot, canonical) { + return "", fmt.Errorf("working directory %q escapes task root", canonical) + } + info, err := os.Stat(canonical) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("working directory %q is not a directory", canonical) + } + return canonical, nil +} + +func checkEnvironment(check Check) []string { + names := append([]string(nil), check.EnvAllowlist...) + sort.Strings(names) + env := make([]string, 0, len(names)+len(check.Env)) + for _, name := range names { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + configured := make([]string, 0, len(check.Env)) + for name := range check.Env { + configured = append(configured, name) + } + sort.Strings(configured) + for _, name := range configured { + env = append(env, name+"="+check.Env[name]) + } + return env +} diff --git a/internal/workflow/checks_test.go b/internal/workflow/checks_test.go new file mode 100644 index 0000000..b45ed53 --- /dev/null +++ b/internal/workflow/checks_test.go @@ -0,0 +1,218 @@ +package workflow + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenquan/taskflow/internal/domain" + "github.com/chenquan/taskflow/internal/execx" +) + +type recordingRunner struct { + specs []execx.CommandSpec + result execx.Result + err error +} + +type resultOnlyRunner struct { + result execx.Result + err error +} + +func (r *recordingRunner) Run(_ context.Context, spec execx.CommandSpec) (execx.Result, error) { + r.specs = append(r.specs, spec) + if spec.Stdout != nil { + _, _ = spec.Stdout.Write([]byte("stdout")) + } + if spec.Stderr != nil { + _, _ = spec.Stderr.Write([]byte("stderr")) + } + return r.result, r.err +} + +func (r resultOnlyRunner) Run(_ context.Context, _ execx.CommandSpec) (execx.Result, error) { + return r.result, r.err +} + +func TestRunChecksUsesConfiguredBoundsAndRecordsAllChecks(t *testing.T) { + taskRoot := t.TempDir() + worktree := filepath.Join(taskRoot, "worktrees", "one") + if err := os.MkdirAll(worktree, 0755); err != nil { + t.Fatal(err) + } + task := domain.Task{Task: domain.TaskInfo{ID: "TASK-1", Root: taskRoot}, Repositories: []domain.Repository{{Name: "one", Worktree: "worktrees/one"}}} + runner := &recordingRunner{result: execx.Result{ExitCode: 0}} + cfg := Normalize(Config{ + Version: ConfigVersion, + Task: TaskRef{ID: "TASK-1"}, + Stages: []Stage{{ID: "stage", Objective: "test", MaxAttempts: 1, Checks: []string{"first", "second"}}}, + Checks: []Check{ + {ID: "first", Argv: []string{"tool", "one"}, CWD: "repo:one", Timeout: Duration(time.Second), OutputLimit: 64, EnvAllowlist: []string{"PATH"}}, + {ID: "second", Argv: []string{"tool", "two"}, CWD: "task", Timeout: Duration(time.Second), OutputLimit: 64}, + }, + }) + verification, err := RunChecks(context.Background(), task, cfg, cfg.Stages[0], runner) + if err != nil || !verification.Passed || len(verification.Checks) != 2 { + t.Fatalf("verification=%#v err=%v", verification, err) + } + if len(runner.specs) != 2 || filepath.Base(runner.specs[0].Dir) != filepath.Base(worktree) || !runner.specs[0].ClearEnv || runner.specs[0].Timeout != time.Second { + t.Fatalf("runner specs=%#v", runner.specs) + } + if verification.Checks[0].Stdout != "stdout" || verification.Checks[0].Stderr != "stderr" { + t.Fatalf("output not captured: %#v", verification.Checks[0]) + } +} + +func TestRunChecksCapturesFailureWithoutSkippingLaterChecks(t *testing.T) { + taskRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(taskRoot, "worktrees", "one"), 0755); err != nil { + t.Fatal(err) + } + task := domain.Task{Task: domain.TaskInfo{ID: "TASK-1", Root: taskRoot}, Repositories: []domain.Repository{{Name: "one", Worktree: "worktrees/one"}}} + runner := &recordingRunner{result: execx.Result{ExitCode: 2}, err: context.Canceled} + cfg := Normalize(Config{ + Version: ConfigVersion, + Task: TaskRef{ID: "TASK-1"}, + Stages: []Stage{{ID: "stage", Objective: "test", MaxAttempts: 1, Checks: []string{"first", "second"}}}, + Checks: []Check{ + {ID: "first", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second)}, + {ID: "second", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second)}, + }, + }) + verification, err := RunChecks(context.Background(), task, cfg, cfg.Stages[0], runner) + if err != nil || verification.Passed || len(verification.Checks) != 2 { + t.Fatalf("verification=%#v err=%v", verification, err) + } + if verification.Checks[0].Passed || verification.Checks[1].Passed { + t.Fatalf("unexpected check results=%#v", verification.Checks) + } +} + +func TestRunChecksBoundsOutputAndFiltersEnvironment(t *testing.T) { + taskRoot := t.TempDir() + task := domain.Task{Task: domain.TaskInfo{ID: "TASK-1", Root: taskRoot}} + t.Setenv("TASKFLOW_CHECK_DENIED", "secret") + runner := &recordingRunner{result: execx.Result{ExitCode: 0}} + cfg := Normalize(Config{ + Version: ConfigVersion, + Task: TaskRef{ID: "TASK-1"}, + Stages: []Stage{{ID: "stage", Objective: "test", MaxAttempts: 1, Checks: []string{"check"}}}, + Checks: []Check{{ID: "check", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second), OutputLimit: 3, EnvAllowlist: []string{"PATH"}}}, + }) + verification, err := RunChecks(context.Background(), task, cfg, cfg.Stages[0], runner) + if err != nil || !verification.Passed || len(verification.Checks) != 1 { + t.Fatalf("verification=%#v err=%v", verification, err) + } + result := verification.Checks[0] + if result.Stdout != "std" || result.Stderr != "std" || !result.OutputTrunc { + t.Fatalf("output bounds not recorded: %#v", result) + } + for _, value := range runner.specs[0].Env { + if strings.HasPrefix(value, "TASKFLOW_CHECK_DENIED=") { + t.Fatalf("denied environment leaked: %v", runner.specs[0].Env) + } + } +} + +func TestRunChecksRejectsInvalidDefinitionsAndUsesRunnerResults(t *testing.T) { + taskRoot := t.TempDir() + task := domain.Task{Task: domain.TaskInfo{ID: "TASK-1", Root: taskRoot}} + base := func(checks []string, configured []Check) Config { + return Normalize(Config{ + Version: ConfigVersion, + Task: TaskRef{ID: "TASK-1"}, + Stages: []Stage{{ID: "stage", Objective: "test", MaxAttempts: 1, Checks: checks}}, + Checks: configured, + }) + } + for _, test := range []struct { + name string + cfg Config + }{ + {name: "unknown check", cfg: base([]string{"missing"}, nil)}, + {name: "missing executable", cfg: base([]string{"check"}, []Check{{ID: "check", CWD: "task", Timeout: Duration(time.Second)}})}, + {name: "invalid repository cwd", cfg: base([]string{"check"}, []Check{{ID: "check", Argv: []string{"tool"}, CWD: "repo:missing", Timeout: Duration(time.Second)}})}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := RunChecks(context.Background(), task, test.cfg, test.cfg.Stages[0], resultOnlyRunner{}); err == nil { + t.Fatal("invalid check definition was accepted") + } + }) + } + + resultConfig := base([]string{"check"}, []Check{{ID: "check", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second)}}) + result, err := RunChecks(context.Background(), task, resultConfig, resultConfig.Stages[0], resultOnlyRunner{result: execx.Result{Stdout: "runner stdout", Stderr: "runner stderr"}}) + if err != nil || !result.Passed || len(result.Checks) != 1 || result.Checks[0].Stdout != "runner stdout" || result.Checks[0].Stderr != "runner stderr" { + t.Fatalf("runner result fallback = %#v, %v", result, err) + } + + failedConfig := base([]string{"check"}, []Check{{ID: "check", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second)}}) + failed, err := RunChecks(context.Background(), task, failedConfig, failedConfig.Stages[0], resultOnlyRunner{err: context.Canceled}) + if err != nil || failed.Passed || failed.Checks[0].ExitCode != 1 || failed.Checks[0].Error == "" { + t.Fatalf("runner error result = %#v, %v", failed, err) + } + timedOutConfig := base([]string{"check"}, []Check{{ID: "check", Argv: []string{"tool"}, CWD: "task", Timeout: Duration(time.Second)}}) + timedOut, err := RunChecks(context.Background(), task, timedOutConfig, timedOutConfig.Stages[0], resultOnlyRunner{result: execx.Result{TimedOut: true}}) + if err != nil || timedOut.Passed || timedOut.Checks[0].ExitCode != -1 || !timedOut.Checks[0].TimedOut { + t.Fatalf("timeout result = %#v, %v", timedOut, err) + } + emptyConfig := base(nil, nil) + empty, err := RunChecks(context.Background(), task, emptyConfig, emptyConfig.Stages[0], resultOnlyRunner{}) + if err != nil || !empty.Passed || len(empty.Checks) != 0 { + t.Fatalf("empty check set = %#v, %v", empty, err) + } +} + +func TestResolveCWDRejectsMissingAndEscapingDirectories(t *testing.T) { + root := t.TempDir() + repo := filepath.Join(root, "repo") + if err := os.Mkdir(repo, 0755); err != nil { + t.Fatal(err) + } + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + canonicalRepo, err := filepath.EvalSymlinks(repo) + if err != nil { + t.Fatal(err) + } + task := domain.Task{ + Task: domain.TaskInfo{ID: "TASK-1", Root: root}, + Repositories: []domain.Repository{{Name: "repo", Worktree: "repo"}}, + } + if got, err := ResolveCWD(task, "task"); err != nil || got != canonicalRoot { + t.Fatalf("task cwd = %q, %v", got, err) + } + if got, err := ResolveCWD(task, "repo:repo"); err != nil || got != canonicalRepo { + t.Fatalf("repo cwd = %q, %v", got, err) + } + for _, test := range []struct { + name string + task domain.Task + cwd string + }{ + {name: "missing task root", task: domain.Task{Task: domain.TaskInfo{Root: filepath.Join(root, "missing")}}, cwd: "task"}, + {name: "unknown repository", task: task, cwd: "repo:missing"}, + {name: "invalid syntax", task: task, cwd: "other"}, + {name: "escaping repository", task: domain.Task{Task: domain.TaskInfo{Root: root}, Repositories: []domain.Repository{{Name: "escape", Worktree: "../outside"}}}, cwd: "repo:escape"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := ResolveCWD(test.task, test.cwd); err == nil { + t.Fatal("invalid working directory was accepted") + } + }) + } + file := filepath.Join(root, "not-a-directory") + if err := os.WriteFile(file, []byte("file"), 0644); err != nil { + t.Fatal(err) + } + task.Repositories = []domain.Repository{{Name: "file", Worktree: "not-a-directory"}} + if _, err := ResolveCWD(task, "repo:file"); err == nil { + t.Fatal("file working directory was accepted") + } +} diff --git a/internal/workflow/config.go b/internal/workflow/config.go new file mode 100644 index 0000000..74813bd --- /dev/null +++ b/internal/workflow/config.go @@ -0,0 +1,359 @@ +package workflow + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + ConfigVersion = 1 + DefaultMaxIterations = 100 + DefaultMaxDuration = 24 * time.Hour + DefaultOutputLimit = 64 * 1024 + DefaultLeaseTTL = 15 * time.Minute + MaxOutputLimit = 16 * 1024 * 1024 + MaxConfiguredStages = 1024 + MaxConfiguredChecks = 4096 + MaxConfiguredEnvValues = 256 + MaxConfiguredActions = 256 +) + +var defaultEnvAllowlist = []string{"PATH", "HOME", "TMPDIR", "GOCACHE", "GOMODCACHE", "GOPATH", "GOENV", "GOTOOLCHAIN", "CGO_ENABLED"} + +var identifierPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) +var environmentNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Duration is a YAML-friendly duration that is serialized as a human-readable +// string in JSON and configuration digests. +type Duration time.Duration + +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.ScalarNode || node.Tag != "!!str" { + return fmt.Errorf("duration must be a string such as 10m") + } + value, err := time.ParseDuration(strings.TrimSpace(node.Value)) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", node.Value, err) + } + *d = Duration(value) + return nil +} + +func (d Duration) MarshalYAML() (any, error) { + return time.Duration(d).String(), nil +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +func (d Duration) TimeDuration() time.Duration { return time.Duration(d) } + +type Config struct { + Version int `yaml:"version" json:"version"` + Task TaskRef `yaml:"task" json:"task"` + Limits Limits `yaml:"limits" json:"limits"` + Stages []Stage `yaml:"stages" json:"stages"` + Checks []Check `yaml:"checks" json:"checks"` + Policy Policy `yaml:"policy" json:"policy"` +} + +type TaskRef struct { + ID string `yaml:"id" json:"id"` +} + +type Limits struct { + MaxIterations int `yaml:"max_iterations" json:"maxIterations"` + MaxDuration Duration `yaml:"max_duration" json:"maxDuration"` + MaxUsage int `yaml:"max_usage" json:"maxUsage"` +} + +type Stage struct { + ID string `yaml:"id" json:"id"` + Objective string `yaml:"objective" json:"objective"` + MaxAttempts int `yaml:"max_attempts" json:"maxAttempts"` + Checks []string `yaml:"checks" json:"checks"` +} + +type Check struct { + ID string `yaml:"id" json:"id"` + Argv []string `yaml:"argv" json:"argv"` + CWD string `yaml:"cwd" json:"cwd"` + Timeout Duration `yaml:"timeout" json:"timeout"` + OutputLimit int `yaml:"output_limit" json:"outputLimit"` + EnvAllowlist []string `yaml:"env_allowlist" json:"envAllowlist"` + Env map[string]string `yaml:"env" json:"env,omitempty"` +} + +type Policy struct { + ExternalActions string `yaml:"external_actions" json:"externalActions"` + AllowedActions []string `yaml:"allowed_actions" json:"allowedActions,omitempty"` +} + +func ConfigPath(taskRoot string) string { return filepath.Join(taskRoot, "workflow.yaml") } + +// Load reads and strictly validates a task-local workflow configuration. The +// returned configuration is normalized before its digest is calculated. +func Load(path, taskID string) (Config, string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Config{}, "", err + } + var cfg Config + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return Config{}, "", fmt.Errorf("decode %s: %w", path, err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return Config{}, "", fmt.Errorf("decode %s: multiple YAML documents are not supported", path) + } + return Config{}, "", fmt.Errorf("decode %s: %w", path, err) + } + if err := Validate(&cfg, taskID); err != nil { + return Config{}, "", err + } + cfg = Normalize(cfg) + digest, err := Digest(cfg) + if err != nil { + return Config{}, "", fmt.Errorf("digest %s: %w", path, err) + } + return cfg, digest, nil +} + +func Normalize(cfg Config) Config { + if cfg.Limits.MaxIterations == 0 { + cfg.Limits.MaxIterations = DefaultMaxIterations + } + if cfg.Limits.MaxDuration == 0 { + cfg.Limits.MaxDuration = Duration(DefaultMaxDuration) + } + if cfg.Policy.ExternalActions == "" { + cfg.Policy.ExternalActions = "deny" + } + for index := range cfg.Checks { + if cfg.Checks[index].OutputLimit == 0 { + cfg.Checks[index].OutputLimit = DefaultOutputLimit + } + if cfg.Checks[index].Env == nil { + cfg.Checks[index].Env = map[string]string{} + } + if cfg.Checks[index].EnvAllowlist == nil { + cfg.Checks[index].EnvAllowlist = append([]string(nil), defaultEnvAllowlist...) + } + sort.Strings(cfg.Checks[index].EnvAllowlist) + } + for index := range cfg.Stages { + if cfg.Stages[index].Checks == nil { + cfg.Stages[index].Checks = []string{} + } + } + sort.Strings(cfg.Policy.AllowedActions) + return cfg +} + +func Validate(cfg *Config, taskID string) error { + if cfg == nil { + return fmt.Errorf("workflow configuration is required") + } + if cfg.Version != ConfigVersion { + return fmt.Errorf("unsupported workflow version %d", cfg.Version) + } + if strings.TrimSpace(cfg.Task.ID) == "" { + return fmt.Errorf("workflow task.id is required") + } + if cfg.Task.ID == "." || cfg.Task.ID == ".." || strings.TrimSpace(cfg.Task.ID) != cfg.Task.ID || strings.ContainsAny(cfg.Task.ID, `/\\`) { + return fmt.Errorf("workflow task.id must be one safe path component") + } + if taskID != "" && cfg.Task.ID != taskID { + return fmt.Errorf("workflow task.id %q does not match task %q", cfg.Task.ID, taskID) + } + if len(cfg.Stages) == 0 { + return fmt.Errorf("workflow stages must not be empty") + } + if len(cfg.Stages) > MaxConfiguredStages { + return fmt.Errorf("workflow has too many stages") + } + if len(cfg.Checks) > MaxConfiguredChecks { + return fmt.Errorf("workflow has too many checks") + } + if cfg.Limits.MaxIterations < 0 { + return fmt.Errorf("limits.max_iterations must not be negative") + } + if cfg.Limits.MaxDuration < 0 { + return fmt.Errorf("limits.max_duration must not be negative") + } + if cfg.Limits.MaxUsage < 0 { + return fmt.Errorf("limits.max_usage must not be negative") + } + + stageIDs := make(map[string]struct{}, len(cfg.Stages)) + for index := range cfg.Stages { + stage := &cfg.Stages[index] + if !identifierPattern.MatchString(stage.ID) { + return fmt.Errorf("invalid stage id %q", stage.ID) + } + if _, exists := stageIDs[stage.ID]; exists { + return fmt.Errorf("duplicate stage %q", stage.ID) + } + stageIDs[stage.ID] = struct{}{} + if strings.TrimSpace(stage.Objective) == "" { + return fmt.Errorf("stage %q objective is required", stage.ID) + } + if stage.MaxAttempts <= 0 { + return fmt.Errorf("stage %q max_attempts must be greater than zero", stage.ID) + } + } + + checkIDs := make(map[string]struct{}, len(cfg.Checks)) + for index := range cfg.Checks { + check := &cfg.Checks[index] + if !identifierPattern.MatchString(check.ID) { + return fmt.Errorf("invalid check id %q", check.ID) + } + if _, exists := checkIDs[check.ID]; exists { + return fmt.Errorf("duplicate check %q", check.ID) + } + checkIDs[check.ID] = struct{}{} + if len(check.Argv) == 0 || strings.TrimSpace(check.Argv[0]) == "" { + return fmt.Errorf("check %q argv must contain an executable", check.ID) + } + if err := validateCheckArgv(check.Argv); err != nil { + return fmt.Errorf("check %q argv: %w", check.ID, err) + } + if err := validateCWD(check.CWD); err != nil { + return fmt.Errorf("check %q cwd: %w", check.ID, err) + } + if check.Timeout <= 0 { + return fmt.Errorf("check %q timeout must be greater than zero", check.ID) + } + if check.OutputLimit < 0 || check.OutputLimit > MaxOutputLimit { + return fmt.Errorf("check %q output_limit must be between zero and %d", check.ID, MaxOutputLimit) + } + if len(check.EnvAllowlist)+len(check.Env) > MaxConfiguredEnvValues { + return fmt.Errorf("check %q has too many environment values", check.ID) + } + envNames := map[string]struct{}{} + for _, name := range check.EnvAllowlist { + if !environmentNamePattern.MatchString(name) { + return fmt.Errorf("check %q has invalid environment name %q", check.ID, name) + } + if _, exists := envNames[name]; exists { + return fmt.Errorf("check %q repeats environment name %q", check.ID, name) + } + envNames[name] = struct{}{} + } + for name := range check.Env { + if !environmentNamePattern.MatchString(name) { + return fmt.Errorf("check %q has invalid environment name %q", check.ID, name) + } + if _, exists := envNames[name]; exists { + return fmt.Errorf("check %q repeats environment name %q", check.ID, name) + } + envNames[name] = struct{}{} + } + } + for _, stage := range cfg.Stages { + seen := map[string]struct{}{} + for _, checkID := range stage.Checks { + if _, exists := checkIDs[checkID]; !exists { + return fmt.Errorf("stage %q references unknown check %q", stage.ID, checkID) + } + if _, exists := seen[checkID]; exists { + return fmt.Errorf("stage %q repeats check %q", stage.ID, checkID) + } + seen[checkID] = struct{}{} + } + } + if cfg.Policy.ExternalActions != "" && cfg.Policy.ExternalActions != "deny" && cfg.Policy.ExternalActions != "approval" { + return fmt.Errorf("policy.external_actions must be deny or approval") + } + if len(cfg.Policy.AllowedActions) > MaxConfiguredActions { + return fmt.Errorf("policy.allowed_actions has too many entries") + } + actionIDs := make(map[string]struct{}, len(cfg.Policy.AllowedActions)) + for _, action := range cfg.Policy.AllowedActions { + if !identifierPattern.MatchString(action) { + return fmt.Errorf("invalid policy action %q", action) + } + if _, exists := actionIDs[action]; exists { + return fmt.Errorf("policy.allowed_actions repeats %q", action) + } + actionIDs[action] = struct{}{} + } + return nil +} + +func validateCWD(cwd string) error { + if cwd == "task" { + return nil + } + if !strings.HasPrefix(cwd, "repo:") || !identifierPattern.MatchString(strings.TrimPrefix(cwd, "repo:")) { + return fmt.Errorf("must be task or repo:") + } + return nil +} + +func validateCheckArgv(argv []string) error { + executable := strings.ToLower(filepath.Base(argv[0])) + switch executable { + case "git": + for _, arg := range argv[1:] { + subcommand := strings.ToLower(arg) + switch subcommand { + case "commit", "push", "pull", "fetch", "merge", "rebase", "reset", "clean", "cherry-pick", "revert": + return fmt.Errorf("git %s is not an allowed workflow check", subcommand) + case "remove", "add", "prune": + return fmt.Errorf("git %s is not an allowed workflow check", subcommand) + } + } + case "gh": + for _, arg := range argv[1:] { + if arg == "pr" || arg == "release" { + return fmt.Errorf("gh %s is not an allowed workflow check", arg) + } + } + case "curl", "wget", "scp", "ssh", "rm", "rmdir": + return fmt.Errorf("%s is not an allowed workflow check", executable) + } + return nil +} + +func Digest(cfg Config) (string, error) { + normalized := Normalize(cfg) + raw, err := json.Marshal(normalized) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func CheckMap(cfg Config) map[string]Check { + result := make(map[string]Check, len(cfg.Checks)) + for _, check := range cfg.Checks { + result[check.ID] = check + } + return result +} + +func StageAt(cfg Config, index int) (Stage, bool) { + if index < 0 || index >= len(cfg.Stages) { + return Stage{}, false + } + return cfg.Stages[index], true +} diff --git a/internal/workflow/config_test.go b/internal/workflow/config_test.go new file mode 100644 index 0000000..bd6d4f6 --- /dev/null +++ b/internal/workflow/config_test.go @@ -0,0 +1,260 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const validWorkflowYAML = `version: 1 +task: + id: TASK-1 +limits: + max_iterations: 4 + max_duration: 1h + max_usage: 10 +stages: + - id: implement + objective: implement the requested change + max_attempts: 2 + checks: [tests] +checks: + - id: tests + argv: ["go", "test", "./..."] + cwd: task + timeout: 2m + output_limit: 4096 + env_allowlist: [PATH] +policy: + external_actions: approval +` + +func TestLoadWorkflowNormalizesAndDigests(t *testing.T) { + path := filepath.Join(t.TempDir(), "workflow.yaml") + if err := os.WriteFile(path, []byte(validWorkflowYAML), 0644); err != nil { + t.Fatal(err) + } + cfg, digest, err := Load(path, "TASK-1") + if err != nil { + t.Fatal(err) + } + if digest == "" || cfg.Limits.MaxIterations != 4 || cfg.Stages[0].Checks[0] != "tests" { + t.Fatalf("unexpected config: %#v digest=%q", cfg, digest) + } + if cfg.Checks[0].Timeout.TimeDuration().String() != "2m0s" { + t.Fatalf("unexpected timeout: %v", cfg.Checks[0].Timeout.TimeDuration()) + } + if _, secondDigest, err := Load(path, "TASK-1"); err != nil || secondDigest != digest { + t.Fatalf("digest is not stable: %q %v", secondDigest, err) + } +} + +func TestLoadWorkflowAppliesSafeDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), "workflow.yaml") + raw := strings.Replace(validWorkflowYAML, " max_iterations: 4\n max_duration: 1h\n max_usage: 10\n", "", 1) + raw = strings.Replace(raw, " output_limit: 4096\n env_allowlist: [PATH]\n", "", 1) + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + cfg, _, err := Load(path, "TASK-1") + if err != nil { + t.Fatal(err) + } + if cfg.Limits.MaxIterations != DefaultMaxIterations || cfg.Limits.MaxDuration.TimeDuration() != DefaultMaxDuration { + t.Fatalf("defaults not applied: %#v", cfg.Limits) + } + if cfg.Checks[0].OutputLimit != DefaultOutputLimit || len(cfg.Checks[0].EnvAllowlist) == 0 { + t.Fatalf("check defaults not applied: %#v", cfg.Checks[0]) + } +} + +func TestLoadWorkflowCanonicalizesSetLikeListsForDigest(t *testing.T) { + first := strings.Replace(validWorkflowYAML, "env_allowlist: [PATH]", "env_allowlist: [HOME, PATH]", 1) + first = strings.Replace(first, "external_actions: approval", "external_actions: approval\n allowed_actions: [deploy, review]", 1) + second := strings.Replace(first, "env_allowlist: [HOME, PATH]", "env_allowlist: [PATH, HOME]", 1) + second = strings.Replace(second, "allowed_actions: [deploy, review]", "allowed_actions: [review, deploy]", 1) + firstPath := filepath.Join(t.TempDir(), "workflow.yaml") + secondPath := filepath.Join(t.TempDir(), "workflow.yaml") + if err := os.WriteFile(firstPath, []byte(first), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(secondPath, []byte(second), 0644); err != nil { + t.Fatal(err) + } + firstCfg, firstDigest, err := Load(firstPath, "TASK-1") + if err != nil { + t.Fatal(err) + } + secondCfg, secondDigest, err := Load(secondPath, "TASK-1") + if err != nil { + t.Fatal(err) + } + if firstDigest != secondDigest || firstCfg.Checks[0].EnvAllowlist[0] != "HOME" || firstCfg.Policy.AllowedActions[0] != "deploy" || secondCfg.Policy.AllowedActions[0] != "deploy" { + t.Fatalf("set-like lists were not canonicalized: first=%#v/%s second=%#v/%s", firstCfg, firstDigest, secondCfg, secondDigest) + } +} + +func TestLoadWorkflowRejectsInvalidSchemas(t *testing.T) { + cases := []struct { + name string + edit string + want string + }{ + {name: "unknown field", edit: "unknown: true\n", want: "unknown"}, + {name: "unknown check", edit: "", want: "unknown check"}, + {name: "duplicate stage", edit: "", want: "duplicate stage"}, + {name: "bad timeout", edit: "", want: "timeout must be greater"}, + {name: "bad cwd", edit: "", want: "must be task or repo"}, + {name: "dangerous command", edit: "", want: "not an allowed workflow check"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := validWorkflowYAML + switch tc.name { + case "unknown field": + raw += tc.edit + case "unknown check": + raw = strings.Replace(raw, "checks: [tests]", "checks: [missing]", 1) + case "duplicate stage": + raw = strings.Replace(raw, "checks: [tests]\nchecks:\n", "checks: [tests]\n - id: implement\n objective: duplicate\n max_attempts: 1\n checks: []\nchecks:\n", 1) + case "bad timeout": + raw = strings.Replace(raw, "timeout: 2m", "timeout: 0s", 1) + case "bad cwd": + raw = strings.Replace(raw, "cwd: task", "cwd: ../outside", 1) + case "dangerous command": + raw = strings.Replace(raw, "argv: [\"go\", \"test\", \"./...\"]", "argv: [\"git\", \"push\"]", 1) + } + path := filepath.Join(t.TempDir(), "workflow.yaml") + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } + if _, _, err := Load(path, "TASK-1"); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q, got %v", tc.want, err) + } + }) + } +} + +func TestLoadWorkflowRejectsTaskMismatchAndMultipleDocuments(t *testing.T) { + path := filepath.Join(t.TempDir(), "workflow.yaml") + if err := os.WriteFile(path, []byte(strings.Replace(validWorkflowYAML, "TASK-1", "OTHER", 1)), 0644); err != nil { + t.Fatal(err) + } + if _, _, err := Load(path, "TASK-1"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected task mismatch, got %v", err) + } + if err := os.WriteFile(path, []byte(validWorkflowYAML+"---\nversion: 1\n"), 0644); err != nil { + t.Fatal(err) + } + if _, _, err := Load(path, "TASK-1"); err == nil || !strings.Contains(err.Error(), "multiple YAML") { + t.Fatalf("expected multiple document rejection, got %v", err) + } +} + +func TestValidateRejectsInvalidWorkflowBoundaries(t *testing.T) { + if err := Validate(nil, "TASK-1"); err == nil { + t.Fatal("expected nil configuration rejection") + } + cases := map[string]func(*Config){ + "version": func(cfg *Config) { cfg.Version++ }, + "missing task ID": func(cfg *Config) { cfg.Task.ID = "" }, + "unsafe task ID": func(cfg *Config) { cfg.Task.ID = "../outside" }, + "too many stages": func(cfg *Config) { cfg.Stages = make([]Stage, MaxConfiguredStages+1) }, + "too many checks": func(cfg *Config) { cfg.Checks = make([]Check, MaxConfiguredChecks+1) }, + "empty stages": func(cfg *Config) { cfg.Stages = nil }, + "negative iterations": func(cfg *Config) { + cfg.Limits.MaxIterations = -1 + }, + "negative duration": func(cfg *Config) { + cfg.Limits.MaxDuration = Duration(-time.Second) + }, + "negative usage": func(cfg *Config) { + cfg.Limits.MaxUsage = -1 + }, + "invalid stage ID": func(cfg *Config) { cfg.Stages[0].ID = "Invalid" }, + "empty objective": func(cfg *Config) { cfg.Stages[0].Objective = " " }, + "invalid attempts": func(cfg *Config) { cfg.Stages[0].MaxAttempts = 0 }, + "invalid check ID": func(cfg *Config) { cfg.Checks[0].ID = "Invalid" }, + "duplicate check ID": func(cfg *Config) { + cfg.Checks = append(cfg.Checks, cfg.Checks[0]) + }, + "empty argv": func(cfg *Config) { cfg.Checks[0].Argv = nil }, + "blank argv": func(cfg *Config) { cfg.Checks[0].Argv = []string{" "} }, + "negative output limit": func(cfg *Config) { cfg.Checks[0].OutputLimit = -1 }, + "large output limit": func(cfg *Config) { cfg.Checks[0].OutputLimit = MaxOutputLimit + 1 }, + "too many environment values": func(cfg *Config) { + cfg.Checks[0].EnvAllowlist = make([]string, MaxConfiguredEnvValues+1) + }, + "invalid allowlist name": func(cfg *Config) { cfg.Checks[0].EnvAllowlist = []string{"BAD-NAME"} }, + "duplicate allowlist name": func(cfg *Config) { cfg.Checks[0].EnvAllowlist = []string{"PATH", "PATH"} }, + "invalid configured name": func(cfg *Config) { + cfg.Checks[0].EnvAllowlist = nil + cfg.Checks[0].Env = map[string]string{"BAD-NAME": "value"} + }, + "duplicate configured name": func(cfg *Config) { + cfg.Checks[0].EnvAllowlist = []string{"PATH"} + cfg.Checks[0].Env = map[string]string{"PATH": "value"} + }, + "unknown stage check": func(cfg *Config) { cfg.Stages[0].Checks = []string{"missing"} }, + "duplicate stage check": func(cfg *Config) { + cfg.Stages[0].Checks = []string{"tests", "tests"} + }, + "invalid action policy": func(cfg *Config) { cfg.Policy.ExternalActions = "allow" }, + "too many actions": func(cfg *Config) { + cfg.Policy.AllowedActions = make([]string, MaxConfiguredActions+1) + }, + "invalid action": func(cfg *Config) { cfg.Policy.AllowedActions = []string{"Invalid"} }, + "duplicate action": func(cfg *Config) { cfg.Policy.AllowedActions = []string{"deploy", "deploy"} }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := runtimeConfig() + mutate(&cfg) + if err := Validate(&cfg, "TASK-1"); err == nil { + t.Fatal("expected invalid workflow configuration to be rejected") + } + }) + } +} + +func TestValidateCheckArgvRejectsSideEffectCommands(t *testing.T) { + for _, argv := range [][]string{ + {"git", "commit"}, + {"git", "push"}, + {"git", "pull"}, + {"git", "fetch"}, + {"git", "merge"}, + {"git", "rebase"}, + {"git", "reset"}, + {"git", "clean"}, + {"git", "cherry-pick"}, + {"git", "revert"}, + {"git", "remove"}, + {"git", "add"}, + {"git", "prune"}, + {"gh", "pr", "create"}, + {"gh", "release", "create"}, + {"curl", "https://example.com"}, + {"wget", "https://example.com"}, + {"scp", "source", "target"}, + {"ssh", "host"}, + {"rm", "file"}, + {"rmdir", "directory"}, + } { + if err := validateCheckArgv(argv); err == nil { + t.Fatalf("validateCheckArgv(%q) accepted a side-effect command", argv) + } + } + for _, argv := range [][]string{ + {"git", "status"}, + {"git", "diff", "--check"}, + {"gh", "issue", "list"}, + {"go", "test", "./..."}, + } { + if err := validateCheckArgv(argv); err != nil { + t.Fatalf("validateCheckArgv(%q) rejected a read-only check: %v", argv, err) + } + } +} diff --git a/internal/workflow/runtime.go b/internal/workflow/runtime.go new file mode 100644 index 0000000..acbb4f4 --- /dev/null +++ b/internal/workflow/runtime.go @@ -0,0 +1,926 @@ +package workflow + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/chenquan/taskflow/internal/fsx" +) + +const RuntimeVersion = 1 + +const ( + MaxReportBytes = 1024 * 1024 + MaxReportItems = 4096 + MaxReportTextSize = 64 * 1024 +) + +type Status string + +const ( + StatusReady Status = "ready" + StatusRunning Status = "running" + StatusVerifying Status = "verifying" + StatusAwaitingApproval Status = "awaiting_approval" + StatusPaused Status = "paused" + StatusUnknown Status = "unknown" + StatusNeedsAttention Status = "needs_attention" + StatusCompleted Status = "completed" + StatusCancelled Status = "cancelled" +) + +type ReportStatus string + +const ( + ReportProgress ReportStatus = "progress" + ReportReady ReportStatus = "ready" + ReportBlocked ReportStatus = "blocked" + ReportNeedsApproval ReportStatus = "needs_approval" +) + +type Snapshot struct { + Version int `json:"version"` + TaskID string `json:"taskID"` + ConfigDigest string `json:"configDigest"` + Status Status `json:"status"` + StageIndex int `json:"stageIndex"` + StageID string `json:"stageID"` + Iteration int `json:"iteration"` + Usage int `json:"usage"` + StageAttempts map[string]int `json:"stageAttempts"` + ActiveAttempt *Attempt `json:"activeAttempt,omitempty"` + LastAttemptID string `json:"lastAttemptID,omitempty"` + LastVerification *VerificationSummary `json:"lastVerification,omitempty"` + PendingApproval *Approval `json:"pendingApproval,omitempty"` + Approvals []Approval `json:"approvals"` + Operations map[string]OperationRecord `json:"operations"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Attempt struct { + ID string `json:"id"` + StageID string `json:"stageID"` + Iteration int `json:"iteration"` + StageAttempt int `json:"stageAttempt"` + SessionID string `json:"sessionID,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + ReportPath string `json:"reportPath,omitempty"` + ReportDigest string `json:"reportDigest,omitempty"` +} + +type VerificationSummary struct { + Passed bool `json:"passed"` + CheckIDs []string `json:"checkIDs"` + FailedCheck []string `json:"failedChecks,omitempty"` + CompletedAt time.Time `json:"completedAt"` +} + +type Approval struct { + ID string `json:"id"` + Action string `json:"action"` + Description string `json:"description"` + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + RequestedAt time.Time `json:"requestedAt"` + DecidedAt *time.Time `json:"decidedAt,omitempty"` +} + +type OperationRecord struct { + ID string `json:"id"` + Command string `json:"command"` + Result map[string]any `json:"result"` + CreatedAt time.Time `json:"createdAt"` +} + +type Event struct { + Version int `json:"version"` + ID string `json:"id"` + OperationID string `json:"operationID,omitempty"` + Type string `json:"type"` + TaskID string `json:"taskID"` + Status Status `json:"status"` + StageID string `json:"stageID,omitempty"` + AttemptID string `json:"attemptID,omitempty"` + Timestamp time.Time `json:"timestamp"` + Data map[string]any `json:"data,omitempty"` +} + +type Lease struct { + Version int `json:"version"` + TaskID string `json:"taskID"` + Engine string `json:"engine"` + SessionID string `json:"sessionID,omitempty"` + OwnerToken string `json:"ownerToken"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type AgentReport struct { + Version int `json:"version"` + TaskID string `json:"taskID"` + StageID string `json:"stageID"` + AttemptID string `json:"attemptID"` + SessionID string `json:"sessionID,omitempty"` + Status ReportStatus `json:"status"` + Summary string `json:"summary"` + ChangedPaths []string `json:"changedPaths"` + Commands []CommandRecord `json:"commands"` + Risks []string `json:"risks"` + NextAction string `json:"nextAction"` + Usage int `json:"usage,omitempty"` + Approval *ApprovalRequest `json:"approval,omitempty"` +} + +type ApprovalRequest struct { + ID string `json:"id"` + Action string `json:"action"` + Description string `json:"description"` +} + +type CommandRecord struct { + Argv []string `json:"argv"` + ExitCode int `json:"exitCode,omitempty"` + Succeeded bool `json:"succeeded"` +} + +type CheckResult struct { + ID string `json:"id"` + StageID string `json:"stageID"` + AttemptID string `json:"attemptID"` + Argv []string `json:"argv"` + CWD string `json:"cwd"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + DurationMS int64 `json:"durationMS"` + ExitCode int `json:"exitCode"` + TimedOut bool `json:"timedOut"` + Passed bool `json:"passed"` + OutputLimit int `json:"outputLimit"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + OutputTrunc bool `json:"outputTruncated,omitempty"` + Error string `json:"error,omitempty"` +} + +type Verification struct { + Passed bool `json:"passed"` + StageID string `json:"stageID"` + AttemptID string `json:"attemptID"` + Checks []CheckResult `json:"checks"` + CompletedAt time.Time `json:"completedAt"` +} + +type Paths struct { + TaskRoot string + RuntimeRoot string + State string + Events string + Lease string + WorkflowRoot string + Attempts string +} + +type AttemptPaths struct { + Root string + Prompt string + Report string + Checks string +} + +func NewPaths(taskRoot string) Paths { + runtimeRoot := filepath.Join(taskRoot, ".taskflow") + workflowRoot := filepath.Join(runtimeRoot, "workflow") + return Paths{ + TaskRoot: taskRoot, + RuntimeRoot: runtimeRoot, + State: filepath.Join(runtimeRoot, "workflow-state.json"), + Events: filepath.Join(runtimeRoot, "workflow-events.jsonl"), + Lease: filepath.Join(runtimeRoot, "workflow-lease.json"), + WorkflowRoot: workflowRoot, + Attempts: filepath.Join(workflowRoot, "attempts"), + } +} + +func (p Paths) Attempt(id string) (AttemptPaths, error) { + if id == "" || id == "." || id == ".." || filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) { + return AttemptPaths{}, fmt.Errorf("invalid attempt ID %q", id) + } + root := filepath.Join(p.Attempts, id) + return AttemptPaths{ + Root: root, + Prompt: filepath.Join(root, "prompt.md"), + Report: filepath.Join(root, "report.json"), + Checks: filepath.Join(root, "checks"), + }, nil +} + +type Store struct{ Paths Paths } + +func NewStore(taskRoot string) Store { return Store{Paths: NewPaths(taskRoot)} } + +func (s Store) ReadSnapshot() (Snapshot, bool, error) { + raw, exists, err := readOptional(s.Paths.State) + if err != nil || !exists { + return Snapshot{}, exists, err + } + var snapshot Snapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + return Snapshot{}, true, &CorruptError{Path: s.Paths.State, Err: err} + } + if err := ValidateSnapshot(snapshot); err != nil { + return Snapshot{}, true, &CorruptError{Path: s.Paths.State, Err: err} + } + return snapshot, true, nil +} + +func (s Store) ReadLease() (Lease, bool, error) { + raw, exists, err := readOptional(s.Paths.Lease) + if err != nil || !exists { + return Lease{}, exists, err + } + var lease Lease + if err := json.Unmarshal(raw, &lease); err != nil { + return Lease{}, true, &CorruptError{Path: s.Paths.Lease, Err: err} + } + if err := validateLease(lease); err != nil { + return Lease{}, true, &CorruptError{Path: s.Paths.Lease, Err: err} + } + return lease, true, nil +} + +func (s Store) ReadEvents(limit int) ([]Event, error) { + file, err := os.Open(s.Paths.Events) + if os.IsNotExist(err) { + return []Event{}, nil + } + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + all := make([]Event, 0) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var event Event + if err := json.Unmarshal([]byte(line), &event); err != nil { + return nil, &CorruptError{Path: s.Paths.Events, Err: err} + } + if event.Version != RuntimeVersion || event.ID == "" || event.Type == "" || event.TaskID == "" { + return nil, &CorruptError{Path: s.Paths.Events, Err: errors.New("invalid event fields")} + } + all = append(all, event) + if limit > 0 && len(all) > limit { + all = all[1:] + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return all, nil +} + +func (s Store) ReadReport(attemptID string) (AgentReport, bool, error) { + paths, err := s.Paths.Attempt(attemptID) + if err != nil { + return AgentReport{}, false, err + } + raw, exists, err := readOptionalLimit(paths.Report, MaxReportBytes) + if err != nil || !exists { + return AgentReport{}, exists, err + } + report, err := DecodeReport(raw) + if err != nil { + return AgentReport{}, true, &CorruptError{Path: paths.Report, Err: err} + } + return report, true, nil +} + +func (s Store) SaveReport(attemptID string, report AgentReport) error { + paths, err := s.Paths.Attempt(attemptID) + if err != nil { + return err + } + if err := ValidateReport(report); err != nil { + return err + } + raw, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + if len(raw)+1 > MaxReportBytes { + return fmt.Errorf("report exceeds %d bytes", MaxReportBytes) + } + raw = append(raw, '\n') + return fsx.AtomicWrite(paths.Report, raw, 0644) +} + +// ReportDigest returns the digest of the canonical report value. The runtime +// stores it on the active attempt so a report edited after checkpoint cannot +// silently change the evidence used by verification. +func ReportDigest(report AgentReport) (string, error) { + raw, err := json.Marshal(report) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func (s Store) SavePrompt(attemptID, prompt string) error { + paths, err := s.Paths.Attempt(attemptID) + if err != nil { + return err + } + return fsx.AtomicWrite(paths.Prompt, []byte(prompt), 0644) +} + +func (s Store) SaveCheckResult(result CheckResult) error { + paths, err := s.Paths.Attempt(result.AttemptID) + if err != nil { + return err + } + if result.ID == "" { + return fmt.Errorf("check result ID is required") + } + raw, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return fsx.AtomicWrite(filepath.Join(paths.Checks, result.ID+".json"), raw, 0644) +} + +type CommitOptions struct { + Lease *Lease + ClearLease bool +} + +// Commit replaces the snapshot and appends one event while the caller holds +// the task lock. If any part fails, the previous files are restored. +func (s Store) Commit(next Snapshot, event Event, options CommitOptions) error { + if err := ValidateSnapshot(next); err != nil { + return err + } + if event.Version == 0 { + event.Version = RuntimeVersion + } + if event.ID == "" || event.Type == "" || event.TaskID == "" { + return fmt.Errorf("event identity is required") + } + nextRaw, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + nextRaw = append(nextRaw, '\n') + eventRaw, err := json.Marshal(event) + if err != nil { + return err + } + eventRaw = append(eventRaw, '\n') + oldState, stateExists, err := readOptional(s.Paths.State) + if err != nil { + return err + } + oldEvents, eventsExist, err := readOptional(s.Paths.Events) + if err != nil { + return err + } + oldLease, leaseExists, err := readOptional(s.Paths.Lease) + if err != nil { + return err + } + + if err := fsx.AtomicWrite(s.Paths.Events, append(oldEvents, eventRaw...), 0644); err != nil { + return err + } + if err := fsx.AtomicWrite(s.Paths.State, nextRaw, 0644); err != nil { + return rollbackRuntimeFiles(s.Paths, oldState, stateExists, oldEvents, eventsExist, oldLease, leaseExists) + } + if options.Lease != nil { + if err := SaveLease(s.Paths.Lease, *options.Lease); err != nil { + return rollbackRuntimeFiles(s.Paths, oldState, stateExists, oldEvents, eventsExist, oldLease, leaseExists) + } + } else if options.ClearLease { + if err := removeIfExists(s.Paths.Lease); err != nil { + return rollbackRuntimeFiles(s.Paths, oldState, stateExists, oldEvents, eventsExist, oldLease, leaseExists) + } + } + return nil +} + +func SaveLease(path string, lease Lease) error { + if err := validateLease(lease); err != nil { + return err + } + raw, err := json.MarshalIndent(lease, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return fsx.AtomicWrite(path, raw, 0644) +} + +func validateLease(lease Lease) error { + if lease.Version != RuntimeVersion { + return fmt.Errorf("invalid lease version %d", lease.Version) + } + if lease.TaskID == "" || strings.TrimSpace(lease.TaskID) != lease.TaskID || strings.ContainsAny(lease.TaskID, `/\\`) || strings.ContainsRune(lease.TaskID, '\x00') { + return fmt.Errorf("invalid lease taskID") + } + switch lease.Engine { + case "unknown", "codex", "claude": + default: + return fmt.Errorf("invalid lease engine %q", lease.Engine) + } + if strings.TrimSpace(lease.OwnerToken) == "" || strings.ContainsRune(lease.OwnerToken, '\x00') || strings.ContainsRune(lease.SessionID, '\x00') { + return fmt.Errorf("invalid lease ownership fields") + } + if lease.CreatedAt.IsZero() || lease.ExpiresAt.IsZero() || lease.ExpiresAt.Before(lease.CreatedAt) { + return fmt.Errorf("invalid lease timestamps") + } + return nil +} + +func ValidateSnapshot(snapshot Snapshot) error { + if snapshot.Version != RuntimeVersion { + return fmt.Errorf("unsupported runtime version %d", snapshot.Version) + } + if snapshot.TaskID == "" || snapshot.ConfigDigest == "" { + return fmt.Errorf("runtime taskID and configDigest are required") + } + if strings.TrimSpace(snapshot.TaskID) != snapshot.TaskID || strings.ContainsAny(snapshot.TaskID, `/\\`) || strings.ContainsRune(snapshot.TaskID, '\x00') { + return fmt.Errorf("runtime taskID must be one safe path component") + } + if !validStatus(snapshot.Status) { + return fmt.Errorf("invalid runtime status %q", snapshot.Status) + } + if snapshot.StageID == "" { + return fmt.Errorf("runtime stageID is required") + } + if snapshot.StageIndex < 0 || snapshot.Iteration < 0 || snapshot.Usage < 0 { + return fmt.Errorf("runtime counters must not be negative") + } + if snapshot.CreatedAt.IsZero() || snapshot.UpdatedAt.IsZero() || snapshot.UpdatedAt.Before(snapshot.CreatedAt) { + return fmt.Errorf("runtime timestamps are invalid") + } + if snapshot.Approvals == nil { + return fmt.Errorf("runtime approvals must be initialized") + } + if snapshot.Operations == nil { + return fmt.Errorf("runtime operations must be initialized") + } + if snapshot.StageAttempts == nil { + return fmt.Errorf("runtime stageAttempts must be initialized") + } + for stageID, attempts := range snapshot.StageAttempts { + if stageID == "" || strings.ContainsAny(stageID, `/\\`) || strings.ContainsRune(stageID, '\x00') { + return fmt.Errorf("runtime stageAttempts contains an invalid stage ID") + } + if attempts < 0 { + return fmt.Errorf("runtime stage attempt counts must not be negative") + } + } + approvalIDs := make(map[string]struct{}, len(snapshot.Approvals)) + for _, approval := range snapshot.Approvals { + if err := validateApproval(approval); err != nil { + return err + } + if _, exists := approvalIDs[approval.ID]; exists { + return fmt.Errorf("runtime approvals contain duplicate ID %q", approval.ID) + } + approvalIDs[approval.ID] = struct{}{} + } + for operationID, operation := range snapshot.Operations { + if operationID == "" || operation.ID != operationID || operation.Command == "" || operation.CreatedAt.IsZero() { + return fmt.Errorf("runtime operation %q is incomplete", operationID) + } + if strings.ContainsRune(operationID, '\x00') || strings.ContainsRune(operation.Command, '\x00') { + return fmt.Errorf("runtime operation %q contains a NUL byte", operationID) + } + } + if snapshot.LastAttemptID != "" && (strings.ContainsAny(snapshot.LastAttemptID, `/\\`) || strings.ContainsRune(snapshot.LastAttemptID, '\x00')) { + return fmt.Errorf("runtime lastAttemptID is invalid") + } + if snapshot.LastVerification != nil { + if snapshot.LastVerification.CompletedAt.IsZero() { + return fmt.Errorf("runtime last verification timestamp is required") + } + for _, checkID := range snapshot.LastVerification.CheckIDs { + if checkID == "" || strings.ContainsRune(checkID, '\x00') { + return fmt.Errorf("runtime last verification contains an invalid check ID") + } + } + for _, checkID := range snapshot.LastVerification.FailedCheck { + if checkID == "" || strings.ContainsRune(checkID, '\x00') { + return fmt.Errorf("runtime last verification contains an invalid failed check ID") + } + } + } + if snapshot.PendingApproval != nil { + if err := validateApproval(*snapshot.PendingApproval); err != nil { + return fmt.Errorf("runtime pending approval: %w", err) + } + if snapshot.PendingApproval.Decision != "" || snapshot.PendingApproval.DecidedAt != nil { + return fmt.Errorf("runtime pending approval is already decided") + } + if _, exists := approvalIDs[snapshot.PendingApproval.ID]; !exists { + return fmt.Errorf("runtime pending approval is not recorded") + } + for _, approval := range snapshot.Approvals { + if approval.ID == snapshot.PendingApproval.ID && (approval.Action != snapshot.PendingApproval.Action || approval.Description != snapshot.PendingApproval.Description || approval.Decision != "" || approval.DecidedAt != nil) { + return fmt.Errorf("runtime pending approval does not match its record") + } + } + } + if snapshot.ActiveAttempt != nil { + attempt := snapshot.ActiveAttempt + if attempt.ID == "" || attempt.StageID == "" || attempt.Status == "" || attempt.Iteration <= 0 || attempt.StageAttempt <= 0 || attempt.StartedAt.IsZero() { + return fmt.Errorf("active attempt is incomplete") + } + if strings.ContainsAny(attempt.ID, `/\\`) || strings.ContainsRune(attempt.ID, '\x00') || strings.ContainsAny(attempt.StageID, `/\\`) || strings.ContainsRune(attempt.StageID, '\x00') || strings.ContainsRune(attempt.SessionID, '\x00') { + return fmt.Errorf("active attempt identity is invalid") + } + if attempt.FinishedAt != nil { + return fmt.Errorf("active attempt must not have a finished timestamp") + } + if attempt.ReportPath != "" { + clean := filepath.Clean(filepath.FromSlash(attempt.ReportPath)) + if filepath.IsAbs(attempt.ReportPath) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || strings.ContainsRune(attempt.ReportPath, '\x00') { + return fmt.Errorf("active attempt report path is invalid") + } + } + if attempt.ReportDigest != "" { + if len(attempt.ReportDigest) != sha256.Size*2 { + return fmt.Errorf("active attempt report digest is invalid") + } + if _, err := hex.DecodeString(attempt.ReportDigest); err != nil { + return fmt.Errorf("active attempt report digest is invalid: %w", err) + } + } + if attempt.Status != "active" && attempt.Status != "unknown" { + return fmt.Errorf("active attempt status %q is invalid", attempt.Status) + } + } + if snapshot.Status == StatusAwaitingApproval && snapshot.PendingApproval == nil { + return fmt.Errorf("awaiting approval state requires a pending approval") + } + if snapshot.Status != StatusAwaitingApproval && snapshot.PendingApproval != nil { + return fmt.Errorf("pending approval is only valid in awaiting_approval state") + } + switch snapshot.Status { + case StatusRunning, StatusVerifying: + if snapshot.ActiveAttempt == nil || snapshot.ActiveAttempt.Status != "active" { + return fmt.Errorf("%s state requires an active attempt", snapshot.Status) + } + case StatusUnknown: + if snapshot.ActiveAttempt != nil && snapshot.ActiveAttempt.Status != "unknown" { + return fmt.Errorf("unknown state requires an unknown active attempt") + } + default: + if snapshot.ActiveAttempt != nil { + return fmt.Errorf("%s state must not have an active attempt", snapshot.Status) + } + } + return nil +} + +// ValidateSnapshotForConfig validates invariants that depend on the current +// declared stage list. Callers should use it only after the snapshot digest +// has been compared with the current workflow configuration. +func ValidateSnapshotForConfig(snapshot Snapshot, cfg Config) error { + if err := ValidateSnapshot(snapshot); err != nil { + return err + } + stage, ok := StageAt(cfg, snapshot.StageIndex) + if !ok || stage.ID != snapshot.StageID { + return fmt.Errorf("runtime stage does not match workflow configuration") + } + knownStages := make(map[string]Stage, len(cfg.Stages)) + for _, configured := range cfg.Stages { + knownStages[configured.ID] = configured + } + for stageID, attempts := range snapshot.StageAttempts { + configured, exists := knownStages[stageID] + if !exists { + return fmt.Errorf("runtime contains attempts for unknown stage %q", stageID) + } + if attempts > configured.MaxAttempts { + return fmt.Errorf("runtime attempts for stage %q exceed the configured limit", stageID) + } + } + if snapshot.ActiveAttempt != nil { + active := snapshot.ActiveAttempt + if active.StageID != snapshot.StageID || active.Iteration != snapshot.Iteration || active.StageAttempt != snapshot.StageAttempts[active.StageID] { + return fmt.Errorf("active attempt does not match runtime stage or counters") + } + if active.StageAttempt > stage.MaxAttempts { + return fmt.Errorf("active attempt exceeds the configured stage limit") + } + } + return nil +} + +func validateApproval(approval Approval) error { + if approval.ID == "" || approval.Action == "" || strings.TrimSpace(approval.Description) == "" || approval.RequestedAt.IsZero() { + return fmt.Errorf("runtime approval is incomplete") + } + if strings.ContainsRune(approval.ID, '\x00') || strings.ContainsRune(approval.Action, '\x00') || strings.ContainsRune(approval.Description, '\x00') { + return fmt.Errorf("runtime approval contains a NUL byte") + } + if approval.Decision != "" && approval.Decision != "approve" && approval.Decision != "reject" { + return fmt.Errorf("runtime approval has invalid decision %q", approval.Decision) + } + if approval.Decision == "" && approval.DecidedAt != nil { + return fmt.Errorf("runtime approval has a decision timestamp without a decision") + } + if approval.Decision != "" && approval.DecidedAt == nil { + return fmt.Errorf("runtime approval decision timestamp is required") + } + if approval.DecidedAt != nil && approval.DecidedAt.Before(approval.RequestedAt) { + return fmt.Errorf("runtime approval decision predates its request") + } + return nil +} + +func NewSnapshot(taskID, digest string, cfg Config, now time.Time) Snapshot { + stageID := "" + if stage, ok := StageAt(cfg, 0); ok { + stageID = stage.ID + } + return Snapshot{ + Version: RuntimeVersion, + TaskID: taskID, + ConfigDigest: digest, + Status: StatusReady, + StageIndex: 0, + StageID: stageID, + StageAttempts: map[string]int{}, + Approvals: []Approval{}, + Operations: map[string]OperationRecord{}, + CreatedAt: now, + UpdatedAt: now, + } +} + +func NewEvent(taskID, operationID, eventType string, snapshot Snapshot, now time.Time, data map[string]any) Event { + return Event{ + Version: RuntimeVersion, + ID: NewID("event"), + OperationID: operationID, + Type: eventType, + TaskID: taskID, + Status: snapshot.Status, + StageID: snapshot.StageID, + AttemptID: attemptID(snapshot), + Timestamp: now, + Data: data, + } +} + +func attemptID(snapshot Snapshot) string { + if snapshot.ActiveAttempt == nil { + return "" + } + return snapshot.ActiveAttempt.ID +} + +func (s Snapshot) Operation(id, command string) (OperationRecord, bool) { + if id == "" { + return OperationRecord{}, false + } + operation, ok := s.Operations[id] + if !ok || operation.Command != command { + return OperationRecord{}, false + } + return operation, true +} + +func (s *Snapshot) RecordOperation(id, command string, result map[string]any, now time.Time) { + if s.Operations == nil { + s.Operations = map[string]OperationRecord{} + } + s.Operations[id] = OperationRecord{ID: id, Command: command, Result: result, CreatedAt: now} +} + +func (s Snapshot) IsTerminal() bool { + return s.Status == StatusCompleted || s.Status == StatusCancelled +} + +func validStatus(status Status) bool { + switch status { + case StatusReady, StatusRunning, StatusVerifying, StatusAwaitingApproval, StatusPaused, StatusUnknown, StatusNeedsAttention, StatusCompleted, StatusCancelled: + return true + default: + return false + } +} + +func ValidateReport(report AgentReport) error { + if report.Version != RuntimeVersion { + return fmt.Errorf("unsupported report version %d", report.Version) + } + if report.TaskID == "" || report.StageID == "" || report.AttemptID == "" { + return fmt.Errorf("report taskID, stageID, and attemptID are required") + } + switch report.Status { + case ReportProgress, ReportReady, ReportBlocked, ReportNeedsApproval: + default: + return fmt.Errorf("invalid report status %q", report.Status) + } + if strings.TrimSpace(report.Summary) == "" { + return fmt.Errorf("report summary is required") + } + if len(report.Summary) > MaxReportTextSize { + return fmt.Errorf("report summary exceeds %d bytes", MaxReportTextSize) + } + if strings.TrimSpace(report.NextAction) == "" { + return fmt.Errorf("report nextAction is required") + } + if len(report.NextAction) > MaxReportTextSize { + return fmt.Errorf("report nextAction exceeds %d bytes", MaxReportTextSize) + } + if len(report.ChangedPaths) > MaxReportItems { + return fmt.Errorf("report has too many changed paths") + } + for _, path := range report.ChangedPaths { + if err := validateChangedPath(path); err != nil { + return err + } + } + if len(report.Commands) > MaxReportItems { + return fmt.Errorf("report has too many command records") + } + for index, command := range report.Commands { + if len(command.Argv) == 0 || strings.TrimSpace(command.Argv[0]) == "" { + return fmt.Errorf("report command %d argv must contain an executable", index) + } + for _, arg := range command.Argv { + if strings.ContainsRune(arg, '\x00') { + return fmt.Errorf("report command %d contains a NUL byte", index) + } + } + } + if len(report.Risks) > MaxReportItems { + return fmt.Errorf("report has too many risks") + } + for _, risk := range report.Risks { + if strings.ContainsRune(risk, '\x00') { + return fmt.Errorf("report risk contains a NUL byte") + } + if len(risk) > MaxReportTextSize { + return fmt.Errorf("report risk exceeds %d bytes", MaxReportTextSize) + } + } + if report.Status == ReportNeedsApproval { + if report.Approval == nil || report.Approval.ID == "" || report.Approval.Action == "" { + return fmt.Errorf("approval details are required for needs_approval report") + } + if strings.TrimSpace(report.Approval.Description) == "" { + return fmt.Errorf("approval description is required") + } + if len(report.Approval.Description) > MaxReportTextSize { + return fmt.Errorf("approval description exceeds %d bytes", MaxReportTextSize) + } + } else if report.Approval != nil { + return fmt.Errorf("approval details are only allowed for needs_approval report") + } + if report.Usage < 0 { + return fmt.Errorf("report usage must not be negative") + } + return nil +} + +func validateChangedPath(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("report changed path must not be empty") + } + if strings.ContainsRune(path, '\x00') { + return fmt.Errorf("report changed path contains a NUL byte") + } + if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, "\\") { + return fmt.Errorf("report changed path %q must be relative", path) + } + clean := filepath.Clean(filepath.FromSlash(strings.ReplaceAll(path, "\\", "/"))) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("report changed path %q escapes the worktree", path) + } + if len(path) > MaxReportTextSize { + return fmt.Errorf("report changed path exceeds %d bytes", MaxReportTextSize) + } + return nil +} + +func DecodeReport(raw []byte) (AgentReport, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var report AgentReport + if err := decoder.Decode(&report); err != nil { + return AgentReport{}, err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return AgentReport{}, fmt.Errorf("multiple JSON values are not supported") + } + return AgentReport{}, fmt.Errorf("multiple JSON values are not supported: %w", err) + } + return report, nil +} + +func NewID(prefix string) string { + var raw [12]byte + if _, err := rand.Read(raw[:]); err == nil { + return prefix + "-" + hex.EncodeToString(raw[:]) + } + return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano()) +} + +type CorruptError struct { + Path string + Err error +} + +func (e *CorruptError) Error() string { + return fmt.Sprintf("corrupt runtime file %s: %v", e.Path, e.Err) +} +func (e *CorruptError) Unwrap() error { return e.Err } + +func readOptional(path string) ([]byte, bool, error) { + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return raw, true, nil +} + +func readOptionalLimit(path string, limit int) ([]byte, bool, error) { + file, err := os.Open(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, int64(limit)+1)) + if err != nil { + return nil, true, err + } + if len(raw) > limit { + return nil, true, fmt.Errorf("file exceeds %d bytes", limit) + } + return raw, true, nil +} + +func removeIfExists(path string) error { + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +func restoreFile(path string, raw []byte, exists bool) error { + if !exists { + return removeIfExists(path) + } + return fsx.AtomicWrite(path, raw, 0644) +} + +func rollbackRuntimeFiles(paths Paths, oldState []byte, stateExists bool, oldEvents []byte, eventsExist bool, oldLease []byte, leaseExists bool) error { + var rollbackErr error + if err := restoreFile(paths.State, oldState, stateExists); err != nil { + rollbackErr = err + } + if err := restoreFile(paths.Events, oldEvents, eventsExist); err != nil && rollbackErr == nil { + rollbackErr = err + } + if err := restoreFile(paths.Lease, oldLease, leaseExists); err != nil && rollbackErr == nil { + rollbackErr = err + } + if rollbackErr != nil { + return fmt.Errorf("runtime commit failed and rollback failed: %w", rollbackErr) + } + return fmt.Errorf("runtime commit failed") +} diff --git a/internal/workflow/runtime_test.go b/internal/workflow/runtime_test.go new file mode 100644 index 0000000..f15edf8 --- /dev/null +++ b/internal/workflow/runtime_test.go @@ -0,0 +1,635 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func runtimeConfig() Config { + return Normalize(Config{ + Version: ConfigVersion, + Task: TaskRef{ID: "TASK-1"}, + Stages: []Stage{{ID: "implement", Objective: "implement", MaxAttempts: 2, Checks: []string{"tests"}}}, + Checks: []Check{{ID: "tests", Argv: []string{"true"}, CWD: "task", Timeout: Duration(time.Minute)}}, + }) +} + +func TestStoreCommitPersistsSnapshotEventsAndEvidence(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + now := time.Now().UTC().Truncate(time.Second) + cfg := runtimeConfig() + snapshot := NewSnapshot("TASK-1", "digest", cfg, now) + attemptID := NewID("attempt") + snapshot.Status = StatusRunning + snapshot.StageAttempts["implement"] = 1 + snapshot.Iteration = 1 + snapshot.LastAttemptID = attemptID + snapshot.ActiveAttempt = &Attempt{ID: attemptID, StageID: "implement", Iteration: 1, StageAttempt: 1, Status: "active", StartedAt: now, ReportPath: filepath.Join(".taskflow", "workflow", "attempts", attemptID, "report.json")} + event := NewEvent("TASK-1", "operation-1", "begin", snapshot, now, map[string]any{"test": true}) + if err := store.Commit(snapshot, event, CommitOptions{}); err != nil { + t.Fatal(err) + } + loaded, exists, err := store.ReadSnapshot() + if err != nil || !exists || loaded.ActiveAttempt == nil || loaded.ActiveAttempt.ID != attemptID { + t.Fatalf("loaded snapshot=%#v exists=%v err=%v", loaded, exists, err) + } + events, err := store.ReadEvents(10) + if err != nil || len(events) != 1 || events[0].Type != "begin" { + t.Fatalf("events=%#v err=%v", events, err) + } + report := AgentReport{Version: RuntimeVersion, TaskID: "TASK-1", StageID: "implement", AttemptID: attemptID, Status: ReportReady, Summary: "ready", ChangedPaths: []string{"main.go"}, Commands: []CommandRecord{}, Risks: []string{}, NextAction: "verify"} + if err := store.SavePrompt(attemptID, "prompt"); err != nil { + t.Fatal(err) + } + if err := store.SaveReport(attemptID, report); err != nil { + t.Fatal(err) + } + readReport, exists, err := store.ReadReport(attemptID) + if err != nil || !exists || readReport.Summary != "ready" { + t.Fatalf("report=%#v exists=%v err=%v", readReport, exists, err) + } +} + +func TestStoreCommitRollbackPreservesPreviousSnapshotAndEvents(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + cfg := runtimeConfig() + now := time.Now().UTC() + initial := NewSnapshot("TASK-1", "digest", cfg, now) + if err := store.Commit(initial, NewEvent("TASK-1", "initial", "init", initial, now, nil), CommitOptions{}); err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(store.Paths.State) + if err != nil { + t.Fatal(err) + } + eventsBefore, err := os.ReadFile(store.Paths.Events) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(store.Paths.State+"-blocked", 0755); err != nil { + t.Fatal(err) + } + blocked := store + blocked.Paths.State = store.Paths.State + "-blocked" + next := initial + next.Status = StatusRunning + next.UpdatedAt = now.Add(time.Second) + err = blocked.Commit(next, NewEvent("TASK-1", "failed", "should_rollback", next, now, nil), CommitOptions{}) + if err == nil { + t.Fatal("expected commit failure") + } + if got, _ := os.ReadFile(store.Paths.State); string(got) != string(stateBefore) { + t.Fatal("previous snapshot changed") + } + if got, _ := os.ReadFile(store.Paths.Events); string(got) != string(eventsBefore) { + t.Fatal("previous events changed") + } +} + +func TestDecodeReportRejectsUnknownFieldsAndMultipleValues(t *testing.T) { + if _, err := DecodeReport([]byte(`{"version":1,"taskID":"t","stageID":"s","attemptID":"a","status":"ready","summary":"ok","unknown":true}`)); err == nil { + t.Fatal("expected unknown field rejection") + } + if _, err := DecodeReport([]byte(`{"version":1,"taskID":"t","stageID":"s","attemptID":"a","status":"ready","summary":"ok"} {}`)); err == nil { + t.Fatal("expected multiple value rejection") + } +} + +func TestStoreBoundsEventReadsAndReports(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + cfg := runtimeConfig() + now := time.Now().UTC().Truncate(time.Second) + for index := 0; index < 3; index++ { + snapshot := NewSnapshot("TASK-1", "digest", cfg, now.Add(time.Duration(index)*time.Second)) + if err := store.Commit(snapshot, NewEvent("TASK-1", "operation-"+string(rune('1'+index)), "event", snapshot, snapshot.UpdatedAt, nil), CommitOptions{}); err != nil { + t.Fatal(err) + } + } + events, err := store.ReadEvents(2) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[0].OperationID != "operation-2" || events[1].OperationID != "operation-3" { + t.Fatalf("bounded events=%#v", events) + } + + attemptID := "attempt-large" + paths, err := store.Paths.Attempt(attemptID) + if err != nil { + t.Fatal(err) + } + largeReport := AgentReport{ + Version: RuntimeVersion, + TaskID: "TASK-1", + StageID: "implement", + AttemptID: attemptID, + Status: ReportReady, + Summary: "ready", + ChangedPaths: make([]string, MaxReportItems), + Commands: []CommandRecord{}, + Risks: []string{}, + NextAction: "verify", + } + for index := range largeReport.ChangedPaths { + largeReport.ChangedPaths[index] = strings.Repeat("a", 256) + } + if err := store.SaveReport(attemptID, largeReport); err == nil { + t.Fatal("expected oversized report to be rejected") + } + if _, err := os.Stat(paths.Report); !os.IsNotExist(err) { + t.Fatalf("oversized report should not be persisted, stat error=%v", err) + } + + if err := os.MkdirAll(filepath.Dir(paths.Report), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.Report, []byte(strings.Repeat("x", MaxReportBytes+1)), 0644); err != nil { + t.Fatal(err) + } + if _, exists, err := store.ReadReport(attemptID); err == nil || !exists { + t.Fatalf("expected oversized report read failure, exists=%v err=%v", exists, err) + } +} + +func TestSaveLeaseRejectsInvalidFields(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + lease := Lease{ + Version: RuntimeVersion, + TaskID: "TASK-1", + Engine: "invalid", + OwnerToken: "owner", + CreatedAt: now, + ExpiresAt: now.Add(time.Minute), + } + if err := SaveLease(filepath.Join(t.TempDir(), "lease.json"), lease); err == nil { + t.Fatal("expected invalid engine to be rejected") + } + lease.Engine = "codex" + lease.SessionID = "session-1" + path := filepath.Join(t.TempDir(), "lease.json") + if err := SaveLease(path, lease); err != nil { + t.Fatal(err) + } + loaded, exists, err := (Store{Paths: Paths{Lease: path}}).ReadLease() + if err != nil || !exists || loaded.TaskID != lease.TaskID || loaded.SessionID != lease.SessionID { + t.Fatalf("loaded lease=%#v exists=%v err=%v", loaded, exists, err) + } +} + +func TestValidateReportRejectsUnsafeMetadata(t *testing.T) { + base := AgentReport{ + Version: RuntimeVersion, + TaskID: "TASK-1", + StageID: "implement", + AttemptID: "attempt-1", + Status: ReportReady, + Summary: "ready", + ChangedPaths: []string{"main.go"}, + Commands: []CommandRecord{{Argv: []string{"go", "test"}}}, + Risks: []string{}, + NextAction: "verify", + } + for name, mutate := range map[string]func(*AgentReport){ + "path traversal": func(report *AgentReport) { report.ChangedPaths = []string{"../outside"} }, + "empty command": func(report *AgentReport) { report.Commands = []CommandRecord{{}} }, + "missing next action": func(report *AgentReport) { + report.NextAction = " " + }, + } { + t.Run(name, func(t *testing.T) { + report := base + mutate(&report) + if err := ValidateReport(report); err == nil { + t.Fatal("expected invalid report") + } + }) + } +} + +func TestValidateReportRejectsInvalidBoundaries(t *testing.T) { + base := AgentReport{ + Version: RuntimeVersion, + TaskID: "TASK-1", + StageID: "implement", + AttemptID: "attempt-1", + Status: ReportReady, + Summary: "ready", + ChangedPaths: []string{}, + Commands: []CommandRecord{}, + Risks: []string{}, + NextAction: "verify", + } + cases := map[string]func(*AgentReport){ + "version": func(report *AgentReport) { report.Version++ }, + "missing identity": func(report *AgentReport) { report.TaskID = "" }, + "invalid status": func(report *AgentReport) { report.Status = ReportStatus("unknown") }, + "empty summary": func(report *AgentReport) { report.Summary = " " }, + "large summary": func(report *AgentReport) { report.Summary = strings.Repeat("x", MaxReportTextSize+1) }, + "empty next action": func(report *AgentReport) { + report.NextAction = " " + }, + "large next action": func(report *AgentReport) { + report.NextAction = strings.Repeat("x", MaxReportTextSize+1) + }, + "too many paths": func(report *AgentReport) { + report.ChangedPaths = make([]string, MaxReportItems+1) + }, + "absolute path": func(report *AgentReport) { report.ChangedPaths = []string{"/outside"} }, + "too many commands": func(report *AgentReport) { + report.Commands = make([]CommandRecord, MaxReportItems+1) + }, + "empty command": func(report *AgentReport) { report.Commands = []CommandRecord{{}} }, + "command NUL": func(report *AgentReport) { + report.Commands = []CommandRecord{{Argv: []string{"go", "\x00"}}} + }, + "too many risks": func(report *AgentReport) { + report.Risks = make([]string, MaxReportItems+1) + }, + "risk NUL": func(report *AgentReport) { report.Risks = []string{"bad\x00risk"} }, + "large risk": func(report *AgentReport) { + report.Risks = []string{strings.Repeat("x", MaxReportTextSize+1)} + }, + "approval missing": func(report *AgentReport) { report.Status = ReportNeedsApproval }, + "approval description missing": func(report *AgentReport) { + report.Status = ReportNeedsApproval + report.Approval = &ApprovalRequest{ID: "approval-1", Action: "review"} + }, + "approval description large": func(report *AgentReport) { + report.Status = ReportNeedsApproval + report.Approval = &ApprovalRequest{ID: "approval-1", Action: "review", Description: strings.Repeat("x", MaxReportTextSize+1)} + }, + "unexpected approval": func(report *AgentReport) { + report.Approval = &ApprovalRequest{ID: "approval-1", Action: "review", Description: "review"} + }, + "negative usage": func(report *AgentReport) { report.Usage = -1 }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + report := base + mutate(&report) + if err := ValidateReport(report); err == nil { + t.Fatal("expected invalid report to be rejected") + } + }) + } +} + +func TestLimitedBufferMarksDiscardedOutput(t *testing.T) { + unlimited := limitedBuffer{limit: len("output")} + if _, err := unlimited.Write([]byte("output")); err != nil || unlimited.String() != "output" || unlimited.truncated { + t.Fatalf("unexpected unlimited buffer: %#v err=%v", unlimited, err) + } + zero := limitedBuffer{limit: 0} + if _, err := zero.Write([]byte("discarded")); err != nil || !zero.truncated || zero.Len() != 0 { + t.Fatalf("zero buffer did not record truncation: %#v err=%v", zero, err) + } + bounded := limitedBuffer{limit: 2} + if _, err := bounded.Write([]byte("ok")); err != nil { + t.Fatal(err) + } + if _, err := bounded.Write([]byte("more")); err != nil || bounded.String() != "ok" || !bounded.truncated { + t.Fatalf("full buffer did not discard output: %#v err=%v", bounded, err) + } +} + +func TestPathsRejectAttemptTraversal(t *testing.T) { + _, err := NewPaths(t.TempDir()).Attempt(filepath.Join("..", "escape")) + if err == nil || err.Error() == "" { + t.Fatalf("expected attempt traversal rejection: %v", err) + } +} + +func TestValidateSnapshotRejectsInconsistentRuntimeStates(t *testing.T) { + now := time.Now().UTC() + base := NewSnapshot("TASK-1", "digest", runtimeConfig(), now) + active := &Attempt{ID: "attempt-1", StageID: "implement", Iteration: 1, StageAttempt: 1, Status: "active", StartedAt: now, ReportPath: ".taskflow/workflow/attempts/attempt-1/report.json"} + cases := map[string]func(*Snapshot){ + "running without active attempt": func(snapshot *Snapshot) { snapshot.Status = StatusRunning }, + "ready with active attempt": func(snapshot *Snapshot) { + snapshot.Status = StatusReady + snapshot.Iteration = 1 + snapshot.StageAttempts["implement"] = 1 + snapshot.LastAttemptID = active.ID + snapshot.ActiveAttempt = active + }, + "awaiting approval without request": func(snapshot *Snapshot) { snapshot.Status = StatusAwaitingApproval }, + "operation identity mismatch": func(snapshot *Snapshot) { + snapshot.Operations["operation-1"] = OperationRecord{ID: "other", Command: "workflow begin", CreatedAt: now} + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + snapshot := base + snapshot.StageAttempts = map[string]int{} + snapshot.Operations = map[string]OperationRecord{} + mutate(&snapshot) + if err := ValidateSnapshot(snapshot); err == nil { + t.Fatal("expected inconsistent runtime state to be rejected") + } + }) + } +} + +func TestValidateSnapshotForConfigRejectsStageMismatch(t *testing.T) { + now := time.Now().UTC() + cfg := runtimeConfig() + snapshot := NewSnapshot("TASK-1", "digest", cfg, now) + snapshot.StageID = "other" + if err := ValidateSnapshotForConfig(snapshot, cfg); err == nil { + t.Fatal("expected stage mismatch to be rejected") + } +} + +func TestValidateLeaseRejectsInvalidBoundaries(t *testing.T) { + now := time.Now().UTC() + base := Lease{ + Version: RuntimeVersion, + TaskID: "TASK-1", + Engine: "codex", + SessionID: "session-1", + OwnerToken: "owner-1", + CreatedAt: now, + ExpiresAt: now.Add(time.Minute), + } + cases := map[string]func(*Lease){ + "version": func(lease *Lease) { lease.Version++ }, + "missing task": func(lease *Lease) { lease.TaskID = "" }, + "trimmed task": func(lease *Lease) { lease.TaskID = " TASK-1" }, + "unsafe task": func(lease *Lease) { lease.TaskID = "TASK/1" }, + "invalid engine": func(lease *Lease) { lease.Engine = "other" }, + "missing owner": func(lease *Lease) { lease.OwnerToken = " " }, + "owner NUL": func(lease *Lease) { lease.OwnerToken = "owner\x00" }, + "session NUL": func(lease *Lease) { lease.SessionID = "session\x00" }, + "missing created": func(lease *Lease) { lease.CreatedAt = time.Time{} }, + "missing expiry": func(lease *Lease) { lease.ExpiresAt = time.Time{} }, + "expiry before creation": func(lease *Lease) { + lease.ExpiresAt = lease.CreatedAt.Add(-time.Second) + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + lease := base + mutate(&lease) + if err := validateLease(lease); err == nil { + t.Fatal("invalid lease was accepted") + } + }) + } + if err := validateLease(base); err != nil { + t.Fatalf("valid lease was rejected: %v", err) + } +} + +func TestRuntimeHelperBoundaries(t *testing.T) { + cfg := runtimeConfig() + if _, ok := StageAt(cfg, -1); ok { + t.Fatal("negative stage index was accepted") + } + if _, ok := StageAt(cfg, len(cfg.Stages)); ok { + t.Fatal("out-of-range stage index was accepted") + } + if _, ok := StageAt(cfg, 0); !ok { + t.Fatal("valid stage index was rejected") + } + + now := time.Now().UTC() + snapshot := NewSnapshot("TASK-1", "digest", cfg, now) + snapshot.RecordOperation("operation-1", "workflow begin", map[string]any{"ok": true}, now) + if operation, ok := snapshot.Operation("operation-1", "workflow begin"); !ok || operation.ID != "operation-1" { + t.Fatalf("recorded operation = %#v, %v", operation, ok) + } + if _, ok := snapshot.Operation("operation-1", "workflow pause"); ok { + t.Fatal("operation with a different command was accepted") + } + if _, ok := snapshot.Operation("missing", "workflow begin"); ok { + t.Fatal("missing operation was accepted") + } + for _, status := range []Status{StatusReady, StatusRunning, StatusVerifying, StatusAwaitingApproval, StatusPaused, StatusUnknown, StatusNeedsAttention, StatusCompleted, StatusCancelled} { + if !validStatus(status) { + t.Fatalf("known status %q was rejected", status) + } + } + if validStatus(Status("invalid")) { + t.Fatal("invalid status was accepted") + } +} + +func TestValidateApprovalRejectsInvalidBoundaries(t *testing.T) { + now := time.Now().UTC() + base := Approval{ID: "approval-1", Action: "review", Description: "review change", RequestedAt: now} + decided := now.Add(time.Second) + cases := map[string]func(*Approval){ + "missing fields": func(approval *Approval) { *approval = Approval{} }, + "NUL fields": func(approval *Approval) { approval.ID = "approval\x00" }, + "invalid decision": func(approval *Approval) { approval.Decision = "maybe" }, + "missing decision time": func(approval *Approval) { approval.Decision = "approve" }, + "missing decision": func(approval *Approval) { approval.DecidedAt = &decided }, + "decision before request": func(approval *Approval) { + approval.Decision = "reject" + before := now.Add(-time.Second) + approval.DecidedAt = &before + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + approval := base + mutate(&approval) + if err := validateApproval(approval); err == nil { + t.Fatal("invalid approval was accepted") + } + }) + } + for _, decision := range []string{"", "approve", "reject"} { + approval := base + approval.Decision = decision + if decision != "" { + approval.DecidedAt = &decided + } + if err := validateApproval(approval); err != nil { + t.Fatalf("valid %q approval was rejected: %v", decision, err) + } + } +} + +func TestValidateSnapshotRejectsInvalidBoundaries(t *testing.T) { + now := time.Now().UTC() + cfg := runtimeConfig() + newSnapshot := func() Snapshot { return NewSnapshot("TASK-1", "digest", cfg, now) } + newApproval := func() Approval { + return Approval{ID: "approval-1", Action: "review", Description: "review change", RequestedAt: now} + } + decidedTime := now.Add(time.Second) + newActiveSnapshot := func() Snapshot { + snapshot := newSnapshot() + snapshot.Status = StatusRunning + snapshot.Iteration = 1 + snapshot.StageAttempts["implement"] = 1 + snapshot.ActiveAttempt = &Attempt{ID: "attempt-1", StageID: "implement", Iteration: 1, StageAttempt: 1, Status: "active", StartedAt: now, ReportPath: "report.json"} + return snapshot + } + cases := map[string]func(*Snapshot){ + "version": func(snapshot *Snapshot) { snapshot.Version++ }, + "missing identity": func(snapshot *Snapshot) { snapshot.TaskID = "" }, + "unsafe identity": func(snapshot *Snapshot) { snapshot.TaskID = "TASK/1" }, + "invalid status": func(snapshot *Snapshot) { snapshot.Status = Status("invalid") }, + "missing stage": func(snapshot *Snapshot) { snapshot.StageID = "" }, + "negative stage index": func(snapshot *Snapshot) { snapshot.StageIndex = -1 }, + "negative iteration": func(snapshot *Snapshot) { snapshot.Iteration = -1 }, + "negative usage": func(snapshot *Snapshot) { snapshot.Usage = -1 }, + "missing created time": func(snapshot *Snapshot) { snapshot.CreatedAt = time.Time{} }, + "updated before created": func(snapshot *Snapshot) { + snapshot.UpdatedAt = snapshot.CreatedAt.Add(-time.Second) + }, + "missing approvals": func(snapshot *Snapshot) { snapshot.Approvals = nil }, + "missing operations": func(snapshot *Snapshot) { snapshot.Operations = nil }, + "missing stage attempts": func(snapshot *Snapshot) { snapshot.StageAttempts = nil }, + "unsafe stage attempt ID": func(snapshot *Snapshot) { + snapshot.StageAttempts = map[string]int{"stage/id": 1} + }, + "negative stage attempts": func(snapshot *Snapshot) { + snapshot.StageAttempts = map[string]int{"stage": -1} + }, + "invalid approval": func(snapshot *Snapshot) { + snapshot.Approvals = []Approval{{}} + }, + "duplicate approvals": func(snapshot *Snapshot) { + approval := newApproval() + snapshot.Approvals = []Approval{approval, approval} + }, + "incomplete operation": func(snapshot *Snapshot) { + snapshot.Operations = map[string]OperationRecord{"operation-1": {}} + }, + "NUL operation": func(snapshot *Snapshot) { + snapshot.Operations = map[string]OperationRecord{"operation\x00": {ID: "operation\x00", Command: "workflow begin", CreatedAt: now}} + }, + "unsafe last attempt": func(snapshot *Snapshot) { snapshot.LastAttemptID = "attempt/1" }, + "missing verification time": func(snapshot *Snapshot) { + snapshot.LastVerification = &VerificationSummary{} + }, + "invalid verification check": func(snapshot *Snapshot) { + snapshot.LastVerification = &VerificationSummary{CompletedAt: now, CheckIDs: []string{""}} + }, + "invalid failed check": func(snapshot *Snapshot) { + snapshot.LastVerification = &VerificationSummary{CompletedAt: now, FailedCheck: []string{"bad\x00check"}} + }, + "invalid pending approval": func(snapshot *Snapshot) { + snapshot.PendingApproval = &Approval{} + }, + "decided pending approval": func(snapshot *Snapshot) { + approval := newApproval() + approval.Decision = "approve" + approval.DecidedAt = &decidedTime + snapshot.Status = StatusAwaitingApproval + snapshot.PendingApproval = &approval + }, + "unrecorded pending approval": func(snapshot *Snapshot) { + approval := newApproval() + snapshot.Status = StatusAwaitingApproval + snapshot.PendingApproval = &approval + }, + "mismatched pending approval": func(snapshot *Snapshot) { + recorded := newApproval() + pending := recorded + pending.Description = "different description" + snapshot.Status = StatusAwaitingApproval + snapshot.Approvals = []Approval{recorded} + snapshot.PendingApproval = &pending + }, + "active attempt incomplete": func(snapshot *Snapshot) { + snapshot.ActiveAttempt = &Attempt{} + }, + "active attempt identity": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.ID = "attempt/1" + *snapshot = active + }, + "active attempt finished": func(snapshot *Snapshot) { + active := newActiveSnapshot() + finished := now + active.ActiveAttempt.FinishedAt = &finished + *snapshot = active + }, + "active report path": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.ReportPath = "../report.json" + *snapshot = active + }, + "active report digest length": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.ReportDigest = "short" + *snapshot = active + }, + "active report digest encoding": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.ReportDigest = strings.Repeat("z", 64) + *snapshot = active + }, + "active attempt status": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.Status = "paused" + *snapshot = active + }, + "awaiting approval without request": func(snapshot *Snapshot) { + snapshot.Status = StatusAwaitingApproval + }, + "pending approval in wrong state": func(snapshot *Snapshot) { + approval := newApproval() + snapshot.Approvals = []Approval{approval} + snapshot.PendingApproval = &approval + }, + "running without active attempt": func(snapshot *Snapshot) { snapshot.Status = StatusRunning }, + "running with non-active attempt": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.ActiveAttempt.Status = "unknown" + *snapshot = active + }, + "unknown with active attempt": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.Status = StatusUnknown + *snapshot = active + }, + "ready with active attempt": func(snapshot *Snapshot) { + active := newActiveSnapshot() + active.Status = StatusReady + *snapshot = active + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + snapshot := newSnapshot() + mutate(&snapshot) + if err := ValidateSnapshot(snapshot); err == nil { + t.Fatal("invalid runtime snapshot was accepted") + } + }) + } +} + +func TestValidateSnapshotForConfigRejectsUnknownAndExcessAttempts(t *testing.T) { + now := time.Now().UTC() + cfg := runtimeConfig() + unknown := NewSnapshot("TASK-1", "digest", cfg, now) + unknown.StageAttempts = map[string]int{"other": 1} + if err := ValidateSnapshotForConfig(unknown, cfg); err == nil { + t.Fatal("attempts for an unknown stage were accepted") + } + excess := NewSnapshot("TASK-1", "digest", cfg, now) + excess.StageAttempts["implement"] = cfg.Stages[0].MaxAttempts + 1 + if err := ValidateSnapshotForConfig(excess, cfg); err == nil { + t.Fatal("attempts over the stage limit were accepted") + } + active := NewSnapshot("TASK-1", "digest", cfg, now) + active.Status = StatusRunning + active.Iteration = 1 + active.StageAttempts["implement"] = 1 + active.ActiveAttempt = &Attempt{ID: "attempt-1", StageID: "other", Iteration: 1, StageAttempt: 1, Status: "active", StartedAt: now} + if err := ValidateSnapshotForConfig(active, cfg); err == nil { + t.Fatal("active attempt from another stage was accepted") + } +} diff --git a/openspec/changes/session-driven-ai-workflow/.openspec.yaml b/openspec/changes/session-driven-ai-workflow/.openspec.yaml new file mode 100644 index 0000000..88ea482 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-30 diff --git a/openspec/changes/session-driven-ai-workflow/design.md b/openspec/changes/session-driven-ai-workflow/design.md new file mode 100644 index 0000000..95d75a5 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/design.md @@ -0,0 +1,136 @@ +## Context + +Taskflow currently owns a declarative `taskflow.yaml`, safe Git worktree creation and deletion, ownership metadata, locks, dry-run preflight, and installation of the bundled Skill for Codex or Claude. Its current Skill prepares a worktree and tells the user how to launch an Agent; it does not own task progress, Agent sessions, validation, or lifecycle state. + +The requested workflow has a different control shape: the user starts `codex` or `claude`, invokes a globally available workflow Skill, and uses the host session's `/loop` facility to keep making progress. The Skill can call the Taskflow CLI, but Taskflow must not start a nested Agent or require an App Server. The session performs reasoning and edits; the CLI persists the workflow contract, checks, evidence, and safe transitions. + +The design must support both global Skill targets, preserve all existing worktree behavior, survive a closed or interrupted session, and avoid treating a model's self-report as proof of completion. + +## Goals / Non-Goals + +**Goals:** + +- Provide a local, inspectable, resumable workflow loop controlled from an active Codex or Claude session. +- Keep the Skill engine-neutral while allowing the two hosts' native global and loop mechanisms to invoke it. +- Use a separate strict `workflow.yaml` for objectives, linear stages, bounded checks, retry policy, budgets, and action policy. +- Make Taskflow CLI the source of truth for workflow state, event history, leases, verification results, and terminal decisions. +- Require machine verification before a workflow can become `completed`. +- Make retries, pauses, configuration changes, interrupted attempts, and stale sessions explicit and recoverable. +- Preserve current `taskflow.yaml`, worktree ownership, create/delete safety, and global Skill installation behavior. + +**Non-Goals:** + +- Starting, embedding, or supervising a Claude/Codex Agent process. +- Implementing Codex App Server, a provider-neutral Agent protocol, or a background daemon. +- Supporting arbitrary workflow DAGs, parallel Agents, multi-tenant hosting, Web UI, or remote execution. +- Automatically committing, pushing, creating/merging PRs, releasing, deleting resources, or writing to external systems. +- Replacing the host's conversation transcript or making global Skill content the source of task-specific state. + +## Decisions + +### 1. Add a workflow Skill instead of changing the existing workspace Skill + +Create a new bundled `taskflow-workflow` Skill and install the same engine-neutral content into both global Skill roots. Keep the existing `taskflow` Skill focused on worktree preparation and cleanup. The new Skill composes with it by requiring a structurally ready worktree before beginning a workflow. + +This is additive and avoids contradicting the current Skill contract, which explicitly tells the user to launch the interactive tool themselves. Engine-specific launch syntax belongs to the host CLI; the workflow Skill only calls `taskflow` commands and repository checks from the active session. + +### 2. Use the active session as the Agent runtime and `/loop` as the trigger + +The workflow Skill defines one bounded iteration. The host's `/loop` repeats a fixed instruction such as “read status, advance one iteration, checkpoint, verify, and stop on terminal or approval state.” The Skill must never recursively launch `codex` or `claude`. + +The loop is session-bound. Closing the session stops new iterations, but state and evidence remain on disk; a later session invokes the Skill again and resumes after a recovery check. A future unattended scheduler is a separate change. + +### 3. Keep workflow configuration separate from `taskflow.yaml` + +`taskflow.yaml` remains the only desired worktree configuration. `workflow.yaml` is a new user-authored contract with its own version and strict decoder. This prevents adding lifecycle fields to the existing worktree schema and allows users to use Taskflow only for worktree setup. + +The v1 configuration is a linear ordered list of stages. Each stage has an objective, an attempt limit, and references to named verification checks. Checks use an executable plus argument array, a bounded working directory selected from the task root or configured repository, an optional environment allowlist, and a timeout. Shell-string evaluation is not the default. + +### 4. Make CLI state transitions explicit and idempotent + +Add a `workflow` command group with these operations: + +- `validate` reads and validates both task and workflow configuration plus worktree readiness. +- `status` returns the current snapshot and recent event summary. +- `begin` claims the task, creates an attempt, and returns the active stage and attempt identifier. +- `checkpoint` records a validated Agent report for the active attempt. +- `verify` runs the configured checks and advances, retries, pauses, or completes the workflow. +- `pause`, `resume`, `approve`, and `cancel` implement explicit user control. + +Every mutating command takes the task lock, validates the configuration digest and attempt/lease token, appends an event, and atomically replaces the state snapshot. Repeating a command with the same operation identifier must return the original result rather than duplicate a transition. + +### 5. Store a snapshot plus an append-only event log + +Use the following runtime layout: + +```text +.taskflow/ +├── workflow-state.json +├── workflow-events.jsonl +├── workflow-lease.json +└── workflow/ + └── attempts// + ├── prompt.md + ├── report.json + └── checks/.json +``` + +The snapshot contains schema version, task ID, workflow config digest, current status/stage/iteration, active attempt, session reference when supplied, last verification, approval records, budget counters, and timestamps. The event log is the audit and recovery source. Reports and check outputs are bounded or summarized to prevent unbounded state growth; paths to full user-owned artifacts may be recorded. + +Writes use the existing same-directory atomic-write pattern. A corrupt snapshot does not authorize guessing: the CLI preserves the event log, returns a recovery diagnostic, and requires a recovery path before continuing. + +### 6. Use an expiring lease because the loop crosses CLI invocations + +The existing file lock protects one CLI operation, not an entire interactive session. `begin` therefore creates a workflow lease containing task ID, engine, session reference, owner token, creation time, and expiry. `checkpoint`, `verify`, `pause`, and `resume` must present the owner token or perform the explicit stale-lease recovery flow. + +An expired lease permits inspection but not blind continuation. The next session must compare the configured worktrees and recent events, then explicitly resume. A session killed during an attempt produces `unknown` until the worktree and last known operation are inspected. + +### 7. Separate Agent reports from machine verification + +The Skill asks the Agent to produce a structured checkpoint containing status (`progress`, `ready`, `blocked`, or `needs_approval`), summary, changed paths, commands run, risks, and next action. The CLI validates and records this report but never treats it as completion proof. + +`verify` is authoritative for stage completion. It records each check's command, bounded output, exit code, timeout, duration, and result. Only successful required checks may advance the stage or produce `completed`. A model claim that tests passed without corresponding check evidence is rejected. + +### 8. Treat high-risk actions as a hard workflow boundary where possible + +The Skill only authorizes reading, editing, and local validation. Commit, push, PR, merge, release, deletion, and external writes transition to `awaiting_approval` or remain outside the workflow. `approve` records a human decision and allows the Skill to continue only when the workflow contract permits it. + +Because a generic interactive Agent can still issue an arbitrary shell command, Skill text alone cannot guarantee that a user-configured CLI will never run `git push`. The v1 safety guarantee is therefore scoped: the workflow does not instruct or expose those actions, uses isolated worktrees and least-privilege host settings, and does not report them as Taskflow-managed successful actions. Strong pre-execution interception requires a later command proxy, hook, sandbox, or App Server integration. + +### 9. Preserve the same workflow contract across Codex and Claude + +The bundled source contains one common workflow protocol and small host-neutral examples. `taskflow skill install` installs it to both global targets by default, while the host determines how the user invokes the global Skill and `/loop`. No workflow state or behavior is stored in a provider-specific directory. + +The Skill uses `taskflow --json` for all state and verification calls. Text output remains user-readable, but JSON is the integration contract so the model can make decisions from stable codes, statuses, and evidence rather than parsing prose. + +### 10. Keep workflow execution foreground and serial in v1 + +Only one active attempt may exist per task. Stages run in declaration order, and a failed check retries the current stage until its attempt or global iteration budget is exhausted. There is no parallel stage execution or multi-Agent coordination. This makes the lease, evidence, and recovery model deterministic and fits the one active interactive session assumption. + +## Risks / Trade-offs + +- **[Risk] `/loop` can continue prompting after the task is already complete.** → Every iteration begins with `workflow status`; terminal, paused, approval, unknown, and budget-exhausted states cause a no-op and a clear stop report. +- **[Risk] A killed Agent may have changed files before checkpointing.** → Record `unknown`, preserve the worktree, require recovery inspection, and never replay an ambiguous non-idempotent action automatically. +- **[Risk] Agent reports can be fabricated or incomplete.** → Validate report shape, store it as evidence only, and require independently executed checks for progression. +- **[Risk] Global Skill content can drift from the installed binary.** → Extend the existing installer fingerprint/overwrite behavior and add content tests for both Codex and Claude targets. +- **[Risk] Workflow configuration changes during execution.** → Store a digest in every attempt and fail closed with `CONFIG_CHANGED` until the user explicitly restarts or resumes against the new configuration. +- **[Risk] Verification commands can hang, mutate outside the worktree, or consume excessive resources.** → Use argv-based execution, bounded cwd, timeout, output limits, environment filtering, and per-check/global budgets; document that repository scripts remain trusted inputs. +- **[Risk] CLI-only policy cannot fully intercept arbitrary Agent shell commands.** → Keep external side effects out of the v1 workflow contract and identify command proxy/sandbox integration as a future security-hardening change. +- **[Trade-off] Separate `workflow.yaml` and runtime files add operational surface.** → Keep desired config, runtime state, ownership, and event evidence in clearly separated files with independent versions and strict validation. + +## Migration Plan + +1. Add the new workflow packages, command namespace, bundled Skill, examples, and tests without changing existing commands or existing `taskflow.yaml` decoding. +2. Install the new Skill globally for both targets only when the user runs the existing Skill installation command; do not modify existing user-installed Skills automatically during ordinary workflow commands. +3. For an existing task, require a valid current `taskflow.yaml` and structurally matching worktrees before allowing `workflow begin`. No automatic migration of old state, inventory, or validation reports is performed. +4. If `workflow.yaml` is absent, the task remains a normal worktree-only Taskflow task. If it is present but invalid, workflow commands fail without modifying worktrees or the desired configuration. +5. Rollback is binary rollback: existing `create`, `delete`, and Skill installation behavior remains usable. Workflow runtime files can be left inert because they are not read by the legacy worktree commands. Do not delete runtime files automatically during rollback. + +## Open Questions + +There are no blocking v1 questions. The following are explicitly deferred decisions for later changes: + +- whether to add a background scheduler for work after the interactive session closes; +- whether to add a command proxy or App Server adapter for enforceable per-tool approvals; +- whether to support parallel stages or multiple Agent sessions; +- whether to add a Web UI or remote task store. diff --git a/openspec/changes/session-driven-ai-workflow/proposal.md b/openspec/changes/session-driven-ai-workflow/proposal.md new file mode 100644 index 0000000..27f6096 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/proposal.md @@ -0,0 +1,36 @@ +## Why + +Taskflow currently prepares isolated Git worktrees and installs a Skill, but the Agent session still has to coordinate progress, validation, retries, and recovery manually. This change adds a session-driven AI workflow so a user can start Claude or Codex, invoke the globally installed workflow Skill, and use the session loop to make bounded progress toward a verifiable result. + +The workflow must remain local, inspectable, and compatible with Taskflow's existing worktree safety model. The Skill owns the conversational procedure, while Taskflow CLI owns durable state and deterministic checks; no Agent daemon or App Server is required. + +## What Changes + +- Add a strict, task-local `workflow.yaml` for objective, linear stages, verification commands, retry limits, budgets, and allowed actions. +- Add persistent workflow state, append-only events, attempt records, configuration digests, and expiring session leases under `.taskflow/`. +- Add `taskflow workflow` commands for validation, status, begin, checkpoint, verify, pause, resume, approval records, and cancellation. +- Add a globally installable `taskflow-workflow` Skill for both Codex and Claude that drives one bounded iteration per loop and calls the Taskflow CLI. +- Require machine-verifiable checks before reporting completion; bound retries and pause on approval, ambiguity, configuration changes, stale ownership, or exhausted budgets. +- Preserve the existing `taskflow` worktree Skill and existing `create`, `delete`, and `skill install` behavior; the new workflow layer is additive. +- Keep commit, push, pull request, merge, release, deletion, and external writes outside automatic Agent actions in the first version. + +## Capabilities + +### New Capabilities + +- `workflow-definition`: Strict task-local workflow configuration, linear stages, checks, policies, and limits. +- `workflow-session-loop`: Global Codex/Claude Skill protocol for `/loop`-driven bounded iterations and Taskflow CLI interaction. +- `workflow-state-runtime`: Durable workflow state, events, attempts, leases, recovery, and idempotent transitions. +- `workflow-cli-control`: User- and Skill-facing commands for workflow inspection, progression, verification, pausing, resuming, and cancellation. + +### Modified Capabilities + + + +## Impact + +- Extends the Go CLI and domain/configuration packages with a new `workflow` command namespace and runtime model. +- Adds workflow configuration and runtime files alongside existing `taskflow.yaml` and ownership metadata. +- Extends bundled Skill installation and content tests for both global Codex and Claude targets. +- Adds process execution for configured, bounded verification commands, but does not make Taskflow responsible for launching or embedding Claude/Codex. +- Requires documentation updates describing the distinction between worktree preparation, session loop control, durable workflow state, and manual external side effects. diff --git a/openspec/changes/session-driven-ai-workflow/specs/workflow-cli-control/spec.md b/openspec/changes/session-driven-ai-workflow/specs/workflow-cli-control/spec.md new file mode 100644 index 0000000..9d7d11e --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/specs/workflow-cli-control/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: The CLI SHALL expose workflow lifecycle operations +The CLI SHALL provide `workflow validate`, `workflow status`, `workflow begin`, `workflow checkpoint`, `workflow verify`, `workflow pause`, `workflow resume`, `workflow approve`, and `workflow cancel` commands under the existing Taskflow root command. The commands SHALL accept the existing task selection and JSON output conventions. + +#### Scenario: Inspect workflow status +- **WHEN** the user runs `taskflow --json workflow status ` +- **THEN** the CLI returns the current state, stage, iteration, lease/approval summary, last verification, and diagnostic warnings without changing files + +#### Scenario: Unknown workflow command argument +- **WHEN** a workflow command receives an invalid task ID, unsupported option, or missing required argument +- **THEN** the CLI returns a structured configuration or argument diagnostic without mutating runtime or Git state + +### Requirement: Workflow validation SHALL precede mutation +`workflow begin` and every command that advances or executes a workflow SHALL validate the task configuration, workflow configuration, worktree readiness, configuration digest, current state, and lease requirements before writing runtime state or running a check. + +#### Scenario: Worktree is not ready +- **WHEN** a workflow begins while a configured worktree is missing or has mismatched Git identity +- **THEN** the CLI returns a worktree diagnostic and does not start an attempt or execute verification commands + +#### Scenario: Valid workflow begins +- **WHEN** all configuration, worktree, state, and lease preconditions pass +- **THEN** `workflow begin` creates one attempt, persists its metadata, and returns the stage objective and attempt token + +### Requirement: Checkpoint input SHALL be schema-validated +`workflow checkpoint` SHALL accept a structured report for the active attempt and SHALL validate status, attempt identity, changed paths, commands, risks, and summary fields before persisting the checkpoint. Invalid reports SHALL not advance workflow state. + +#### Scenario: Valid ready checkpoint +- **WHEN** the active Agent submits a valid `ready` checkpoint for the current attempt +- **THEN** the CLI persists the report and makes the attempt eligible for `workflow verify` + +#### Scenario: Invalid or foreign checkpoint +- **WHEN** a checkpoint is malformed or references another task, stage, or attempt +- **THEN** the CLI returns a checkpoint diagnostic and leaves the active state and event history unchanged + +### Requirement: Verification SHALL execute only configured checks +`workflow verify` SHALL execute the checks referenced by the current stage using the configured argv, bounded working directory, timeout, environment policy, and output limit. It SHALL persist individual results and a stage-level result. + +#### Scenario: Verification succeeds +- **WHEN** every required check for the current stage exits with success +- **THEN** the CLI records successful check evidence and advances the workflow according to the stage order + +#### Scenario: Verification fails +- **WHEN** one or more required checks fail or time out +- **THEN** the CLI records the failure evidence and either creates an eligible retry or enters `needs_attention` according to the configured limits + +### Requirement: Approval commands SHALL record decisions without bypassing policy +`workflow approve` SHALL record a human approval or rejection for a named approval request. Approval SHALL NOT authorize an action absent from the workflow policy, alter the worktree configuration, or bypass task, lease, configuration, or verification checks. + +#### Scenario: Approval is granted +- **WHEN** a valid pending approval request is approved by the user +- **THEN** the CLI records the decision and changes the workflow to the next policy-allowed state without executing an external side effect itself + +#### Scenario: Unknown approval request +- **WHEN** the user approves an expired, completed, rejected, or unknown approval ID +- **THEN** the CLI returns an approval conflict and leaves workflow state unchanged + +### Requirement: Pause, resume, and cancel SHALL be explicit and recoverable +`workflow pause`, `workflow resume`, and `workflow cancel` SHALL be idempotent, SHALL preserve prior evidence, and SHALL not delete or reset worktrees. Resume SHALL require valid configuration and safe lease recovery; cancel SHALL leave the task available for inspection and separate user-authorized cleanup. + +#### Scenario: User pauses an active workflow +- **WHEN** the user pauses a workflow with an active or retryable attempt +- **THEN** the CLI records the reason, releases or expires the active lease safely, and enters `paused` + +#### Scenario: User resumes a paused workflow +- **WHEN** the workflow configuration and worktrees remain valid and the user resumes the task +- **THEN** the CLI creates a new valid execution context or reactivates the eligible stage without deleting previous attempts + +#### Scenario: User cancels a workflow +- **WHEN** the user cancels a workflow in a non-terminal state +- **THEN** the CLI records cancellation, stops future workflow advancement, and leaves worktrees and evidence untouched + +### Requirement: Workflow commands SHALL not launch Agents or perform release-side effects +Workflow CLI commands SHALL not start Codex or Claude, commit or push Git changes, create or merge pull requests, release or deploy artifacts, delete worktrees, or write external systems. They SHALL provide status and evidence for the active Skill session to act on. + +#### Scenario: Skill calls workflow CLI +- **WHEN** an active Agent Skill invokes `workflow begin`, `checkpoint`, or `verify` +- **THEN** the CLI performs only the declared local state or verification operation and returns structured evidence + +#### Scenario: Delete is requested through workflow +- **WHEN** a workflow attempts to delete a worktree or task resource +- **THEN** the workflow command rejects the action and directs the user to the existing separately authorized delete flow diff --git a/openspec/changes/session-driven-ai-workflow/specs/workflow-definition/spec.md b/openspec/changes/session-driven-ai-workflow/specs/workflow-definition/spec.md new file mode 100644 index 0000000..eb2ad30 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/specs/workflow-definition/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Workflow configuration is separate from worktree configuration +The system SHALL load workflow behavior from a task-local `workflow.yaml` and SHALL continue to load Git worktree behavior from `taskflow.yaml`. Workflow fields MUST NOT be added implicitly to the existing worktree configuration contract. + +#### Scenario: Task has only worktree configuration +- **WHEN** a task contains a valid `taskflow.yaml` but no `workflow.yaml` +- **THEN** existing worktree commands continue to operate and workflow commands report that no workflow is configured + +#### Scenario: Workflow and worktree configurations coexist +- **WHEN** a task contains valid `taskflow.yaml` and `workflow.yaml` files +- **THEN** workflow commands use both files without rewriting either user-authored configuration + +### Requirement: Workflow configuration SHALL be strictly validated +The system SHALL reject unsupported workflow versions, missing required fields, duplicate stage or check identifiers, unknown fields, empty stage lists, invalid task IDs, and invalid references before creating or advancing runtime state. + +#### Scenario: Valid workflow configuration +- **WHEN** `workflow.yaml` contains a supported version, matching task ID, unique linear stages, valid checks, limits, and policy +- **THEN** `workflow validate` succeeds and returns the normalized configuration digest + +#### Scenario: Unknown workflow field +- **WHEN** `workflow.yaml` contains a field outside the supported schema +- **THEN** validation fails with a configuration diagnostic and does not create or modify workflow runtime files + +#### Scenario: Stage references an unknown check +- **WHEN** a stage references a check identifier that is not defined +- **THEN** validation fails before `workflow begin` can create an attempt + +### Requirement: Workflow stages SHALL be linear and bounded +The system SHALL preserve declared stage order, SHALL execute at most one active stage and one active attempt per task, and SHALL enforce per-stage attempt limits and global iteration, duration, and cost or usage limits when configured. + +#### Scenario: Stage order is preserved +- **WHEN** a workflow declares stages `understand`, `implement`, and `review` in that order +- **THEN** the runtime exposes and advances those stages in the same order + +#### Scenario: Stage attempt limit is exhausted +- **WHEN** verification fails for the current stage and its configured attempt limit is exhausted +- **THEN** the workflow enters `needs_attention` and does not start another attempt automatically + +### Requirement: Verification checks SHALL be declarative and constrained +Each configured check SHALL define an executable argument array, a task-root or repository-scoped working directory, and a timeout. The verifier MUST enforce the configured working-directory boundary, timeout, output limit, and environment policy, and MUST record the check result. + +#### Scenario: Repository-scoped check +- **WHEN** a check declares `cwd: repo:order-service` and `argv: ["go", "test", "./..."]` +- **THEN** the verifier runs that argument vector in the configured order-service worktree and records its exit status and bounded output + +#### Scenario: Invalid check working directory +- **WHEN** a check resolves outside the task root or configured worktrees +- **THEN** validation fails and the command is never executed + +#### Scenario: Check timeout +- **WHEN** a check exceeds its configured timeout +- **THEN** the verifier terminates or reaps the check process, records a timeout result, and applies the workflow retry policy diff --git a/openspec/changes/session-driven-ai-workflow/specs/workflow-session-loop/spec.md b/openspec/changes/session-driven-ai-workflow/specs/workflow-session-loop/spec.md new file mode 100644 index 0000000..d879ac8 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/specs/workflow-session-loop/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: The workflow Skill SHALL be globally installable for both Agent hosts +The bundled `taskflow-workflow` Skill SHALL be installable in the global Skill scope for both Codex and Claude using the existing Skill installation mechanism. The Skill content SHALL use the same workflow protocol and SHALL not contain task-specific state. + +#### Scenario: Default global installation +- **WHEN** the user installs bundled Skills without selecting a single tool +- **THEN** the workflow Skill is installed for both Codex and Claude global Skill targets + +#### Scenario: Selective global installation +- **WHEN** the user selects only Codex or only Claude +- **THEN** the workflow Skill is installed only for that selected global target and reports the target in machine-readable output + +### Requirement: The workflow Skill SHALL drive one bounded iteration +When invoked in an active Codex or Claude session, the workflow Skill SHALL read the current CLI status, begin or resume only one allowed attempt, guide the Agent through one bounded unit of work, record a checkpoint, and request machine verification. It MUST NOT recursively launch Codex or Claude. + +#### Scenario: Active workflow iteration +- **WHEN** the current state is eligible to run and the user invokes the workflow Skill +- **THEN** the Skill begins one attempt, provides the current stage objective, and requires checkpoint and verification before another iteration + +#### Scenario: Skill would recursively launch an Agent +- **WHEN** the workflow Skill is executing inside a Codex or Claude session +- **THEN** the Skill calls Taskflow and repository commands only and does not launch a nested `codex` or `claude` process + +### Requirement: Loop ticks SHALL be state-gated +The Skill's loop instruction SHALL treat the host `/loop` facility as a trigger only. At the beginning of every tick it MUST query workflow state, and it MUST stop without doing work when the state is `completed`, `paused`, `awaiting_approval`, `needs_attention`, `cancelled`, or `unknown`. + +#### Scenario: Loop observes completed state +- **WHEN** `/loop` triggers after the workflow has entered `completed` +- **THEN** the Skill performs no Agent work, reports completion, and stops the loop + +#### Scenario: Loop observes approval state +- **WHEN** `/loop` triggers while the workflow is `awaiting_approval` +- **THEN** the Skill does not modify files or start an attempt and reports the approval required + +### Requirement: The Skill SHALL use Taskflow JSON output as its control contract +The Skill SHALL call `taskflow --json workflow ...` for state, checkpoint, and verification operations and SHALL make decisions from structured status and diagnostic codes rather than parsing human-readable prose. + +#### Scenario: Structured CLI success +- **WHEN** a Taskflow command returns a successful JSON result +- **THEN** the Skill uses the returned status, attempt, stage, and evidence fields to determine the next bounded action + +#### Scenario: Structured CLI conflict +- **WHEN** a Taskflow command returns a lock, lease, configuration, or worktree diagnostic +- **THEN** the Skill stops and reports the diagnostic without retrying through a shell workaround + +### Requirement: The workflow SHALL be resumable across sessions +The Skill SHALL be able to resume a task in a later Codex or Claude session by reading persisted state and evidence. It MUST perform the CLI recovery checks before continuing an interrupted or stale attempt. + +#### Scenario: New session resumes a paused workflow +- **WHEN** a new Agent session invokes the Skill for a task in `paused` state and the user has resumed it through the CLI +- **THEN** the Skill reads the current stage and continues from the persisted state without replaying completed verification + +#### Scenario: New session finds an unknown attempt +- **WHEN** a new Agent session finds state `unknown` after the prior session ended during an attempt +- **THEN** the Skill requests recovery inspection and does not automatically replay an ambiguous action diff --git a/openspec/changes/session-driven-ai-workflow/specs/workflow-state-runtime/spec.md b/openspec/changes/session-driven-ai-workflow/specs/workflow-state-runtime/spec.md new file mode 100644 index 0000000..b6a6256 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/specs/workflow-state-runtime/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Workflow runtime state SHALL be durable and auditable +The system SHALL persist a versioned workflow snapshot, an append-only JSONL event log, per-attempt evidence, and a workflow lease under the task's `.taskflow/` directory. The snapshot and runtime records MUST identify the task and workflow configuration digest. + +#### Scenario: First workflow attempt +- **WHEN** `workflow begin` successfully starts the first attempt +- **THEN** the system persists the active stage, attempt identifier, configuration digest, lease, and corresponding begin event + +#### Scenario: Event history is inspected +- **WHEN** the user requests workflow status after multiple iterations +- **THEN** the result includes the current snapshot and enough recent event/evidence references to explain the latest transition + +### Requirement: Runtime state writes SHALL be atomic and lock-protected +Every state transition SHALL acquire the task lock, validate the current snapshot before mutation, write the replacement snapshot atomically, and append the transition event. A failed write MUST leave the previous valid snapshot intact. + +#### Scenario: Concurrent state mutation +- **WHEN** two sessions attempt to advance the same task concurrently +- **THEN** only one transition succeeds and the other receives a task or lease conflict without corrupting state + +#### Scenario: State write failure +- **WHEN** a runtime snapshot replacement fails +- **THEN** the system preserves the previous snapshot and reports an execution error without appending a false success event + +### Requirement: Workflow transitions SHALL be explicit and evidence-based +The runtime SHALL support `ready`, `running`, `verifying`, `awaiting_approval`, `paused`, `unknown`, `needs_attention`, `completed`, and `cancelled` states. Only valid transitions for the current state, stage, attempt, lease, and configuration digest SHALL be accepted. + +#### Scenario: Verification passes +- **WHEN** all required checks for the active stage exit successfully and the stage report is valid +- **THEN** the runtime advances to the next declared stage or enters `completed` when no stage remains + +#### Scenario: Model claims success without checks +- **WHEN** an Agent checkpoint claims completion but required checks have not succeeded +- **THEN** the runtime keeps the workflow non-terminal and rejects the completion transition + +### Requirement: Attempts and operations SHALL be idempotent +The runtime SHALL assign stable attempt and operation identifiers. Repeating a begin, checkpoint, verify, pause, resume, approval, or cancellation operation with the same valid identifier SHALL return the existing result without duplicating effects. + +#### Scenario: Duplicate checkpoint +- **WHEN** the Skill submits the same checkpoint after a network or process retry +- **THEN** the runtime returns the original checkpoint result and appends no duplicate transition + +#### Scenario: Stale attempt checkpoint +- **WHEN** a checkpoint references an attempt that is no longer active +- **THEN** the runtime rejects it with an attempt conflict and leaves the current state unchanged + +### Requirement: Leases SHALL protect interactive ownership and support safe recovery +The runtime SHALL create an expiring lease for an active workflow session and SHALL require a valid owner token for active-attempt mutations. An expired lease SHALL permit inspection but SHALL require explicit recovery before continuation. + +#### Scenario: Valid lease renewal +- **WHEN** the active session calls checkpoint or verify before lease expiry +- **THEN** the runtime validates the owner token and refreshes the lease expiry atomically with the operation + +#### Scenario: Expired lease +- **WHEN** a new session attempts to continue after the previous lease expired +- **THEN** the runtime returns a stale-lease condition and requires recovery inspection or explicit resume before beginning work + +### Requirement: Interrupted attempts SHALL fail closed +If the runtime cannot establish whether an active Agent operation completed, it SHALL mark the attempt `unknown`, preserve all worktree and evidence files, and SHALL NOT automatically replay a potentially non-idempotent action. + +#### Scenario: Session terminates before checkpoint +- **WHEN** the Agent session terminates after `begin` but before checkpoint +- **THEN** the next status or recovery operation reports `unknown` and retains the worktree for inspection + +#### Scenario: Recovery confirms no active operation +- **WHEN** a user explicitly resumes an `unknown` attempt after inspecting the worktree and evidence +- **THEN** the runtime creates a new attempt or resumes according to the recorded recovery decision without deleting prior evidence + +### Requirement: Configuration changes SHALL invalidate active execution +The runtime SHALL compare the current normalized workflow configuration digest with the digest recorded at begin. A mismatch SHALL stop active execution and return `CONFIG_CHANGED` until the user explicitly starts a new execution context. + +#### Scenario: Workflow file changes during a run +- **WHEN** `workflow.yaml` changes after an attempt begins +- **THEN** checkpoint, verify, and resume refuse to advance the old attempt and report the digest mismatch + +### Requirement: Completion SHALL require machine verification +The runtime SHALL enter `completed` only after every required stage and final check has a recorded successful verification result. Reports, natural-language responses, or the absence of an error SHALL NOT be sufficient. + +#### Scenario: All final checks pass +- **WHEN** the final stage report is valid and all configured final checks pass +- **THEN** the runtime records successful evidence and enters `completed` + +#### Scenario: Final check fails +- **WHEN** any required final check fails +- **THEN** the runtime remains non-terminal and applies the configured retry or `needs_attention` policy diff --git a/openspec/changes/session-driven-ai-workflow/tasks.md b/openspec/changes/session-driven-ai-workflow/tasks.md new file mode 100644 index 0000000..7d202a9 --- /dev/null +++ b/openspec/changes/session-driven-ai-workflow/tasks.md @@ -0,0 +1,45 @@ +## 1. Workflow contract and domain model + +- [x] 1.1 Define versioned workflow configuration types for linear stages, bounded attempts, check definitions, approval policy, and workflow-level limits. +- [x] 1.2 Implement strict `workflow.yaml` loading, unknown-field rejection, semantic validation, canonical normalization, and configuration digest calculation without changing `taskflow.yaml` behavior. +- [x] 1.3 Define typed runtime models for workflow snapshots, events, attempts, agent reports, check results, leases, operation IDs, and machine-readable errors. +- [x] 1.4 Add valid, invalid, backward-compatibility, and configuration-digest test fixtures covering the workflow-definition requirements. + +## 2. Durable runtime and state machine + +- [x] 2.1 Implement task-local runtime path resolution under `.taskflow`, including snapshot, JSONL event log, lease, and per-attempt evidence directories. +- [x] 2.2 Implement lock-protected atomic snapshot writes and append-only event writes, with fail-closed handling for malformed or partially written state. +- [x] 2.3 Implement the explicit workflow transition reducer for `ready`, `running`, `verifying`, `awaiting_approval`, `paused`, `unknown`, `needs_attention`, `completed`, and `cancelled`. +- [x] 2.4 Implement attempt and operation idempotency, stale-operation rejection, and configuration-digest checks for all mutating workflow operations. +- [x] 2.5 Implement expiring session leases, renewal, expiry inspection, and safe recovery that marks an interrupted attempt `unknown` until explicitly resolved. +- [x] 2.6 Add runtime tests for concurrent mutation, atomic-write failure, duplicate operations, expired leases, interrupted sessions, configuration changes, and event replay. + +## 3. Bounded machine verification + +- [x] 3.1 Implement declarative check execution from executable-plus-argv definitions with task/repository cwd restrictions, timeout enforcement, bounded stdout/stderr capture, and an environment allowlist. +- [x] 3.2 Implement stage verification that runs only configured checks, records command metadata and results as attempt evidence, and derives transitions from machine results rather than model claims. +- [x] 3.3 Implement checkpoint report validation for progress, ready, blocked, and approval-needed outcomes, including stage/attempt/session identity checks. +- [x] 3.4 Add verification tests for passing and failing checks, timeout, output limits, invalid cwd, denied environment values, malformed reports, and final-completion requirements. + +## 4. Workflow CLI control plane + +- [x] 4.1 Add the `taskflow workflow` command group with `--json` output envelopes, stable error codes, bounded operation options, and no Agent-launching behavior. +- [x] 4.2 Implement `workflow validate` and `workflow status`, including config diagnostics, runtime recovery indicators, current stage/attempt, lease state, and latest verification evidence. +- [x] 4.3 Implement `workflow begin` and `workflow checkpoint` with worktree readiness checks, lease acquisition, schema validation, event emission, and idempotent operation handling. +- [x] 4.4 Implement `workflow verify` with configured-check execution, persisted evidence, state transitions, retry-budget accounting, and fail-closed behavior on unverifiable results. +- [x] 4.5 Implement `workflow pause`, `workflow resume`, `workflow approve`, and `workflow cancel` with explicit policy checks, auditable events, and idempotent repeated requests. +- [x] 4.6 Add CLI integration tests proving JSON contract stability, invalid-input handling, stale-session rejection, concurrent mutation protection, and that workflow commands never commit, push, launch Agents, or perform external writes. + +## 5. Codex and Claude Skill integration + +- [x] 5.1 Add the bundled global `taskflow-workflow` Skill with engine-neutral instructions for one bounded iteration: inspect status, begin or resume, work, checkpoint, verify, and stop on terminal or attention states. +- [x] 5.2 Extend the Skill installer and asset verification to install the same workflow contract into the supported Codex and Claude global Skill locations, with explicit selective-install behavior. +- [x] 5.3 Document the host `/loop` responsibility and ensure the Skill uses `taskflow --json workflow ...` decisions instead of parsing human-readable prose. +- [x] 5.4 Add Skill contract tests and a manual smoke procedure covering active-loop gating, completed/paused/approval no-op behavior, session resume, and the prohibition on nested `codex` or `claude` launches. + +## 6. Compatibility, documentation, and release validation + +- [x] 6.1 Add an example workflow configuration and document the workflow lifecycle, state model, command reference, evidence layout, retry/approval rules, and recovery procedure. +- [x] 6.2 Update the existing `taskflow` Skill and README to describe optional composition with `taskflow-workflow` while preserving current task initialization, worktree, ownership, and cleanup semantics. +- [x] 6.3 Add compatibility coverage proving repositories without `workflow.yaml` retain the current Taskflow behavior and invalid workflow configuration fails before mutation. +- [x] 6.4 Run focused Go tests, race/concurrency tests, CLI end-to-end tests, Skill asset checks, and `openspec validate --all --strict`; record any environment-dependent manual checks. diff --git a/skills/install.go b/skills/install.go index 71786d6..84aabce 100644 --- a/skills/install.go +++ b/skills/install.go @@ -12,7 +12,7 @@ import ( // Files contains the complete built-in skill directories. // -//go:embed all:taskflow +//go:embed all:taskflow all:taskflow-workflow var Files embed.FS type Target struct { diff --git a/skills/install_test.go b/skills/install_test.go index 7d4221e..2b61e63 100644 --- a/skills/install_test.go +++ b/skills/install_test.go @@ -20,7 +20,7 @@ func TestInstallCreatesEverySkillForBothTools(t *testing.T) { if err != nil { t.Fatal(err) } - if len(result) != len(targets) || len(names) != 1 || names[0] != "taskflow" { + if len(result) != len(targets) || len(names) != 2 || names[0] != "taskflow" || names[1] != "taskflow-workflow" { t.Fatalf("unexpected install result %#v, names %#v", result, names) } for _, target := range targets { diff --git a/skills/skill_content_test.go b/skills/skill_content_test.go index 9d3e3b3..81d5303 100644 --- a/skills/skill_content_test.go +++ b/skills/skill_content_test.go @@ -82,3 +82,38 @@ func TestTaskflowSkillMetadataMatchesTheGuidance(t *testing.T) { } } } + +func TestTaskflowWorkflowSkillUsesStateGatedBoundedIterations(t *testing.T) { + content, err := Files.ReadFile("taskflow-workflow/SKILL.md") + if err != nil { + t.Fatal(err) + } + text := string(content) + for _, required := range []string{ + "workflow status", + "workflow begin", + "workflow checkpoint", + "workflow verify", + "--json", + "completed", + "awaiting_approval", + "needs_attention", + "unknown", + "/loop", + "codex", + "claude", + "不启动嵌套", + "commit", + "push", + "owner-token", + } { + if !strings.Contains(text, required) { + t.Errorf("workflow skill is missing %q", required) + } + } + for _, forbidden := range []string{"taskflow workflow commit", "taskflow workflow push", "codex --", "claude --"} { + if strings.Contains(text, forbidden) { + t.Errorf("workflow skill contains forbidden command guidance %q", forbidden) + } + } +} diff --git a/skills/taskflow-workflow/SKILL.md b/skills/taskflow-workflow/SKILL.md new file mode 100644 index 0000000..8e5d307 --- /dev/null +++ b/skills/taskflow-workflow/SKILL.md @@ -0,0 +1,70 @@ +--- +name: taskflow-workflow +description: 在当前 Codex 或 Claude 会话中按 task-local workflow.yaml 驱动一个有界、可恢复、可验证的 AI 工作流。使用 Taskflow CLI 管理状态、租约、checkpoint 和机器校验。 +--- + +# Taskflow 会话工作流 + +这个 Skill 运行在已经启动的 Codex 或 Claude 会话中。它负责指导当前 Agent 完成一个有界迭代;宿主提供的 `/loop` 只负责再次触发本 Skill。不启动嵌套的 `codex` 或 `claude`,不要创建嵌套 worktree。 + +## 运行前提 + +- 当前目录必须位于 Taskflow 管理的任务 worktree 中,用户必须提供明确的 task ID;不要扫描任务目录或猜测 task ID。 +- 任务目录必须同时包含有效的 `taskflow.yaml` 和 `workflow.yaml`;先用现有 `taskflow` Skill 准备或复用 worktree。 +- Taskflow CLI 是状态、阶段、租约、检查结果和终端状态的唯一来源。所有 workflow 调用优先使用 `taskflow --json workflow ...`。 +- 全局 Skill 目录只存放本 Skill;任务状态必须留在任务目录的 `.taskflow/` 下。 + +## 每次调用只执行一个迭代 + +每次手动调用或 `/loop` tick 都严格按以下顺序执行: + +1. 使用明确的 task ID 和 tasks-root 查询状态: + + ```bash + taskflow --json --tasks-root workflow status + ``` + +2. 只读取 JSON 的 `data.status`、`data.stage`、`data.snapshot`、`data.lease`、`data.configDigest` 以及顶层 `warnings` 和 `errors` 字段作决定,不解析人类可读文本。 +3. 如果状态是 `completed`、`paused`、`awaiting_approval`、`needs_attention`、`cancelled` 或 `unknown`,不修改文件、不创建 attempt,报告原因并停止本 tick。任务完成时停止宿主的 loop(如果宿主提供停止方式)。 +4. 如果状态是 `ready`,调用 `workflow begin`,保存返回的 `attemptID`、`ownerToken`、`stageID`、`objective` 和 `reportPath`。如果状态是 `running`,只继续当前 active attempt;不要重复 begin。 +5. 只围绕当前阶段 objective 工作一个有界单元。可以阅读、修改当前 worktree 和运行本地探索命令,但不要执行 commit、push、PR、merge、release、deploy、删除资源或外部写入。 +6. 在本 tick 结束时创建结构化 JSON checkpoint,必须包含当前 task、stage、attempt、session(如果可用)、`status`(`progress`、`ready`、`blocked` 或 `needs_approval`)、summary、changed paths、commands、risks 和 next action。把报告写在任务目录内,再调用: + + ```bash + taskflow --json --tasks-root workflow checkpoint \ + --attempt-id \ + --owner-token \ + --report-file / \ + --operation-id + ``` + +7. 只有 checkpoint 返回 `verifying` 或明确允许验证时,调用: + + ```bash + taskflow --json --tasks-root workflow verify \ + --attempt-id \ + --owner-token \ + --operation-id + ``` + +8. 根据 JSON 结果结束本 tick:检查通过则等待下一 tick 进入下一阶段;检查失败则等待允许的重试;出现 lease、锁、worktree、配置 digest、审批或恢复诊断时停止,不用 shell 绕过 CLI。 + +## checkpoint 状态 + +- `progress`:当前 attempt 仍在工作,下一 tick 继续当前 attempt;不要调用 begin。 +- `ready`:当前 attempt 已准备好交给 CLI 执行配置中的机器检查。 +- `blocked`:无法安全继续,CLI 会让工作流进入 `needs_attention`。 +- `needs_approval`:仅在 `workflow.yaml` 的 `policy.external_actions: approval` 且 action 被允许时使用;必须附带唯一 approval ID、action 和 description,CLI 才会进入 `awaiting_approval`。 + +Agent 的 summary 不是完成证明。只有 `workflow verify` 为当前阶段记录了所有 required checks 的成功证据,工作流才能进入下一阶段或 `completed`。 + +## 恢复与人工控制 + +- `CONFIG_CHANGED`、`STALE_LEASE`、`ATTEMPT_CONFLICT`、`WORKTREE_MISMATCH` 或 `RUNTIME_CORRUPT`:报告完整诊断,保留现场,不自动重放或覆盖文件。 +- 会话在 begin 后中断时,下一次会话先查看 `workflow status`;`unknown` attempt 必须由用户检查 worktree 和 evidence 后显式恢复,不能盲目重放。 +- 用户可在会话外使用 `workflow pause`、`workflow resume --recover`、`workflow approve` 或 `workflow cancel`。审批只记录决定,不会替用户执行外部副作用。 +- `completed` 后仍由用户人工检查 `git diff` 并决定 commit、push、PR 或发布;Taskflow workflow 不执行这些动作。 + +## `/loop` 使用约定 + +宿主的 `/loop` 只是重复触发上述单轮流程;不要把状态保存在对话或全局 Skill 文件中。每一轮必须先查询状态,所以即使宿主多触发了一次,终端、暂停、审批、未知和预算耗尽状态也只能产生 no-op 报告。 diff --git a/skills/taskflow-workflow/agents/openai.yaml b/skills/taskflow-workflow/agents/openai.yaml new file mode 100644 index 0000000..e56cc91 --- /dev/null +++ b/skills/taskflow-workflow/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Taskflow 会话工作流" + short_description: "在 Codex 或 Claude 会话中驱动可恢复的有界 AI 工作流" + default_prompt: "使用 $taskflow-workflow 读取 workflow 状态,只推进一个有界迭代,并通过 Taskflow CLI 完成 checkpoint 和机器校验。" diff --git a/skills/taskflow/SKILL.md b/skills/taskflow/SKILL.md index 9df78cc..d980eaa 100644 --- a/skills/taskflow/SKILL.md +++ b/skills/taskflow/SKILL.md @@ -121,6 +121,12 @@ claude --add-dir "" --add-dir "" Codex 使用相同的 cwd、路径引用和 `--add-dir` 参数,只需将工具名替换为 `codex` 并移除 Claude 环境变量。把用户请求的其他工具参数按目标 shell 正确引用后追加在这些 `--add-dir` 参数之后。把完整的、与 shell 匹配的命令展示给用户,由用户在自己的终端执行;不要由 agent shell 代为启动交互式工具。不要加入 `--worktree` 或 `--worktree=...`,避免创建嵌套 worktree;如用户请求这些参数,应省略或拒绝并说明原因。 +## 可选的会话工作流 + +本 Skill 只负责准备 worktree 和生成启动命令。若任务目录中另外存在 `workflow.yaml`,用户可以在已经启动的 Codex 或 Claude 会话中调用全局 `taskflow-workflow` Skill,并由宿主的 `/loop` 驱动后续有界迭代。两个 Skill 的职责不同:本 Skill 不记录阶段进度,也不替代 workflow Skill 的状态、checkpoint 或机器校验流程。 + +工作流 Skill 会再次检查 worktree identity;如果配置、worktree 或 ownership 不满足条件,应先修复本 Skill 报告的诊断,再开始会话工作流。没有 `workflow.yaml` 的任务继续按本 Skill 的普通 worktree 流程使用。 + ## 失败处理 优先使用 JSON 输出,读取 `code`、`repo` 和 `message`,再采取最小修复: