diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 9162fd4..e8383c0 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -39,6 +39,7 @@ As of now: - a repository-level skill workspace monorepo migration plan now exists under `docs/skill-workspace-monorepo.md`, defining the target split between runtime packages under `packages/`, agent-facing skill bundles under `skills/`, support apps under `apps/`, and package-based skill packaging flows - the first migration phase for the skill workspace monorepo is now complete: root `go.work` exists, `pnpm-workspace.yaml` now discovers `packages/*`, empty runtime module roots now exist under `packages/`, and a declarative `scripts/skill-bundles.json` plus `scripts/package_skill_runtimes.sh` scaffold now define package-oriented skill bundle metadata from the repo root - `packages/coord-core` now exists as the first real extracted runtime package, containing shared coordination DB/schema, protocol, and store code, and the active coordination runtimes now import `coord-core` instead of root `internal/db`, `internal/store`, and `internal/protocol` +- `packages/inbox-runtime` and `packages/orch-runtime` now exist as package-owned runtimes with their own `cmd/` entrypoints and package-local CLI wiring/tests, and the root skill packaging flow now builds `skills/inbox`, `skills/orch`, and `skills/council-review` from package entrypoints instead of root `cmd/` paths - a repo-local `scripts/package_skill_clis.sh` packaging flow now builds bundled skill CLI assets for `inbox`, `orch`, and `council-review` - `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `wait`, `blocked`, `answer`, `retry`, `reassign`, `cancel`, `cleanup`, and `status` - `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, retry or reassign work, cancel tasks or runs, clean attempt worktrees, and create per-attempt Git worktrees during strict dispatch @@ -501,10 +502,15 @@ Completed so far: - `packages/coord-core/store` now owns the shared inbox, orch, and council store logic plus its coordination-domain tests - root coordination runtimes under `cmd/`, `internal/cli/`, `internal/app/`, `internal/httpapi/`, and `internal/query/` now import `coord-core` instead of depending on root `internal/db`, `internal/store`, or root `internal/protocol` - `go test ./...` still passes for the root module, and `go test ./...` passes inside `packages/coord-core` +- `packages/inbox-runtime/cmd/inbox` plus `packages/inbox-runtime/internal/cli/inbox` now provide a package-owned inbox runtime and pass `go test ./...` +- `packages/orch-runtime/cmd/orch` plus `packages/orch-runtime/internal/cli/orch` now provide a package-owned orch runtime and pass `go test ./...` +- `scripts/skill-bundles.json` now marks `inbox`, `orch`, and `council-review` as ready package-backed bundles +- `scripts/package_skill_runtimes.sh package` now builds and installs `skills/inbox/assets/inbox`, `skills/orch/assets/orch`, and `skills/council-review/assets/orch` from package entrypoints +- the legacy `scripts/package_skill_clis.sh` entrypoint now delegates to the declarative package-oriented packaging flow instead of hardcoding root `cmd/` paths Remaining: -- extract `inbox`, `orch`, and `orchd` into package-owned runtimes +- extract `orchd` into a package-owned runtime - import `repo-memory` as its own runtime package and add the corresponding skill bundle - graduate the bundle scaffold into the primary packaging flow once package-owned runtime entrypoints exist @@ -515,11 +521,11 @@ If a new agent is taking over now, the next concrete step should be: 1. treat `Milestone 9: Web Product Phase 2 Read-Only Operator UI` as complete for the initial operator surface and do not expand web feature scope further until the workspace split is decided package-by-package 2. treat the Phase 1 workspace bootstrap for `Milestone 10` as complete and keep the new `go.work`, `packages/`, and declarative bundle metadata as the baseline for all further migration steps 3. treat the shared coordination kernel extraction into `packages/coord-core` as complete and move `inbox` plus `orch` into package-owned runtimes next -4. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as runtime ownership moves from root paths to package paths -5. keep the legacy hardcoded packaging flow working temporarily, but evolve the new declarative bundle scaffold into the primary packaging path before adding `repo-memory` +4. treat `inbox-runtime` and `orch-runtime` as package-owned and move `orchd` into `packages/orchd-runtime` next so the web backend stops depending on root-owned runtime code +5. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as runtime ownership moves from root paths to package paths 6. import `repo-memory` only after the package-based runtime and skill packaging pattern exists -The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, the web product now has its first real operator-facing read surfaces, and the repository has completed both the workspace bootstrap and the shared coordination-kernel extraction phases of the skill monorepo migration, so the next step should be runtime extraction rather than continuing to accrete new root-owned runtimes. +The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, the web product now has its first real operator-facing read surfaces, and the repository has completed the workspace bootstrap, the shared coordination-kernel extraction, and the first package-owned runtime extraction phases of the skill monorepo migration, so the next step should be moving `orchd` and then importing `repo-memory`, not continuing to accrete new root-owned runtime paths. ## Recommended Driver Choices diff --git a/docs/roadmaps/active/skill-workspace-monorepo-migration.md b/docs/roadmaps/active/skill-workspace-monorepo-migration.md index 1504744..bccd186 100644 --- a/docs/roadmaps/active/skill-workspace-monorepo-migration.md +++ b/docs/roadmaps/active/skill-workspace-monorepo-migration.md @@ -27,7 +27,7 @@ - [x] create or adopt an active execution roadmap for the migration workstream - [x] Phase 1: bootstrap `go.work`, expanded workspace manifests, package roots, and declarative skill bundle metadata - [x] Phase 2: extract shared coordination code into `packages/coord-core` -- [ ] Phase 3: extract `inbox-runtime` and `orch-runtime` +- [x] Phase 3: extract `inbox-runtime` and `orch-runtime` - [ ] Phase 4: extract `orchd-runtime` - [ ] Phase 5: import `repo-memory-runtime` and add `skills/repo-memory` - [ ] Phase 6: remove root runtime ownership and normalize package-based packaging @@ -54,4 +54,4 @@ ## Next Step -- start Phase 3 by moving `inbox` and `orch` into package-owned runtimes on top of the now-shared `packages/coord-core` kernel +- start Phase 4 by moving the HTTP/query/web backend runtime into `packages/orchd-runtime` on top of the extracted `coord-core`, `inbox-runtime`, and `orch-runtime` packages diff --git a/package.json b/package.json index 3bd7dba..e9997f4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "packageManager": "pnpm@10.25.0", "scripts": { + "skills:bundle": "bash ./scripts/package_skill_runtimes.sh package", "skills:bundle:plan": "bash ./scripts/package_skill_runtimes.sh plan", "skills:bundle:validate": "bash ./scripts/package_skill_runtimes.sh validate", "web:dev": "pnpm --filter @ai-workflow-skill/web dev", diff --git a/packages/inbox-runtime/cmd/inbox/main.go b/packages/inbox-runtime/cmd/inbox/main.go new file mode 100644 index 0000000..e4526eb --- /dev/null +++ b/packages/inbox-runtime/cmd/inbox/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + inboxcli "ai-workflow-skill/packages/inbox-runtime/internal/cli/inbox" +) + +func main() { + os.Exit(inboxcli.Execute(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/packages/inbox-runtime/go.mod b/packages/inbox-runtime/go.mod index 17828b1..c186850 100644 --- a/packages/inbox-runtime/go.mod +++ b/packages/inbox-runtime/go.mod @@ -1,3 +1,7 @@ module ai-workflow-skill/packages/inbox-runtime go 1.26 + +require ( + github.com/spf13/cobra v1.10.1 +) diff --git a/packages/inbox-runtime/internal/cli/inbox/artifact.go b/packages/inbox-runtime/internal/cli/inbox/artifact.go new file mode 100644 index 0000000..54a6eb0 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/artifact.go @@ -0,0 +1,78 @@ +package inbox + +import ( + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type artifactOptions struct { + paths []string + kinds []string + metadataJSONs []string +} + +func addArtifactFlags(cmd *cobra.Command, opts *artifactOptions) { + cmd.Flags().StringArrayVar(&opts.paths, "artifact", nil, "Artifact path to attach; may be repeated") + cmd.Flags().StringArrayVar(&opts.kinds, "artifact-kind", nil, "Artifact kind; one value applies to all, or match artifact count") + cmd.Flags().StringArrayVar(&opts.metadataJSONs, "artifact-metadata-json", nil, "Artifact metadata JSON; one value applies to all, or match artifact count") +} + +func resolveArtifacts(opts artifactOptions) ([]store.ArtifactInput, error) { + if len(opts.paths) == 0 { + if len(opts.kinds) > 0 || len(opts.metadataJSONs) > 0 { + return nil, protocol.InvalidInput("artifact-kind and artifact-metadata-json require at least one artifact path", nil) + } + return nil, nil + } + + kinds, err := expandArtifactValues(opts.kinds, len(opts.paths), "artifact-kind") + if err != nil { + return nil, err + } + metadataJSONs, err := expandArtifactValues(opts.metadataJSONs, len(opts.paths), "artifact-metadata-json") + if err != nil { + return nil, err + } + + artifacts := make([]store.ArtifactInput, 0, len(opts.paths)) + for i, path := range opts.paths { + if strings.TrimSpace(path) == "" { + return nil, protocol.InvalidInput("artifact path cannot be empty", nil) + } + + artifact := store.ArtifactInput{ + Path: path, + Kind: "file", + } + if len(kinds) > 0 { + artifact.Kind = kinds[i] + } + if len(metadataJSONs) > 0 { + artifact.MetadataJSON = metadataJSONs[i] + } + artifacts = append(artifacts, artifact) + } + + return artifacts, nil +} + +func expandArtifactValues(values []string, target int, flagName string) ([]string, error) { + switch len(values) { + case 0: + return nil, nil + case 1: + out := make([]string, target) + for i := range out { + out[i] = values[0] + } + return out, nil + case target: + return values, nil + default: + return nil, protocol.InvalidInput(flagName+" must be specified once or once per artifact", nil) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/body.go b/packages/inbox-runtime/internal/cli/inbox/body.go new file mode 100644 index 0000000..3dfe9f1 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/body.go @@ -0,0 +1,22 @@ +package inbox + +import ( + "os" + + "ai-workflow-skill/packages/coord-core/protocol" +) + +func resolveBodyValue(body, bodyFile string) (string, error) { + if body != "" && bodyFile != "" { + return "", protocol.InvalidInput("body and body-file are mutually exclusive", nil) + } + if bodyFile == "" { + return body, nil + } + + content, err := os.ReadFile(bodyFile) + if err != nil { + return "", protocol.InvalidInput("failed to read body-file", err) + } + return string(content), nil +} diff --git a/packages/inbox-runtime/internal/cli/inbox/cancel.go b/packages/inbox-runtime/internal/cli/inbox/cancel.go new file mode 100644 index 0000000..c980f67 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/cancel.go @@ -0,0 +1,83 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type cancelOptions struct { + agent string + threadID string + reason string + artifacts artifactOptions +} + +func newCancelCmd(root *rootOptions) *cobra.Command { + opts := &cancelOptions{} + + cmd := &cobra.Command{ + Use: "cancel", + Short: "Cancel a thread", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + if agent == "" { + return protocol.InvalidInput("agent is required", nil) + } + artifacts, err := resolveArtifacts(opts.artifacts) + if err != nil { + return err + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + thread, message, err := s.CancelThread(ctx, store.CancelInput{ + ThreadID: opts.threadID, + Agent: agent, + Reason: opts.reason, + Artifacts: artifacts, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "cancel", + Data: map[string]any{ + "thread": thread, + "message": message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled thread %s\n", thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Acting agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().StringVar(&opts.reason, "reason", "", "Cancellation reason") + addArtifactFlags(cmd, &opts.artifacts) + + _ = cmd.MarkFlagRequired("thread") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/cancel_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/cancel_integration_test.go new file mode 100644 index 0000000..0197d4d --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/cancel_integration_test.go @@ -0,0 +1,148 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCancelMarksThreadCancelled(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Implement cancellation", + "--summary", "Initial request", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + cancelOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", "Task superseded by a larger refactor", + ) + + var cancelResp map[string]any + mustDecodeJSON(t, cancelOut, &cancelResp) + if status := nestedString(t, cancelResp, "data", "thread", "status"); status != "cancelled" { + t.Fatalf("expected cancelled thread, got %q", status) + } + if kind := nestedString(t, cancelResp, "data", "message", "kind"); kind != "control" { + t.Fatalf("expected control message, got %q", kind) + } +} + +func TestCancelPersistsReasonAndArtifact(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + cancelPath := filepath.Join(tempDir, "cancel.md") + if err := os.WriteFile(cancelPath, []byte("Cancelled by product decision"), 0o644); err != nil { + t.Fatalf("write cancel artifact: %v", err) + } + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Implement cancellation", + "--summary", "Initial request", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", "Task superseded by a larger refactor", + "--artifact", cancelPath, + "--artifact-kind", "brief", + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) == 0 { + t.Fatalf("expected non-empty message history, got %#v", nestedValue(t, showResp, "data", "messages")) + } + + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", messages[len(messages)-1]) + } + if got := lastMessage["summary"]; got != "Task superseded by a larger refactor" { + t.Fatalf("expected cancel summary, got %#v", got) + } + if got := lastMessage["body"]; got != "Task superseded by a larger refactor" { + t.Fatalf("expected cancel body, got %#v", got) + } + artifacts, ok := lastMessage["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one cancel artifact, got %#v", lastMessage["artifacts"]) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if got := artifact["path"]; got != cancelPath { + t.Fatalf("expected artifact path %q, got %#v", cancelPath, got) + } + if got := artifact["kind"]; got != "brief" { + t.Fatalf("expected artifact kind brief, got %#v", got) + } +} + +func TestCancelRejectsWhenThreadMissing(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "leader", + "--json", + "cancel", + "--thread", "thr_missing", + ) + if exitCode != 40 { + t.Fatalf("expected not-found exit code 40, got %d with %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + diff --git a/packages/inbox-runtime/internal/cli/inbox/claim.go b/packages/inbox-runtime/internal/cli/inbox/claim.go new file mode 100644 index 0000000..0ddb6d9 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/claim.go @@ -0,0 +1,80 @@ +package inbox + +import ( + "errors" + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type claimOptions struct { + agent string + threadID string + leaseSeconds int +} + +func newClaimCmd(root *rootOptions) *cobra.Command { + opts := &claimOptions{} + + cmd := &cobra.Command{ + Use: "claim", + Short: "Acquire a lease on a pending thread", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + if agent == "" { + return protocol.InvalidInput("agent is required", nil) + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + result, err := s.ClaimThread(ctx, store.ClaimInput{ + ThreadID: opts.threadID, + Agent: agent, + LeaseSeconds: opts.leaseSeconds, + }) + if err != nil { + if errors.Is(err, store.ErrLeaseConflict) { + return fmt.Errorf("lease conflict: %w", err) + } + return err + } + + resp := protocol.Success{ + OK: true, + Command: "claim", + Data: map[string]any{ + "thread": result.Thread, + "message": result.Message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "claimed thread %s\n", result.Thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Claiming agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().IntVar(&opts.leaseSeconds, "lease-seconds", 900, "Lease duration in seconds") + + _ = cmd.MarkFlagRequired("thread") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/claim_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/claim_integration_test.go new file mode 100644 index 0000000..2ee5875 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/claim_integration_test.go @@ -0,0 +1,112 @@ +package inbox + +import "testing" + +func TestClaimAcquiresThreadLease(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Race claim", "Claim this task") + + claimOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + "--lease-seconds", "300", + ) + + var claimResp map[string]any + mustDecodeJSON(t, claimOut, &claimResp) + if got := nestedString(t, claimResp, "data", "thread", "status"); got != "claimed" { + t.Fatalf("expected claimed status, got %q", got) + } + if got := nestedString(t, claimResp, "data", "thread", "assigned_to"); got != "worker-a" { + t.Fatalf("expected assigned_to worker-a, got %q", got) + } + if got := nestedString(t, claimResp, "data", "message", "kind"); got != "event" { + t.Fatalf("expected event message kind, got %q", got) + } + if got := nestedString(t, claimResp, "data", "message", "summary"); got != "thread claimed" { + t.Fatalf("expected summary thread claimed, got %q", got) + } +} + +func TestClaimRejectsWhenThreadMissing(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-z", + "--thread", "thr_missing", + ) + if exitCode != 40 { + t.Fatalf("expected exit code 40, got %d", exitCode) + } + assertErrorJSON(t, stdout, "not_found") +} + +func TestClaimRejectsWhenThreadAlreadyClaimed(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-z", "Claimed task", "Already claimed") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-z", + "--thread", threadID, + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-y", + "--thread", threadID, + ) + if exitCode != 20 { + t.Fatalf("expected exit code 20, got %d", exitCode) + } + assertErrorJSON(t, stdout, "lease_conflict") +} + +func TestClaimRecordsRequestedLeaseDuration(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Lease payload", "Verify lease payload") + + claimOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + "--lease-seconds", "300", + ) + + var claimResp map[string]any + mustDecodeJSON(t, claimOut, &claimResp) + payload, ok := nestedValue(t, claimResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, claimResp, "data", "message", "payload_json")) + } + leaseSeconds, ok := payload["lease_seconds"].(float64) + if !ok || int(leaseSeconds) != 300 { + t.Fatalf("expected lease_seconds 300, got %#v", payload["lease_seconds"]) + } + leaseToken, _ := payload["lease_token"].(string) + if leaseToken == "" { + t.Fatalf("expected non-empty lease_token, got %#v", payload["lease_token"]) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/db.go b/packages/inbox-runtime/internal/cli/inbox/db.go new file mode 100644 index 0000000..c187f8c --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/db.go @@ -0,0 +1,22 @@ +package inbox + +import ( + "context" + "database/sql" + + "ai-workflow-skill/packages/coord-core/db" +) + +func openInboxDB(ctx context.Context, dbPath string) (*sql.DB, error) { + sqlDB, err := db.Open(ctx, dbPath) + if err != nil { + return nil, err + } + + if err := db.ApplyMigrations(ctx, sqlDB); err != nil { + _ = sqlDB.Close() + return nil, err + } + + return sqlDB, nil +} diff --git a/packages/inbox-runtime/internal/cli/inbox/done.go b/packages/inbox-runtime/internal/cli/inbox/done.go new file mode 100644 index 0000000..8ba49db --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/done.go @@ -0,0 +1,106 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type completeOptions struct { + agent string + threadID string + summary string + body string + bodyFile string + payloadJSON string + artifacts artifactOptions +} + +func newDoneCmd(root *rootOptions) *cobra.Command { + return newCompleteCmd(root, "done") +} + +func newFailCmd(root *rootOptions) *cobra.Command { + return newCompleteCmd(root, "fail") +} + +func newCompleteCmd(root *rootOptions, mode string) *cobra.Command { + opts := &completeOptions{} + + cmd := &cobra.Command{ + Use: mode, + Short: map[string]string{"done": "Mark a thread complete", "fail": "Mark a thread failed"}[mode], + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + if agent == "" { + return protocol.InvalidInput("agent is required", nil) + } + + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + artifacts, err := resolveArtifacts(opts.artifacts) + if err != nil { + return err + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + thread, message, err := s.CompleteThread(ctx, store.CompleteInput{ + ThreadID: opts.threadID, + Agent: agent, + Summary: opts.summary, + Body: body, + PayloadJSON: opts.payloadJSON, + Failed: mode == "fail", + Artifacts: artifacts, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: mode, + Data: map[string]any{ + "thread": thread, + "message": message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s thread %s\n", mode, thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Acting agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Short completion summary") + cmd.Flags().StringVar(&opts.body, "body", "", "Completion body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read completion body from file") + cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string") + addArtifactFlags(cmd, &opts.artifacts) + + _ = cmd.MarkFlagRequired("thread") + _ = cmd.MarkFlagRequired("summary") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/done_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/done_integration_test.go new file mode 100644 index 0000000..f0041e9 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/done_integration_test.go @@ -0,0 +1,140 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDoneMarksThreadTerminal(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + doneOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--body", "The HTTP client now retries the selected transient failures.", + ) + + var doneResp map[string]any + mustDecodeJSON(t, doneOut, &doneResp) + if status := nestedString(t, doneResp, "data", "thread", "status"); status != "done" { + t.Fatalf("expected done thread status, got %q", status) + } + if kind := nestedString(t, doneResp, "data", "message", "kind"); kind != "result" { + t.Fatalf("expected result message kind, got %q", kind) + } +} + +func TestDonePersistsResultBodyAndArtifact(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + resultPath := filepath.Join(tempDir, "result.md") + body := "Result from body file." + if err := os.WriteFile(resultPath, []byte(body), 0o644); err != nil { + t.Fatalf("write result file: %v", err) + } + + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--body-file", resultPath, + "--artifact", resultPath, + "--artifact-kind", "report", + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + + if gotBody, _ := lastMessage["body"].(string); gotBody != body { + t.Fatalf("expected body %q, got %#v", body, lastMessage["body"]) + } + artifacts, ok := lastMessage["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", lastMessage["artifacts"]) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if gotPath, _ := artifact["path"].(string); gotPath != resultPath { + t.Fatalf("expected artifact path %q, got %#v", resultPath, artifact["path"]) + } + if gotKind, _ := artifact["kind"].(string); gotKind != "report" { + t.Fatalf("expected artifact kind report, got %#v", artifact["kind"]) + } +} + +func TestDoneRejectsNonOwner(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "done", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Retry policy implemented", + ) + if exitCode != 20 { + t.Fatalf("expected exit code 20, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "lease_conflict") +} + +func TestDoneRejectsOnTerminalThread(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/execute.go b/packages/inbox-runtime/internal/cli/inbox/execute.go new file mode 100644 index 0000000..67e5561 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/execute.go @@ -0,0 +1,113 @@ +package inbox + +import ( + "errors" + "fmt" + "io" + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" +) + +func Execute(args []string, stdout, stderr io.Writer) int { + cmd := NewRootCmd() + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + jsonOutput := hasJSONFlag(args) + renderError(stdout, stderr, jsonOutput, err) + return exitCodeForError(err) + } + + return 0 +} + +func exitCodeForError(err error) int { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.ExitCode + } + + switch { + case isUsageError(err): + return 30 + case errors.Is(err, store.ErrLeaseConflict): + return 20 + case errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound): + return 40 + case errors.Is(err, store.ErrInvalidInput), errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease): + return 30 + default: + return 50 + } +} + +func errorCodeForError(err error) string { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.Code + } + + switch { + case isUsageError(err): + return "invalid_input" + case errors.Is(err, store.ErrLeaseConflict): + return "lease_conflict" + case errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound): + return "not_found" + case errors.Is(err, store.ErrInvalidInput): + return "invalid_input" + case errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease): + return "invalid_state" + default: + return "internal_error" + } +} + +func renderError(stdout, stderr io.Writer, jsonOutput bool, err error) { + message := errorMessage(err) + if jsonOutput { + _ = protocol.WriteJSON(stdout, protocol.Error{ + OK: false, + Error: protocol.ErrorPayload{ + Code: errorCodeForError(err), + Message: message, + }, + }) + return + } + + _, _ = fmt.Fprintln(stderr, message) +} + +func errorMessage(err error) string { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.Message + } + return err.Error() +} + +func hasJSONFlag(args []string) bool { + for _, arg := range args { + if arg == "--json" { + return true + } + if strings.HasPrefix(arg, "--json=") { + return !strings.HasSuffix(arg, "=false") + } + } + return false +} + +func isUsageError(err error) bool { + message := err.Error() + return strings.HasPrefix(message, "required flag(s)") || + strings.HasPrefix(message, "unknown flag:") || + strings.HasPrefix(message, "unknown command ") || + strings.Contains(message, " accepts ") || + strings.Contains(message, "invalid argument ") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/fail_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/fail_integration_test.go new file mode 100644 index 0000000..62bf9d1 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/fail_integration_test.go @@ -0,0 +1,143 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFailMarksThreadFailed(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + failOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--body", "The migration cannot proceed because the prior schema is inconsistent.", + ) + + var failResp map[string]any + mustDecodeJSON(t, failOut, &failResp) + if status := nestedString(t, failResp, "data", "thread", "status"); status != "failed" { + t.Fatalf("expected failed thread status, got %q", status) + } + if kind := nestedString(t, failResp, "data", "message", "kind"); kind != "result" { + t.Fatalf("expected result message kind, got %q", kind) + } + if toAgent := nestedString(t, failResp, "data", "message", "to_agent"); toAgent != "leader" { + t.Fatalf("expected message to_agent leader, got %q", toAgent) + } +} + +func TestFailPersistsFailureBodyAndArtifact(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + failurePath := filepath.Join(tempDir, "failure.md") + body := "Failure details from file." + if err := os.WriteFile(failurePath, []byte(body), 0o644); err != nil { + t.Fatalf("write failure file: %v", err) + } + + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--body-file", failurePath, + "--artifact", failurePath, + "--artifact-kind", "report", + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + + if gotBody, _ := lastMessage["body"].(string); gotBody != body { + t.Fatalf("expected body %q, got %#v", body, lastMessage["body"]) + } + artifacts, ok := lastMessage["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", lastMessage["artifacts"]) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if gotPath, _ := artifact["path"].(string); gotPath != failurePath { + t.Fatalf("expected artifact path %q, got %#v", failurePath, artifact["path"]) + } + if gotKind, _ := artifact["kind"].(string); gotKind != "report" { + t.Fatalf("expected artifact kind report, got %#v", artifact["kind"]) + } +} + +func TestFailRejectsNonOwner(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-x", + "--thread", threadID, + "--summary", "Migration failed", + ) + if exitCode != 20 { + t.Fatalf("expected exit code 20, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "lease_conflict") +} + +func TestFailRejectsOnTerminalThread(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-b", "worker-b") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/fetch.go b/packages/inbox-runtime/internal/cli/inbox/fetch.go new file mode 100644 index 0000000..0564621 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/fetch.go @@ -0,0 +1,97 @@ +package inbox + +import ( + "fmt" + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type fetchOptions struct { + agent string + statuses string + limit int + unread bool +} + +func newFetchCmd(root *rootOptions) *cobra.Command { + opts := &fetchOptions{} + + cmd := &cobra.Command{ + Use: "fetch", + Short: "List candidate threads for an agent", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + threads, err := s.FetchThreads(ctx, store.FetchInput{ + Agent: agent, + Statuses: parseCSV(opts.statuses), + Limit: opts.limit, + Unread: opts.unread, + }) + if err != nil { + return err + } + if len(threads) == 0 { + return protocol.NoMatchingWork("no matching work") + } + + resp := protocol.Success{ + OK: true, + Command: "fetch", + Data: map[string]any{ + "threads": threads, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + for _, thread := range threads { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", thread.ThreadID, thread.Status, thread.Subject); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Assigned agent filter") + cmd.Flags().StringVar(&opts.statuses, "status", "pending", "Comma-separated status filter") + cmd.Flags().IntVar(&opts.limit, "limit", 20, "Maximum number of threads") + cmd.Flags().BoolVar(&opts.unread, "unread", false, "Only return threads whose latest message is unread by the agent") + + return cmd +} + +func parseCSV(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + raw := strings.Split(value, ",") + out := make([]string, 0, len(raw)) + for _, entry := range raw { + entry = strings.TrimSpace(entry) + if entry != "" { + out = append(out, entry) + } + } + return out +} diff --git a/packages/inbox-runtime/internal/cli/inbox/fetch_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/fetch_integration_test.go new file mode 100644 index 0000000..efdbf4e --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/fetch_integration_test.go @@ -0,0 +1,187 @@ +package inbox + +import "testing" + +func TestFetchReturnsPendingThreadForTargetAgent(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + sendPendingThread(t, dbPath, "leader", "worker-a", "Implement task", "Create API endpoint") + + fetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-a", + "--status", "pending", + ) + + var fetchResp map[string]any + mustDecodeJSON(t, fetchOut, &fetchResp) + threads, ok := nestedValue(t, fetchResp, "data", "threads").([]any) + if !ok || len(threads) < 1 { + t.Fatalf("expected at least one fetched thread, got %#v", nestedValue(t, fetchResp, "data", "threads")) + } + thread, ok := threads[0].(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", threads[0]) + } + if got, _ := thread["assigned_to"].(string); got != "worker-a" { + t.Fatalf("expected assigned_to worker-a, got %#v", thread["assigned_to"]) + } + if got, _ := thread["status"].(string); got != "pending" { + t.Fatalf("expected pending status, got %#v", thread["status"]) + } +} + +func TestFetchRespectsStatusAndLimitFilters(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + sendPendingThread(t, dbPath, "leader", "worker-a", "Task A", "Pending task") + blockedThreadID := sendPendingThread(t, dbPath, "leader", "worker-a", "Task B", "Blocked task") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", blockedThreadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", blockedThreadID, + "--status", "blocked", + "--summary", "Need decision", + "--payload-json", `{"question":"continue?"}`, + ) + + fetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-a", + "--status", "pending,blocked", + "--limit", "1", + ) + + var fetchResp map[string]any + mustDecodeJSON(t, fetchOut, &fetchResp) + threads, ok := nestedValue(t, fetchResp, "data", "threads").([]any) + if !ok { + t.Fatalf("expected threads array, got %#v", nestedValue(t, fetchResp, "data", "threads")) + } + if len(threads) > 1 { + t.Fatalf("expected at most one thread with limit=1, got %d", len(threads)) + } + if len(threads) == 0 { + t.Fatalf("expected one thread with status filter, got empty result") + } + thread, ok := threads[0].(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", threads[0]) + } + status, _ := thread["status"].(string) + if status != "pending" && status != "blocked" { + t.Fatalf("expected pending or blocked status, got %#v", thread["status"]) + } +} + +func TestFetchUnreadUsesReadCursor(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-e", "Review navbar copy", "Check top nav wording") + + firstFetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + + var firstFetchResp map[string]any + mustDecodeJSON(t, firstFetchOut, &firstFetchResp) + firstThreads, ok := nestedValue(t, firstFetchResp, "data", "threads").([]any) + if !ok || len(firstThreads) != 1 { + t.Fatalf("expected one unread thread before mark-read, got %#v", nestedValue(t, firstFetchResp, "data", "threads")) + } + + runInboxCommand( + t, + "--db", dbPath, + "--agent", "worker-e", + "--json", + "show", + "--thread", threadID, + "--mark-read", + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + if exitCode != 10 { + t.Fatalf("expected unread fetch to return no_matching_work after mark-read, got exit=%d stdout=%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-e", + "--thread", threadID, + "--summary", "Use sentence case", + "--body", "Keep the nav labels in sentence case.", + ) + + thirdFetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + var thirdFetchResp map[string]any + mustDecodeJSON(t, thirdFetchOut, &thirdFetchResp) + thirdThreads, ok := nestedValue(t, thirdFetchResp, "data", "threads").([]any) + if !ok || len(thirdThreads) != 1 { + t.Fatalf("expected unread thread to reappear after new message, got %#v", nestedValue(t, thirdFetchResp, "data", "threads")) + } +} + +func TestFetchReturnsNoMatchingWorkWhenEmpty(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-z", + "--status", "pending", + ) + if exitCode != 10 { + t.Fatalf("expected exit code 10, got %d", exitCode) + } + assertErrorJSON(t, stdout, "no_matching_work") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/init.go b/packages/inbox-runtime/internal/cli/inbox/init.go new file mode 100644 index 0000000..2be90d4 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/init.go @@ -0,0 +1,41 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + + "github.com/spf13/cobra" +) + +func newInitCmd(opts *rootOptions) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize the shared SQLite database schema", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openInboxDB(ctx, opts.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + resp := protocol.Success{ + OK: true, + Command: "init", + Data: map[string]any{ + "db_path": opts.dbPath, + "status": "initialized", + }, + } + + if opts.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "initialized database: %s\n", opts.dbPath) + return err + }, + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/init_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/init_integration_test.go new file mode 100644 index 0000000..99b8bb0 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/init_integration_test.go @@ -0,0 +1,83 @@ +package inbox + +import ( + "path/filepath" + "testing" +) + +func TestInitCreatesSchemaOnEmptyDB(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + initOut := runInboxCommand(t, "--db", dbPath, "--json", "init") + + var initResp map[string]any + mustDecodeJSON(t, initOut, &initResp) + + if ok, _ := initResp["ok"].(bool); !ok { + t.Fatalf("expected ok=true, got %#v", initResp) + } + if cmd, _ := initResp["command"].(string); cmd != "init" { + t.Fatalf("expected command init, got %#v", initResp["command"]) + } + if got := nestedString(t, initResp, "data", "db_path"); got != dbPath { + t.Fatalf("expected db_path %q, got %q", dbPath, got) + } + if got := nestedString(t, initResp, "data", "status"); got != "initialized" { + t.Fatalf("expected initialized status, got %q", got) + } +} + +func TestInitIsIdempotentOnExistingDB(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + firstOut := runInboxCommand(t, "--db", dbPath, "--json", "init") + secondOut := runInboxCommand(t, "--db", dbPath, "--json", "init") + + var firstResp map[string]any + var secondResp map[string]any + mustDecodeJSON(t, firstOut, &firstResp) + mustDecodeJSON(t, secondOut, &secondResp) + + if got := nestedString(t, firstResp, "data", "status"); got != "initialized" { + t.Fatalf("expected first init status initialized, got %q", got) + } + if got := nestedString(t, secondResp, "data", "status"); got != "initialized" { + t.Fatalf("expected second init status initialized, got %q", got) + } + if got := nestedString(t, firstResp, "data", "db_path"); got != dbPath { + t.Fatalf("expected first db_path %q, got %q", dbPath, got) + } + if got := nestedString(t, secondResp, "data", "db_path"); got != dbPath { + t.Fatalf("expected second db_path %q, got %q", dbPath, got) + } +} + +func initCommandTestDB(t *testing.T) string { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + return dbPath +} + +func sendPendingThread(t *testing.T, dbPath, from, to, subject, summary string) string { + t.Helper() + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", from, + "--to", to, + "--subject", subject, + "--summary", summary, + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + return nestedString(t, sendResp, "data", "thread", "thread_id") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/integration_test.go b/packages/inbox-runtime/internal/cli/inbox/integration_test.go new file mode 100644 index 0000000..da84f05 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/integration_test.go @@ -0,0 +1,734 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestInboxLifecycle(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + initOut := runInboxCommand(t, "--db", dbPath, "--json", "init") + var initResp map[string]any + mustDecodeJSON(t, initOut, &initResp) + if initResp["ok"] != true { + t.Fatalf("expected init ok=true, got %#v", initResp) + } + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Implement feature X", + "--summary", "Add retry policy", + "--body", "Implement retry handling for the HTTP client.", + "--run", "run_blog_001", + "--task", "T1", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + threadStatus := nestedString(t, sendResp, "data", "thread", "status") + if threadStatus != "pending" { + t.Fatalf("expected pending thread, got %q", threadStatus) + } + + fetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-a", + "--status", "pending", + ) + + var fetchResp map[string]any + mustDecodeJSON(t, fetchOut, &fetchResp) + threadsValue := nestedValue(t, fetchResp, "data", "threads") + threads, ok := threadsValue.([]any) + if !ok || len(threads) != 1 { + t.Fatalf("expected one fetched thread, got %#v", threadsValue) + } + + claimOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + "--lease-seconds", "300", + ) + + var claimResp map[string]any + mustDecodeJSON(t, claimOut, &claimResp) + claimedStatus := nestedString(t, claimResp, "data", "thread", "status") + if claimedStatus != "claimed" { + t.Fatalf("expected claimed thread, got %q", claimedStatus) + } + + updateOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + "--body", "Scanning current HTTP client usage.", + ) + + var updateResp map[string]any + mustDecodeJSON(t, updateOut, &updateResp) + updatedStatus := nestedString(t, updateResp, "data", "thread", "status") + if updatedStatus != "in_progress" { + t.Fatalf("expected in_progress thread, got %q", updatedStatus) + } + + blockedOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need timeout decision", + "--payload-json", `{"question":"Should retries apply to read timeouts?"}`, + ) + + var blockedResp map[string]any + mustDecodeJSON(t, blockedOut, &blockedResp) + blockedStatus := nestedString(t, blockedResp, "data", "thread", "status") + if blockedStatus != "blocked" { + t.Fatalf("expected blocked thread, got %q", blockedStatus) + } + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry read timeouts", + "--body", "Yes, include read timeouts in the retry policy.", + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + replyKind := nestedString(t, replyResp, "data", "message", "kind") + if replyKind != "answer" { + t.Fatalf("expected answer reply, got %q", replyKind) + } + + doneOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--body", "The HTTP client now retries the selected transient failures.", + ) + + var doneResp map[string]any + mustDecodeJSON(t, doneOut, &doneResp) + doneStatus := nestedString(t, doneResp, "data", "thread", "status") + if doneStatus != "done" { + t.Fatalf("expected done thread, got %q", doneStatus) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + showStatus := nestedString(t, showResp, "data", "thread", "status") + if showStatus != "done" { + t.Fatalf("expected show status done, got %q", showStatus) + } + messagesValue := nestedValue(t, showResp, "data", "messages") + messages, ok := messagesValue.([]any) + if !ok || len(messages) != 6 { + t.Fatalf("expected six messages in thread history, got %#v", messagesValue) + } +} + +func TestInboxFailLifecycle(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-b", + "--subject", "Investigate failing migration", + "--summary", "Check migration failure", + "--run", "run_blog_002", + "--task", "T2", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-b", + "--thread", threadID, + ) + + failOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-b", + "--thread", threadID, + "--summary", "Migration failed", + "--body", "The migration cannot proceed because the prior schema is inconsistent.", + ) + + var failResp map[string]any + mustDecodeJSON(t, failOut, &failResp) + failStatus := nestedString(t, failResp, "data", "thread", "status") + if failStatus != "failed" { + t.Fatalf("expected failed thread, got %q", failStatus) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + showStatus := nestedString(t, showResp, "data", "thread", "status") + if showStatus != "failed" { + t.Fatalf("expected show status failed, got %q", showStatus) + } +} + +func TestInboxRenewWaitReplyAndCancel(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-c", + "--subject", "Investigate auth edge case", + "--summary", "Check auth redirect behavior", + "--run", "run_blog_003", + "--task", "T3", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-c", + "--thread", threadID, + "--lease-seconds", "300", + ) + + renewOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-c", + "--thread", threadID, + "--lease-seconds", "600", + ) + + var renewResp map[string]any + mustDecodeJSON(t, renewOut, &renewResp) + if got := nestedString(t, renewResp, "data", "message", "summary"); got != "lease renewed" { + t.Fatalf("expected lease renewed summary, got %q", got) + } + + blockedOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-c", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need policy decision", + "--body", "Should guest users be redirected to login or shown a 403 page?", + ) + + var blockedResp map[string]any + mustDecodeJSON(t, blockedOut, &blockedResp) + blockedMessageID := nestedString(t, blockedResp, "data", "message", "message_id") + + type commandResult struct { + stdout string + stderr string + exit int + } + + waitCh := make(chan commandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "--json", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--timeout-seconds", "2", + ) + waitCh <- commandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--summary", "Redirect to login", + "--body", "Redirect guests to login for the MVP.", + ) + + var waitResult commandResult + select { + case waitResult = <-waitCh: + case <-time.After(3 * time.Second): + t.Fatal("wait-reply command did not return") + } + + if waitResult.exit != 0 { + t.Fatalf("wait-reply failed with exit=%d\nstderr:\n%s\nstdout:\n%s", waitResult.exit, waitResult.stderr, waitResult.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, waitResult.stdout, &waitResp) + if woke, ok := nestedValue(t, waitResp, "data", "woke").(bool); !ok || !woke { + t.Fatalf("expected wait-reply to wake, got %#v", nestedValue(t, waitResp, "data", "woke")) + } + if kind := nestedString(t, waitResp, "data", "message", "kind"); kind != "answer" { + t.Fatalf("expected answer wake message, got %q", kind) + } + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "--json", + "fetch", + "--status", "blocked", + "--unread", + ) + if exitCode != 10 { + t.Fatalf("expected blocked unread list to be cleared after wait-reply, got exit %d with %s", exitCode, stdout) + } + + cancelOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--agent", "leader", + "--thread", threadID, + "--reason", "Task superseded by a larger refactor", + ) + + var cancelResp map[string]any + mustDecodeJSON(t, cancelOut, &cancelResp) + if status := nestedString(t, cancelResp, "data", "thread", "status"); status != "cancelled" { + t.Fatalf("expected cancelled thread, got %q", status) + } +} + +func TestInboxWatchListUnreadAndAppend(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "task.md") + + if err := os.WriteFile(bodyPath, []byte("Implement the initial admin post editor."), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + type commandResult struct { + stdout string + stderr string + exit int + } + + watchCh := make(chan commandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "watch", + "--agent", "worker-d", + "--status", "pending", + "--timeout-seconds", "2", + ) + watchCh <- commandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--subject", "Build admin editor", + "--summary", "Create the first editor screen", + "--body-file", bodyPath, + "--artifact", bodyPath, + "--artifact-kind", "brief", + "--artifact-metadata-json", `{"label":"task-brief"}`, + "--run", "run_blog_004", + "--task", "T4", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + var watchResult commandResult + select { + case watchResult = <-watchCh: + case <-time.After(3 * time.Second): + t.Fatal("watch command did not return") + } + + if watchResult.exit != 0 { + t.Fatalf("watch failed with exit=%d\nstderr:\n%s\nstdout:\n%s", watchResult.exit, watchResult.stderr, watchResult.stdout) + } + + var watchResp map[string]any + mustDecodeJSON(t, watchResult.stdout, &watchResp) + if woke, ok := nestedValue(t, watchResp, "data", "woke").(bool); !ok || !woke { + t.Fatalf("expected watch to wake, got %#v", nestedValue(t, watchResp, "data", "woke")) + } + if watchedThreadID := nestedString(t, watchResp, "data", "thread", "thread_id"); watchedThreadID != threadID { + t.Fatalf("expected watch on thread %s, got %s", threadID, watchedThreadID) + } + + fetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-d", + "--status", "pending", + "--unread", + ) + + var fetchResp map[string]any + mustDecodeJSON(t, fetchOut, &fetchResp) + fetchedThreads, ok := nestedValue(t, fetchResp, "data", "threads").([]any) + if !ok || len(fetchedThreads) != 1 { + t.Fatalf("expected one unread pending thread, got %#v", nestedValue(t, fetchResp, "data", "threads")) + } + + listOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "list", + "--assigned-to", "worker-d", + "--status", "pending", + ) + + var listResp map[string]any + mustDecodeJSON(t, listOut, &listResp) + listedThreads, ok := nestedValue(t, listResp, "data", "threads").([]any) + if !ok || len(listedThreads) != 1 { + t.Fatalf("expected one listed thread, got %#v", nestedValue(t, listResp, "data", "threads")) + } + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--thread", threadID, + "--summary", "Use a markdown editor", + "--body", "Prefer a textarea-based markdown editor for v1.", + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) != 2 { + t.Fatalf("expected two messages after append, got %#v", nestedValue(t, showResp, "data", "messages")) + } + firstMessage, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("expected first message object, got %#v", messages[0]) + } + if firstMessage["body"] != "Implement the initial admin post editor." { + t.Fatalf("expected body-file content in first message, got %#v", firstMessage["body"]) + } + artifacts, ok := firstMessage["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact on first message, got %#v", firstMessage["artifacts"]) + } + firstArtifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if firstArtifact["path"] != bodyPath { + t.Fatalf("expected artifact path %q, got %#v", bodyPath, firstArtifact["path"]) + } + if firstArtifact["kind"] != "brief" { + t.Fatalf("expected artifact kind brief, got %#v", firstArtifact["kind"]) + } +} + +func TestInboxUnreadReadCursor(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-e", + "--subject", "Review navbar copy", + "--summary", "Check top nav wording", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + fetchOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + + var fetchResp map[string]any + mustDecodeJSON(t, fetchOut, &fetchResp) + threads, ok := nestedValue(t, fetchResp, "data", "threads").([]any) + if !ok || len(threads) != 1 { + t.Fatalf("expected one unread pending thread, got %#v", nestedValue(t, fetchResp, "data", "threads")) + } + + runInboxCommand( + t, + "--db", dbPath, + "--agent", "worker-e", + "--json", + "show", + "--thread", threadID, + "--mark-read", + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + if exitCode != 10 { + t.Fatalf("expected unread fetch to clear after mark-read, got exit %d with %s", exitCode, stdout) + } + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-e", + "--thread", threadID, + "--summary", "Use sentence case", + "--body", "Keep the nav labels in sentence case.", + ) + + fetchOut = runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + + mustDecodeJSON(t, fetchOut, &fetchResp) + threads, ok = nestedValue(t, fetchResp, "data", "threads").([]any) + if !ok || len(threads) != 1 { + t.Fatalf("expected unread thread to reappear after new message, got %#v", nestedValue(t, fetchResp, "data", "threads")) + } +} + +func TestInboxJSONErrorsAndExitCodes(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + if _, _, exitCode := executeInboxCommand("--db", dbPath, "--json", "init"); exitCode != 0 { + t.Fatalf("expected init exit code 0, got %d", exitCode) + } + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-z", + "--status", "pending", + ) + if exitCode != 10 { + t.Fatalf("expected fetch no-match exit code 10, got %d", exitCode) + } + assertErrorJSON(t, stdout, "no_matching_work") + + stdout, _, exitCode = executeInboxCommand( + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-z", + "--thread", "thr_missing", + ) + if exitCode != 40 { + t.Fatalf("expected claim missing-thread exit code 40, got %d", exitCode) + } + assertErrorJSON(t, stdout, "not_found") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-z", + "--subject", "Review cache settings", + "--summary", "Check cache config", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-z", + "--thread", threadID, + ) + + stdout, _, exitCode = executeInboxCommand( + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-y", + "--thread", threadID, + ) + if exitCode != 20 { + t.Fatalf("expected lease conflict exit code 20, got %d", exitCode) + } + assertErrorJSON(t, stdout, "lease_conflict") + + stdout, _, exitCode = executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-z", + "--subject", "Invalid payload json", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected invalid input exit code 30, got %d", exitCode) + } + assertErrorJSON(t, stdout, "invalid_input") + + stdout, _, exitCode = executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-z", + "--subject", "Invalid artifact json", + "--artifact", "/tmp/report.md", + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected invalid artifact metadata exit code 30, got %d", exitCode) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/list.go b/packages/inbox-runtime/internal/cli/inbox/list.go new file mode 100644 index 0000000..4c99f28 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/list.go @@ -0,0 +1,83 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type listOptions struct { + agent string + statuses string + createdBy string + assignedTo string + limit int +} + +func newListCmd(root *rootOptions) *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List threads with filters", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + threads, err := s.ListThreads(ctx, store.ListInput{ + Agent: agent, + Statuses: parseCSV(opts.statuses), + CreatedBy: opts.createdBy, + AssignedTo: opts.assignedTo, + Limit: opts.limit, + }) + if err != nil { + return err + } + if len(threads) == 0 { + return protocol.NoMatchingWork("no matching work") + } + + resp := protocol.Success{ + OK: true, + Command: "list", + Data: map[string]any{ + "threads": threads, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + for _, thread := range threads { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\t%s\n", thread.ThreadID, thread.Status, thread.AssignedTo, thread.Subject); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Assigned agent filter shortcut") + cmd.Flags().StringVar(&opts.statuses, "status", "", "Comma-separated status filter") + cmd.Flags().StringVar(&opts.createdBy, "created-by", "", "Created-by filter") + cmd.Flags().StringVar(&opts.assignedTo, "assigned-to", "", "Assigned-to filter") + cmd.Flags().IntVar(&opts.limit, "limit", 20, "Maximum number of threads") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/list_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/list_integration_test.go new file mode 100644 index 0000000..cd6081e --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/list_integration_test.go @@ -0,0 +1,183 @@ +package inbox + +import ( + "path/filepath" + "testing" + "time" +) + +func TestListFiltersByStatus(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + createThreadForList(t, dbPath, "leader", "worker-d", "Task pending", "Pending task") + blockedThreadID := createThreadForList(t, dbPath, "leader", "worker-d", "Task blocked", "Blocked task") + runInboxCommand(t, "--db", dbPath, "--json", "claim", "--agent", "worker-d", "--thread", blockedThreadID) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-d", + "--thread", blockedThreadID, + "--status", "blocked", + "--summary", "Need policy decision", + ) + + listOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "list", + "--agent", "worker-d", + "--status", "pending,blocked", + ) + + var listResp map[string]any + mustDecodeJSON(t, listOut, &listResp) + threads, ok := nestedValue(t, listResp, "data", "threads").([]any) + if !ok || len(threads) < 2 { + t.Fatalf("expected at least two matching threads, got %#v", nestedValue(t, listResp, "data", "threads")) + } + for _, raw := range threads { + thread, ok := raw.(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", raw) + } + status, _ := thread["status"].(string) + if status != "pending" && status != "blocked" { + t.Fatalf("expected status pending or blocked, got %#v", status) + } + } +} + +func TestListFiltersByCreatedBy(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + createThreadForList(t, dbPath, "leader", "worker-d", "Leader task", "From leader") + createThreadForList(t, dbPath, "planner", "worker-d", "Planner task", "From planner") + + listOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "list", + "--created-by", "leader", + "--assigned-to", "worker-d", + ) + + var listResp map[string]any + mustDecodeJSON(t, listOut, &listResp) + threads, ok := nestedValue(t, listResp, "data", "threads").([]any) + if !ok || len(threads) == 0 { + t.Fatalf("expected matching leader-created threads, got %#v", nestedValue(t, listResp, "data", "threads")) + } + for _, raw := range threads { + thread, ok := raw.(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", raw) + } + if got := thread["created_by"]; got != "leader" { + t.Fatalf("expected created_by leader, got %#v", got) + } + } +} + +func TestListFiltersByAssignedTo(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + createThreadForList(t, dbPath, "leader", "worker-d", "Worker D task", "For worker-d") + createThreadForList(t, dbPath, "leader", "worker-e", "Worker E task", "For worker-e") + + listOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "list", + "--assigned-to", "worker-d", + "--status", "pending", + ) + + var listResp map[string]any + mustDecodeJSON(t, listOut, &listResp) + threads, ok := nestedValue(t, listResp, "data", "threads").([]any) + if !ok || len(threads) == 0 { + t.Fatalf("expected assigned-to match, got %#v", nestedValue(t, listResp, "data", "threads")) + } + for _, raw := range threads { + thread, ok := raw.(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", raw) + } + if got := thread["assigned_to"]; got != "worker-d" { + t.Fatalf("expected assigned_to worker-d, got %#v", got) + } + if got := thread["status"]; got != "pending" { + t.Fatalf("expected pending status, got %#v", got) + } + } +} + +func TestListRespectsLimit(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + createThreadForList(t, dbPath, "leader", "worker-d", "Task 1", "Earlier task") + time.Sleep(20 * time.Millisecond) + createThreadForList(t, dbPath, "leader", "worker-d", "Task 2", "Latest task") + + listOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "list", + "--assigned-to", "worker-d", + "--limit", "1", + ) + + var listResp map[string]any + mustDecodeJSON(t, listOut, &listResp) + threads, ok := nestedValue(t, listResp, "data", "threads").([]any) + if !ok { + t.Fatalf("expected threads array, got %#v", nestedValue(t, listResp, "data", "threads")) + } + if len(threads) != 1 { + t.Fatalf("expected exactly one row for limit=1, got %d", len(threads)) + } + thread, ok := threads[0].(map[string]any) + if !ok { + t.Fatalf("expected thread object, got %#v", threads[0]) + } + if got := thread["subject"]; got != "Task 2" { + t.Fatalf("expected latest thread subject Task 2, got %#v", got) + } +} + +func createThreadForList(t *testing.T, dbPath, from, to, subject, summary string) string { + t.Helper() + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", from, + "--to", to, + "--subject", subject, + "--summary", summary, + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + return nestedString(t, sendResp, "data", "thread", "thread_id") +} + diff --git a/packages/inbox-runtime/internal/cli/inbox/renew.go b/packages/inbox-runtime/internal/cli/inbox/renew.go new file mode 100644 index 0000000..735d1b0 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/renew.go @@ -0,0 +1,76 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type renewOptions struct { + agent string + threadID string + leaseSeconds int +} + +func newRenewCmd(root *rootOptions) *cobra.Command { + opts := &renewOptions{} + + cmd := &cobra.Command{ + Use: "renew", + Short: "Extend an existing lease", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + if agent == "" { + return protocol.InvalidInput("agent is required", nil) + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + result, err := s.RenewLease(ctx, store.RenewInput{ + ThreadID: opts.threadID, + Agent: agent, + LeaseSeconds: opts.leaseSeconds, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "renew", + Data: map[string]any{ + "thread": result.Thread, + "message": result.Message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "renewed lease on thread %s\n", result.Thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Lease owner") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().IntVar(&opts.leaseSeconds, "lease-seconds", 900, "Lease duration in seconds") + + _ = cmd.MarkFlagRequired("thread") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/renew_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/renew_integration_test.go new file mode 100644 index 0000000..05ed1ee --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/renew_integration_test.go @@ -0,0 +1,103 @@ +package inbox + +import "testing" + +func TestRenewExtendsActiveLease(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-c", "Renew lease", "Need renew coverage") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-c", + "--thread", threadID, + "--lease-seconds", "300", + ) + + renewOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-c", + "--thread", threadID, + "--lease-seconds", "600", + ) + + var renewResp map[string]any + mustDecodeJSON(t, renewOut, &renewResp) + if got := nestedString(t, renewResp, "data", "thread", "status"); got != "claimed" { + t.Fatalf("expected status to stay claimed, got %q", got) + } + if got := nestedString(t, renewResp, "data", "message", "kind"); got != "event" { + t.Fatalf("expected event message kind, got %q", got) + } + if got := nestedString(t, renewResp, "data", "message", "summary"); got != "lease renewed" { + t.Fatalf("expected lease renewed summary, got %q", got) + } + payload, ok := nestedValue(t, renewResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, renewResp, "data", "message", "payload_json")) + } + leaseSeconds, ok := payload["lease_seconds"].(float64) + if !ok || int(leaseSeconds) != 600 { + t.Fatalf("expected lease_seconds 600, got %#v", payload["lease_seconds"]) + } + leaseToken, _ := payload["lease_token"].(string) + if leaseToken == "" { + t.Fatalf("expected non-empty lease_token, got %#v", payload["lease_token"]) + } +} + +func TestRenewRejectsNonOwner(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-c", "Renew non-owner", "Reject non-owner renew") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-c", + "--thread", threadID, + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-x", + "--thread", threadID, + "--lease-seconds", "600", + ) + if exitCode != 20 { + t.Fatalf("expected exit code 20, got %d", exitCode) + } + assertErrorJSON(t, stdout, "lease_conflict") +} + +func TestRenewRejectsWithoutActiveLease(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-c", "Renew without lease", "Should fail without active lease") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "renew", + "--agent", "worker-c", + "--thread", threadID, + "--lease-seconds", "600", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d", exitCode) + } + assertErrorJSON(t, stdout, "invalid_state") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/reply.go b/packages/inbox-runtime/internal/cli/inbox/reply.go new file mode 100644 index 0000000..db1387a --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/reply.go @@ -0,0 +1,104 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type replyOptions struct { + from string + to string + threadID string + kind string + summary string + body string + bodyFile string + payloadJSON string + artifacts artifactOptions +} + +func newReplyCmd(root *rootOptions) *cobra.Command { + opts := &replyOptions{} + + cmd := &cobra.Command{ + Use: "reply", + Short: "Reply inside an existing thread", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + from := opts.from + if from == "" { + from = root.agent + } + if from == "" { + return protocol.InvalidInput("from agent is required", nil) + } + + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + artifacts, err := resolveArtifacts(opts.artifacts) + if err != nil { + return err + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + thread, message, err := s.ReplyToThread(ctx, store.ReplyInput{ + ThreadID: opts.threadID, + FromAgent: from, + ToAgent: opts.to, + Kind: opts.kind, + Summary: opts.summary, + Body: body, + PayloadJSON: opts.payloadJSON, + Artifacts: artifacts, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "reply", + Data: map[string]any{ + "thread": thread, + "message": message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "replied on thread %s\n", thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.from, "from", "", "Replying agent") + cmd.Flags().StringVar(&opts.to, "to", "", "Receiving agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().StringVar(&opts.kind, "kind", "answer", "Reply kind") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Short reply summary") + cmd.Flags().StringVar(&opts.body, "body", "", "Reply body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read reply body from file") + cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string") + addArtifactFlags(cmd, &opts.artifacts) + + _ = cmd.MarkFlagRequired("thread") + _ = cmd.MarkFlagRequired("to") + _ = cmd.MarkFlagRequired("summary") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/reply_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/reply_integration_test.go new file mode 100644 index 0000000..3f68a62 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/reply_integration_test.go @@ -0,0 +1,138 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReplyAddsAnswerMessage(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry read timeouts", + "--body", "Yes, include read timeouts in the retry policy.", + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + if kind := nestedString(t, replyResp, "data", "message", "kind"); kind != "answer" { + t.Fatalf("expected answer message kind, got %q", kind) + } + if gotThreadID := nestedString(t, replyResp, "data", "thread", "thread_id"); gotThreadID != threadID { + t.Fatalf("expected thread_id %q, got %q", threadID, gotThreadID) + } + if status := nestedString(t, replyResp, "data", "thread", "status"); status != "pending" { + t.Fatalf("expected thread status pending, got %q", status) + } +} + +func TestReplySupportsControlKind(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--kind", "control", + "--summary", "Pause rollout", + "--body", "Pause rollout until QA confirms the fix.", + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + if kind := nestedString(t, replyResp, "data", "message", "kind"); kind != "control" { + t.Fatalf("expected control message kind, got %q", kind) + } +} + +func TestReplyAttachesArtifact(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + decisionPath := filepath.Join(tempDir, "decision.md") + if err := os.WriteFile(decisionPath, []byte("Decision note."), 0o644); err != nil { + t.Fatalf("write decision file: %v", err) + } + + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + replyOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry read timeouts", + "--artifact", decisionPath, + "--artifact-kind", "brief", + "--artifact-metadata-json", `{"label":"decision"}`, + ) + + var replyResp map[string]any + mustDecodeJSON(t, replyOut, &replyResp) + artifactsValue := nestedValue(t, replyResp, "data", "message", "artifacts") + artifacts, ok := artifactsValue.([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", artifactsValue) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if gotPath, _ := artifact["path"].(string); gotPath != decisionPath { + t.Fatalf("expected artifact path %q, got %#v", decisionPath, artifact["path"]) + } + if gotKind, _ := artifact["kind"].(string); gotKind != "brief" { + t.Fatalf("expected artifact kind brief, got %#v", artifact["kind"]) + } + metadata, ok := artifact["metadata_json"].(map[string]any) + if !ok { + t.Fatalf("expected metadata_json object, got %#v", artifact["metadata_json"]) + } + if gotLabel := metadata["label"]; gotLabel != "decision" { + t.Fatalf("expected metadata label decision, got %#v", gotLabel) + } +} + +func TestReplyRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedThreadForInboxTests(t, dbPath, "leader", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Retry read timeouts", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/root.go b/packages/inbox-runtime/internal/cli/inbox/root.go new file mode 100644 index 0000000..56045e6 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/root.go @@ -0,0 +1,43 @@ +package inbox + +import ( + "github.com/spf13/cobra" +) + +type rootOptions struct { + dbPath string + json bool + agent string +} + +func NewRootCmd() *cobra.Command { + opts := &rootOptions{} + + cmd := &cobra.Command{ + Use: "inbox", + Short: "Worker-facing durable coordination bus", + SilenceErrors: true, + SilenceUsage: true, + } + + cmd.PersistentFlags().StringVar(&opts.dbPath, "db", ".agents/coord.db", "SQLite database path") + cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Emit machine-readable JSON") + cmd.PersistentFlags().StringVar(&opts.agent, "agent", "", "Agent identity") + + cmd.AddCommand(newInitCmd(opts)) + cmd.AddCommand(newSendCmd(opts)) + cmd.AddCommand(newFetchCmd(opts)) + cmd.AddCommand(newClaimCmd(opts)) + cmd.AddCommand(newRenewCmd(opts)) + cmd.AddCommand(newUpdateCmd(opts)) + cmd.AddCommand(newReplyCmd(opts)) + cmd.AddCommand(newDoneCmd(opts)) + cmd.AddCommand(newFailCmd(opts)) + cmd.AddCommand(newCancelCmd(opts)) + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newWatchCmd(opts)) + cmd.AddCommand(newWaitReplyCmd(opts)) + cmd.AddCommand(newShowCmd(opts)) + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/send.go b/packages/inbox-runtime/internal/cli/inbox/send.go new file mode 100644 index 0000000..cf46a03 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/send.go @@ -0,0 +1,117 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type sendOptions struct { + from string + to string + threadID string + runID string + taskID string + subject string + kind string + summary string + body string + bodyFile string + payloadJSON string + priority string + artifacts artifactOptions +} + +func newSendCmd(root *rootOptions) *cobra.Command { + opts := &sendOptions{} + + cmd := &cobra.Command{ + Use: "send", + Short: "Create a thread with an initial directed message", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + from := opts.from + if from == "" { + from = root.agent + } + if from == "" { + return protocol.InvalidInput("from agent is required", nil) + } + if opts.threadID == "" && opts.subject == "" { + return protocol.InvalidInput("subject is required when creating a new thread", nil) + } + + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + artifacts, err := resolveArtifacts(opts.artifacts) + if err != nil { + return err + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + thread, message, err := s.Send(ctx, store.SendInput{ + ThreadID: opts.threadID, + RunID: opts.runID, + TaskID: opts.taskID, + Subject: opts.subject, + FromAgent: from, + ToAgent: opts.to, + Kind: opts.kind, + Summary: opts.summary, + Body: body, + PayloadJSON: opts.payloadJSON, + Priority: opts.priority, + Artifacts: artifacts, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "send", + Data: map[string]any{ + "thread": thread, + "message": message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "created thread %s\n", thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.from, "from", "", "Sending agent") + cmd.Flags().StringVar(&opts.to, "to", "", "Receiving agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Optional thread ID override") + cmd.Flags().StringVar(&opts.runID, "run", "", "Optional run ID override") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID override") + cmd.Flags().StringVar(&opts.subject, "subject", "", "Thread subject") + cmd.Flags().StringVar(&opts.kind, "kind", "task", "Initial message kind") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Short message summary") + cmd.Flags().StringVar(&opts.body, "body", "", "Message body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read message body from file") + cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string") + cmd.Flags().StringVar(&opts.priority, "priority", "normal", "Thread priority") + addArtifactFlags(cmd, &opts.artifacts) + + _ = cmd.MarkFlagRequired("to") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/send_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/send_integration_test.go new file mode 100644 index 0000000..ef167e4 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/send_integration_test.go @@ -0,0 +1,230 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSendCreatesNewThread(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Implement feature X", + "--summary", "Add retry policy", + "--body", "Implement retry handling for the HTTP client.", + "--run", "run_blog_001", + "--task", "T1", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + + if got := nestedString(t, sendResp, "data", "thread", "thread_id"); got == "" { + t.Fatalf("expected thread_id, got empty") + } + if got := nestedString(t, sendResp, "data", "thread", "status"); got != "pending" { + t.Fatalf("expected pending status, got %q", got) + } + if got := nestedString(t, sendResp, "data", "thread", "created_by"); got != "leader" { + t.Fatalf("expected created_by leader, got %q", got) + } + if got := nestedString(t, sendResp, "data", "thread", "assigned_to"); got != "worker-a" { + t.Fatalf("expected assigned_to worker-a, got %q", got) + } + if got := nestedString(t, sendResp, "data", "message", "kind"); got != "task" { + t.Fatalf("expected message kind task, got %q", got) + } +} + +func TestSendAppendsMessageToExistingThread(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + threadID := sendPendingThread(t, dbPath, "leader", "worker-d", "Build editor", "Create editor v1") + + appendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--thread", threadID, + "--summary", "Use a markdown editor", + "--body", "Prefer a textarea-based markdown editor for v1.", + ) + + var appendResp map[string]any + mustDecodeJSON(t, appendOut, &appendResp) + if got := nestedString(t, appendResp, "data", "thread", "thread_id"); got != threadID { + t.Fatalf("expected same thread_id %q, got %q", threadID, got) + } + if got := nestedString(t, appendResp, "data", "thread", "status"); got != "pending" { + t.Fatalf("expected thread status to stay pending, got %q", got) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) != 2 { + t.Fatalf("expected two messages after append, got %#v", nestedValue(t, showResp, "data", "messages")) + } +} + +func TestSendReadsBodyFromBodyFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + bodyPath := filepath.Join(tempDir, "task.md") + bodyContent := "Create the first editor screen.\nUse markdown syntax." + if err := os.WriteFile(bodyPath, []byte(bodyContent), 0o644); err != nil { + t.Fatalf("write body file: %v", err) + } + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--subject", "Build admin editor", + "--summary", "Create the first editor screen", + "--body-file", bodyPath, + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) != 1 { + t.Fatalf("expected one message, got %#v", nestedValue(t, showResp, "data", "messages")) + } + message, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", messages[0]) + } + if got, _ := message["body"].(string); got != bodyContent { + t.Fatalf("expected body %q, got %#v", bodyContent, message["body"]) + } +} + +func TestSendAttachesArtifactWithMetadata(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + artifactPath := filepath.Join(tempDir, "task.md") + if err := os.WriteFile(artifactPath, []byte("task brief"), 0o644); err != nil { + t.Fatalf("write artifact file: %v", err) + } + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--subject", "Build admin editor", + "--summary", "Create the first editor screen", + "--artifact", artifactPath, + "--artifact-kind", "brief", + "--artifact-metadata-json", `{"label":"task-brief"}`, + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + + artifacts, ok := nestedValue(t, sendResp, "data", "message", "artifacts").([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", nestedValue(t, sendResp, "data", "message", "artifacts")) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if got, _ := artifact["path"].(string); got != artifactPath { + t.Fatalf("expected artifact path %q, got %#v", artifactPath, artifact["path"]) + } + if got, _ := artifact["kind"].(string); got != "brief" { + t.Fatalf("expected artifact kind brief, got %#v", artifact["kind"]) + } + metadata, ok := artifact["metadata_json"].(map[string]any) + if !ok { + t.Fatalf("expected metadata_json object, got %#v", artifact["metadata_json"]) + } + if got, _ := metadata["label"].(string); got != "task-brief" { + t.Fatalf("expected metadata_json.label task-brief, got %#v", metadata["label"]) + } +} + +func TestSendRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-z", + "--subject", "Invalid payload json", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d", exitCode) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +func TestSendRejectsInvalidArtifactMetadataJSON(t *testing.T) { + t.Parallel() + + dbPath := initCommandTestDB(t) + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-z", + "--subject", "Invalid artifact json", + "--artifact", "/tmp/report.md", + "--artifact-metadata-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d", exitCode) + } + assertErrorJSON(t, stdout, "invalid_input") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/show.go b/packages/inbox-runtime/internal/cli/inbox/show.go new file mode 100644 index 0000000..f08dba0 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/show.go @@ -0,0 +1,78 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type showOptions struct { + threadID string + markRead bool +} + +func newShowCmd(root *rootOptions) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show one thread with message history", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + agent := root.agent + if opts.markRead && agent == "" { + return protocol.InvalidInput("agent is required when using --mark-read", nil) + } + + detail, err := s.GetThreadForAgent(ctx, opts.threadID, agent, opts.markRead) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "show", + Data: map[string]any{ + "thread": detail.Thread, + "messages": detail.Messages, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", detail.Thread.ThreadID, detail.Thread.Status, detail.Thread.Subject); err != nil { + return err + } + for _, message := range detail.Messages { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s\t%s\t%s\n", message.MessageID, message.Kind, message.Summary); err != nil { + return err + } + for _, artifact := range message.Artifacts { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), " artifact\t%s\t%s\n", artifact.Kind, artifact.Path); err != nil { + return err + } + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().BoolVar(&opts.markRead, "mark-read", false, "Advance the caller's read cursor to the latest message") + _ = cmd.MarkFlagRequired("thread") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/show_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/show_integration_test.go new file mode 100644 index 0000000..f22868c --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/show_integration_test.go @@ -0,0 +1,196 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func TestShowReturnsThreadAndMessageHistory(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Implement feature X", + "--summary", "Initial request", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--thread", threadID, + "--summary", "Follow-up request", + "--body", "Please include request logging.", + ) + + showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "thread", "thread_id"); got != threadID { + t.Fatalf("expected thread %q, got %q", threadID, got) + } + + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) != 2 { + t.Fatalf("expected two ordered messages, got %#v", nestedValue(t, showResp, "data", "messages")) + } + first, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("expected first message object, got %#v", messages[0]) + } + second, ok := messages[1].(map[string]any) + if !ok { + t.Fatalf("expected second message object, got %#v", messages[1]) + } + if got := first["summary"]; got != "Initial request" { + t.Fatalf("expected first summary Initial request, got %#v", got) + } + if got := second["summary"]; got != "Follow-up request" { + t.Fatalf("expected second summary Follow-up request, got %#v", got) + } +} + +func TestShowIncludesArtifactsPerMessage(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + artifactPath := filepath.Join(tempDir, "task.md") + if err := os.WriteFile(artifactPath, []byte("task brief"), 0o644); err != nil { + t.Fatalf("write artifact file: %v", err) + } + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-a", + "--subject", "Artifact task", + "--summary", "Attach brief", + "--artifact", artifactPath, + "--artifact-kind", "brief", + "--artifact-metadata-json", `{"label":"task-brief"}`, + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + showOut := runInboxCommand(t, "--db", dbPath, "--json", "show", "--thread", threadID) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + + messages, ok := nestedValue(t, showResp, "data", "messages").([]any) + if !ok || len(messages) == 0 { + t.Fatalf("expected messages with artifacts, got %#v", nestedValue(t, showResp, "data", "messages")) + } + first, ok := messages[0].(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", messages[0]) + } + artifacts, ok := first["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", first["artifacts"]) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if got := artifact["path"]; got != artifactPath { + t.Fatalf("expected artifact path %q, got %#v", artifactPath, got) + } + if got := artifact["kind"]; got != "brief" { + t.Fatalf("expected artifact kind brief, got %#v", got) + } +} + +func TestShowMarkReadAdvancesReadCursor(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-e", + "--subject", "Review nav copy", + "--summary", "Check wording", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + + runInboxCommand( + t, + "--db", dbPath, + "--agent", "worker-e", + "--json", + "show", + "--thread", threadID, + "--mark-read", + ) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "fetch", + "--agent", "worker-e", + "--status", "pending", + "--unread", + ) + if exitCode != 10 { + t.Fatalf("expected unread fetch to be empty after mark-read, got exit=%d with %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") +} + +func TestShowRejectsWhenThreadMissing(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "show", + "--thread", "thr_missing", + ) + if exitCode != 40 { + t.Fatalf("expected not-found exit code 40, got %d with %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + diff --git a/packages/inbox-runtime/internal/cli/inbox/test_helpers_test.go b/packages/inbox-runtime/internal/cli/inbox/test_helpers_test.go new file mode 100644 index 0000000..518882c --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/test_helpers_test.go @@ -0,0 +1,78 @@ +package inbox + +import ( + "bytes" + "encoding/json" + "testing" +) + +func runInboxCommand(t *testing.T, args ...string) string { + t.Helper() + + stdout, stderr, exitCode := executeInboxCommand(args...) + if exitCode != 0 { + t.Fatalf("execute inbox command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout) + } + + return stdout +} + +func executeInboxCommand(args ...string) (string, string, int) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := Execute(args, &stdout, &stderr) + return stdout.String(), stderr.String(), exitCode +} + +func mustDecodeJSON(t *testing.T, raw string, target any) { + t.Helper() + + if err := json.Unmarshal([]byte(raw), target); err != nil { + t.Fatalf("decode json %q: %v", raw, err) + } +} + +func nestedString(t *testing.T, value map[string]any, keys ...string) string { + t.Helper() + + current := nestedValue(t, value, keys...) + str, ok := current.(string) + if !ok { + t.Fatalf("expected string at %v, got %#v", keys, current) + } + return str +} + +func nestedValue(t *testing.T, value map[string]any, keys ...string) any { + t.Helper() + + var current any = value + for _, key := range keys { + obj, ok := current.(map[string]any) + if !ok { + t.Fatalf("expected object at %q in %v, got %#v", key, keys, current) + } + current, ok = obj[key] + if !ok { + t.Fatalf("missing key %q in %v", key, keys) + } + } + return current +} + +func assertErrorJSON(t *testing.T, raw string, expectedCode string) { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + if ok, _ := payload["ok"].(bool); ok { + t.Fatalf("expected ok=false error payload, got %#v", payload) + } + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + if code, _ := errorValue["code"].(string); code != expectedCode { + t.Fatalf("expected error code %q, got %#v", expectedCode, errorValue["code"]) + } +} diff --git a/packages/inbox-runtime/internal/cli/inbox/update.go b/packages/inbox-runtime/internal/cli/inbox/update.go new file mode 100644 index 0000000..4cdfe17 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/update.go @@ -0,0 +1,101 @@ +package inbox + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type updateOptions struct { + agent string + threadID string + status string + summary string + body string + bodyFile string + payloadJSON string + artifacts artifactOptions +} + +func newUpdateCmd(root *rootOptions) *cobra.Command { + opts := &updateOptions{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Append a progress or blocked update to a thread", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + if agent == "" { + return protocol.InvalidInput("agent is required", nil) + } + + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + artifacts, err := resolveArtifacts(opts.artifacts) + if err != nil { + return err + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + thread, message, err := s.UpdateThreadStatus(ctx, store.UpdateInput{ + ThreadID: opts.threadID, + Agent: agent, + Status: opts.status, + Summary: opts.summary, + Body: body, + PayloadJSON: opts.payloadJSON, + Artifacts: artifacts, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "update", + Data: map[string]any{ + "thread": thread, + "message": message, + }, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "updated thread %s to %s\n", thread.ThreadID, thread.Status) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Updating agent") + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().StringVar(&opts.status, "status", "", "New status: in_progress or blocked") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Short update summary") + cmd.Flags().StringVar(&opts.body, "body", "", "Update body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read update body from file") + cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string") + addArtifactFlags(cmd, &opts.artifacts) + + _ = cmd.MarkFlagRequired("thread") + _ = cmd.MarkFlagRequired("status") + _ = cmd.MarkFlagRequired("summary") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/update_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/update_integration_test.go new file mode 100644 index 0000000..b0185b8 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/update_integration_test.go @@ -0,0 +1,226 @@ +package inbox + +import ( + "os" + "path/filepath" + "testing" +) + +func seedThreadForInboxTests(t *testing.T, dbPath, from, to string) string { + t.Helper() + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", from, + "--to", to, + "--subject", "Implement feature X", + "--summary", "Add retry policy", + ) + + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + return nestedString(t, sendResp, "data", "thread", "thread_id") +} + +func seedClaimedThreadForInboxTests(t *testing.T, dbPath, from, to, claimer string) string { + t.Helper() + + threadID := seedThreadForInboxTests(t, dbPath, from, to) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", claimer, + "--thread", threadID, + "--lease-seconds", "300", + ) + return threadID +} + +func lastThreadMessageFromShow(t *testing.T, showResp map[string]any) map[string]any { + t.Helper() + + messagesValue := nestedValue(t, showResp, "data", "messages") + messages, ok := messagesValue.([]any) + if !ok || len(messages) == 0 { + t.Fatalf("expected non-empty messages, got %#v", messagesValue) + } + + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", messages[len(messages)-1]) + } + return lastMessage +} + +func TestUpdateMovesThreadToInProgress(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + updateOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + "--body", "Scanning current HTTP client usage.", + ) + + var updateResp map[string]any + mustDecodeJSON(t, updateOut, &updateResp) + if status := nestedString(t, updateResp, "data", "thread", "status"); status != "in_progress" { + t.Fatalf("expected in_progress thread status, got %q", status) + } + if kind := nestedString(t, updateResp, "data", "message", "kind"); kind != "progress" { + t.Fatalf("expected progress message kind, got %q", kind) + } + if toAgent := nestedString(t, updateResp, "data", "message", "to_agent"); toAgent != "leader" { + t.Fatalf("expected message to_agent leader, got %q", toAgent) + } +} + +func TestUpdateMovesThreadToBlockedWithPayload(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + updateOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need timeout decision", + "--payload-json", `{"question":"Should retries apply to read timeouts?"}`, + ) + + var updateResp map[string]any + mustDecodeJSON(t, updateOut, &updateResp) + if status := nestedString(t, updateResp, "data", "thread", "status"); status != "blocked" { + t.Fatalf("expected blocked thread status, got %q", status) + } + if kind := nestedString(t, updateResp, "data", "message", "kind"); kind != "question" { + t.Fatalf("expected question message kind, got %q", kind) + } + payload, ok := nestedValue(t, updateResp, "data", "message", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", nestedValue(t, updateResp, "data", "message", "payload_json")) + } + if got := payload["question"]; got != "Should retries apply to read timeouts?" { + t.Fatalf("expected payload question, got %#v", got) + } +} + +func TestUpdateAcceptsBodyFileAndArtifact(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "coord.db") + progressPath := filepath.Join(tempDir, "progress.md") + body := "Progress update from file." + if err := os.WriteFile(progressPath, []byte(body), 0o644); err != nil { + t.Fatalf("write progress file: %v", err) + } + + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + "--body-file", progressPath, + "--artifact", progressPath, + "--artifact-kind", "note", + ) + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + lastMessage := lastThreadMessageFromShow(t, showResp) + + if gotBody, _ := lastMessage["body"].(string); gotBody != body { + t.Fatalf("expected body %q, got %#v", body, lastMessage["body"]) + } + artifacts, ok := lastMessage["artifacts"].([]any) + if !ok || len(artifacts) != 1 { + t.Fatalf("expected one artifact, got %#v", lastMessage["artifacts"]) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected artifact object, got %#v", artifacts[0]) + } + if gotPath, _ := artifact["path"].(string); gotPath != progressPath { + t.Fatalf("expected artifact path %q, got %#v", progressPath, artifact["path"]) + } + if gotKind, _ := artifact["kind"].(string); gotKind != "note" { + t.Fatalf("expected artifact kind note, got %#v", artifact["kind"]) + } +} + +func TestUpdateRejectsInvalidPayloadJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need timeout decision", + "--payload-json", "not-json", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +func TestUpdateRejectsNonOwner(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedClaimedThreadForInboxTests(t, dbPath, "leader", "worker-a", "worker-a") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "update", + "--agent", "worker-b", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + ) + if exitCode != 20 { + t.Fatalf("expected exit code 20, got %d with output %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "lease_conflict") +} diff --git a/packages/inbox-runtime/internal/cli/inbox/wait_reply.go b/packages/inbox-runtime/internal/cli/inbox/wait_reply.go new file mode 100644 index 0000000..63c3b1e --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/wait_reply.go @@ -0,0 +1,85 @@ +package inbox + +import ( + "fmt" + "time" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type waitReplyOptions struct { + threadID string + afterMessageID string + afterEventID int64 + kinds string + timeoutSeconds int +} + +func newWaitReplyCmd(root *rootOptions) *cobra.Command { + opts := &waitReplyOptions{} + + cmd := &cobra.Command{ + Use: "wait-reply", + Short: "Block until a reply-like message appears in a thread", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + agent := root.agent + result, err := s.WaitReply(ctx, store.WaitReplyInput{ + ThreadID: opts.threadID, + AfterMessageID: opts.afterMessageID, + AfterEventID: opts.afterEventID, + Kinds: parseCSV(opts.kinds), + Agent: agent, + Timeout: time.Duration(opts.timeoutSeconds) * time.Second, + }) + if err != nil { + return err + } + if !result.Woke { + return protocol.NoMatchingWork("no matching reply before timeout") + } + + data := map[string]any{ + "woke": result.Woke, + "next_event_id": result.NextEventID, + } + if result.Message != nil { + data["message"] = result.Message + } + + resp := protocol.Success{ + OK: true, + Command: "wait-reply", + Data: data, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "reply received on thread %s at event %d\n", result.Message.ThreadID, result.NextEventID) + return err + }, + } + + cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID") + cmd.Flags().StringVar(&opts.afterMessageID, "after-message", "", "Resume after a known message ID") + cmd.Flags().Int64Var(&opts.afterEventID, "after-event", 0, "Resume after a known event ID") + cmd.Flags().StringVar(&opts.kinds, "kinds", "answer,control,result", "Comma-separated message kinds to wake on") + cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait; 0 waits forever") + + _ = cmd.MarkFlagRequired("thread") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/wait_reply_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/wait_reply_integration_test.go new file mode 100644 index 0000000..a2b31a1 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/wait_reply_integration_test.go @@ -0,0 +1,221 @@ +package inbox + +import ( + "path/filepath" + "strconv" + "testing" + "time" +) + +type waitReplyCommandResult struct { + stdout string + stderr string + exit int +} + +func TestWaitReplyWakesOnAnswerAfterMessage(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID, blockedMessageID := seedBlockedThreadForWaitReply(t, dbPath) + + waitCh := make(chan waitReplyCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "--json", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--timeout-seconds", "2", + ) + waitCh <- waitReplyCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--summary", "Redirect to login", + "--body", "Redirect guests to login for the MVP.", + ) + + var waitResult waitReplyCommandResult + select { + case waitResult = <-waitCh: + case <-time.After(3 * time.Second): + t.Fatal("wait-reply command did not return") + } + if waitResult.exit != 0 { + t.Fatalf("wait-reply failed with exit=%d\nstderr:\n%s\nstdout:\n%s", waitResult.exit, waitResult.stderr, waitResult.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, waitResult.stdout, &waitResp) + if woke, ok := nestedValue(t, waitResp, "data", "woke").(bool); !ok || !woke { + t.Fatalf("expected wait-reply wake, got %#v", nestedValue(t, waitResp, "data", "woke")) + } + if kind := nestedString(t, waitResp, "data", "message", "kind"); kind != "answer" { + t.Fatalf("expected answer wake message, got %q", kind) + } +} + +func TestWaitReplyCanStartFromAfterEvent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID, blockedMessageID := seedBlockedThreadForWaitReply(t, dbPath) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--summary", "First answer", + "--body", "First reply payload.", + ) + + firstWaitOut := runInboxCommand( + t, + "--db", dbPath, + "--agent", "worker-c", + "--json", + "wait-reply", + "--thread", threadID, + "--after-message", blockedMessageID, + "--timeout-seconds", "2", + ) + var firstWaitResp map[string]any + mustDecodeJSON(t, firstWaitOut, &firstWaitResp) + firstEventIDFloat, ok := nestedValue(t, firstWaitResp, "data", "next_event_id").(float64) + if !ok { + t.Fatalf("expected numeric next_event_id, got %#v", nestedValue(t, firstWaitResp, "data", "next_event_id")) + } + firstEventID := int64(firstEventIDFloat) + + waitCh := make(chan waitReplyCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "--json", + "wait-reply", + "--thread", threadID, + "--after-event", strconv.FormatInt(firstEventID, 10), + "--timeout-seconds", "2", + ) + waitCh <- waitReplyCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "reply", + "--from", "leader", + "--to", "worker-c", + "--thread", threadID, + "--summary", "Second answer", + "--body", "Second reply payload.", + ) + + var waitResult waitReplyCommandResult + select { + case waitResult = <-waitCh: + case <-time.After(3 * time.Second): + t.Fatal("wait-reply after-event command did not return") + } + if waitResult.exit != 0 { + t.Fatalf("wait-reply after-event failed with exit=%d\nstderr:\n%s\nstdout:\n%s", waitResult.exit, waitResult.stderr, waitResult.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, waitResult.stdout, &waitResp) + if got := nestedString(t, waitResp, "data", "message", "summary"); got != "Second answer" { + t.Fatalf("expected second answer wake message, got %q", got) + } + secondEventIDFloat, ok := nestedValue(t, waitResp, "data", "next_event_id").(float64) + if !ok { + t.Fatalf("expected numeric next_event_id, got %#v", nestedValue(t, waitResp, "data", "next_event_id")) + } + if int64(secondEventIDFloat) <= firstEventID { + t.Fatalf("expected second event id > first event id, got %d <= %d", int64(secondEventIDFloat), firstEventID) + } +} + +func TestWaitReplyTimesOutWhenNoReply(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID, _ := seedBlockedThreadForWaitReply(t, dbPath) + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--agent", "worker-c", + "--json", + "wait-reply", + "--thread", threadID, + "--timeout-seconds", "1", + ) + if exitCode != 10 { + t.Fatalf("expected wait-reply timeout exit code 10, got %d with %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") +} + +func seedBlockedThreadForWaitReply(t *testing.T, dbPath string) (threadID string, blockedMessageID string) { + t.Helper() + + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-c", + "--subject", "Investigate auth edge case", + "--summary", "Check auth redirect behavior", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID = nestedString(t, sendResp, "data", "thread", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-c", + "--thread", threadID, + ) + + blockedOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-c", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need policy decision", + ) + var blockedResp map[string]any + mustDecodeJSON(t, blockedOut, &blockedResp) + blockedMessageID = nestedString(t, blockedResp, "data", "message", "message_id") + return threadID, blockedMessageID +} + diff --git a/packages/inbox-runtime/internal/cli/inbox/watch.go b/packages/inbox-runtime/internal/cli/inbox/watch.go new file mode 100644 index 0000000..2a128f8 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/watch.go @@ -0,0 +1,90 @@ +package inbox + +import ( + "fmt" + "time" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type watchOptions struct { + agent string + statuses string + timeoutSeconds int + afterEventID int64 +} + +func newWatchCmd(root *rootOptions) *cobra.Command { + opts := &watchOptions{} + + cmd := &cobra.Command{ + Use: "watch", + Short: "Block until new matching activity appears", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + agent := opts.agent + if agent == "" { + agent = root.agent + } + + sqlDB, err := openInboxDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewInboxStore(sqlDB) + result, err := s.WatchThreads(ctx, store.WatchInput{ + Agent: agent, + Statuses: parseCSV(opts.statuses), + AfterEventID: opts.afterEventID, + StartFromNow: !cmd.Flags().Changed("after-event"), + Timeout: time.Duration(opts.timeoutSeconds) * time.Second, + }) + if err != nil { + return err + } + if !result.Woke { + return protocol.NoMatchingWork("no matching work before watch timeout") + } + + data := map[string]any{ + "woke": result.Woke, + "next_event_id": result.NextEventID, + } + if result.Thread != nil { + data["thread"] = result.Thread + } + if result.Message != nil { + data["message"] = result.Message + } + if result.Event != nil { + data["event"] = result.Event + } + + resp := protocol.Success{ + OK: true, + Command: "watch", + Data: data, + } + + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "watch woke on thread %s at event %d\n", result.Thread.ThreadID, result.NextEventID) + return err + }, + } + + cmd.Flags().StringVar(&opts.agent, "agent", "", "Assigned agent filter") + cmd.Flags().StringVar(&opts.statuses, "status", "pending,blocked,done,failed", "Comma-separated status filter") + cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait; 0 waits forever") + cmd.Flags().Int64Var(&opts.afterEventID, "after-event", 0, "Resume after a known event ID") + + return cmd +} diff --git a/packages/inbox-runtime/internal/cli/inbox/watch_integration_test.go b/packages/inbox-runtime/internal/cli/inbox/watch_integration_test.go new file mode 100644 index 0000000..8d87eb5 --- /dev/null +++ b/packages/inbox-runtime/internal/cli/inbox/watch_integration_test.go @@ -0,0 +1,171 @@ +package inbox + +import ( + "path/filepath" + "testing" + "time" +) + +type watchCommandResult struct { + stdout string + stderr string + exit int +} + +func TestWatchWakesOnMatchingThread(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + watchCh := make(chan watchCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "watch", + "--agent", "worker-d", + "--status", "pending", + "--timeout-seconds", "2", + ) + watchCh <- watchCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-d", + "--subject", "Build admin editor", + "--summary", "Create the first editor screen", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + var watchResult watchCommandResult + select { + case watchResult = <-watchCh: + case <-time.After(3 * time.Second): + t.Fatal("watch command did not return") + } + if watchResult.exit != 0 { + t.Fatalf("watch failed with exit=%d\nstderr:\n%s\nstdout:\n%s", watchResult.exit, watchResult.stderr, watchResult.stdout) + } + + var watchResp map[string]any + mustDecodeJSON(t, watchResult.stdout, &watchResp) + if woke, ok := nestedValue(t, watchResp, "data", "woke").(bool); !ok || !woke { + t.Fatalf("expected watch to wake, got %#v", nestedValue(t, watchResp, "data", "woke")) + } + if got := nestedString(t, watchResp, "data", "thread", "thread_id"); got != threadID { + t.Fatalf("expected woken thread %q, got %q", threadID, got) + } + nextEventID, ok := nestedValue(t, watchResp, "data", "next_event_id").(float64) + if !ok { + t.Fatalf("expected numeric next_event_id, got %#v", nestedValue(t, watchResp, "data", "next_event_id")) + } + eventID, ok := nestedValue(t, watchResp, "data", "event", "event_id").(float64) + if !ok { + t.Fatalf("expected numeric event_id, got %#v", nestedValue(t, watchResp, "data", "event", "event_id")) + } + if nextEventID != eventID { + t.Fatalf("expected next_event_id == event.event_id, got %v vs %v", nextEventID, eventID) + } +} + +func TestWatchRespectsStatusFilter(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + sendOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "send", + "--from", "leader", + "--to", "worker-c", + "--subject", "Investigate policy edge case", + "--summary", "Initial request", + ) + var sendResp map[string]any + mustDecodeJSON(t, sendOut, &sendResp) + threadID := nestedString(t, sendResp, "data", "thread", "thread_id") + + watchCh := make(chan watchCommandResult, 1) + go func() { + stdout, stderr, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "watch", + "--agent", "worker-c", + "--status", "blocked", + "--timeout-seconds", "2", + ) + watchCh <- watchCommandResult{stdout: stdout, stderr: stderr, exit: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-c", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-c", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need policy decision", + ) + + var watchResult watchCommandResult + select { + case watchResult = <-watchCh: + case <-time.After(3 * time.Second): + t.Fatal("watch command did not return") + } + if watchResult.exit != 0 { + t.Fatalf("watch failed with exit=%d\nstderr:\n%s\nstdout:\n%s", watchResult.exit, watchResult.stderr, watchResult.stdout) + } + + var watchResp map[string]any + mustDecodeJSON(t, watchResult.stdout, &watchResp) + if status := nestedString(t, watchResp, "data", "thread", "status"); status != "blocked" { + t.Fatalf("expected blocked status wake, got %q", status) + } +} + +func TestWatchTimesOutWithNoActivity(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runInboxCommand(t, "--db", dbPath, "--json", "init") + + stdout, _, exitCode := executeInboxCommand( + "--db", dbPath, + "--json", + "watch", + "--agent", "worker-d", + "--status", "pending", + "--timeout-seconds", "1", + ) + if exitCode != 10 { + t.Fatalf("expected watch timeout exit code 10, got %d with %s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") +} + diff --git a/packages/orch-runtime/cmd/orch/main.go b/packages/orch-runtime/cmd/orch/main.go new file mode 100644 index 0000000..34e5c83 --- /dev/null +++ b/packages/orch-runtime/cmd/orch/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + orchcli "ai-workflow-skill/packages/orch-runtime/internal/cli/orch" +) + +func main() { + os.Exit(orchcli.Execute(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/packages/orch-runtime/go.mod b/packages/orch-runtime/go.mod index 1c54b4d..e0b79b7 100644 --- a/packages/orch-runtime/go.mod +++ b/packages/orch-runtime/go.mod @@ -1,3 +1,7 @@ module ai-workflow-skill/packages/orch-runtime go 1.26 + +require ( + github.com/spf13/cobra v1.10.1 +) diff --git a/packages/orch-runtime/internal/cli/orch/answer.go b/packages/orch-runtime/internal/cli/orch/answer.go new file mode 100644 index 0000000..5f80508 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/answer.go @@ -0,0 +1,77 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type answerOptions struct { + runID string + taskID string + body string + bodyFile string + payloadJSON string +} + +func newAnswerCmd(root *rootOptions) *cobra.Command { + opts := &answerOptions{} + + cmd := &cobra.Command{ + Use: "answer", + Short: "Answer the active blocked question for a task", + RunE: func(cmd *cobra.Command, args []string) error { + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + + ctx := cmd.Context() + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).AnswerTask(ctx, store.AnswerInput{ + RunID: opts.runID, + TaskID: opts.taskID, + Body: body, + PayloadJSON: opts.payloadJSON, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "answer", + Data: map[string]any{ + "task": result.Task, + "attempt": result.Attempt, + "thread": result.Thread, + "message": result.Message, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "answered task %s on thread %s\n", result.Task.TaskID, result.Thread.ThreadID) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.body, "body", "", "Answer body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read answer body from file") + cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/blocked.go b/packages/orch-runtime/internal/cli/orch/blocked.go new file mode 100644 index 0000000..dd59880 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/blocked.go @@ -0,0 +1,64 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type blockedOptions struct { + runID string +} + +func newBlockedCmd(root *rootOptions) *cobra.Command { + opts := &blockedOptions{} + + cmd := &cobra.Command{ + Use: "blocked", + Short: "List blocked tasks and their latest question", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + blocked, err := store.NewOrchStore(sqlDB).ListBlockedTasks(ctx, opts.runID) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "blocked", + Data: map[string]any{ + "blocked": blocked, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if len(blocked) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "no blocked tasks") + return err + } + for _, item := range blocked { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", item.Task.TaskID, item.Question.Summary); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/body.go b/packages/orch-runtime/internal/cli/orch/body.go new file mode 100644 index 0000000..0a53195 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/body.go @@ -0,0 +1,22 @@ +package orch + +import ( + "os" + + "ai-workflow-skill/packages/coord-core/protocol" +) + +func resolveBodyValue(body, bodyFile string) (string, error) { + if body != "" && bodyFile != "" { + return "", protocol.InvalidInput("body and body-file are mutually exclusive", nil) + } + if bodyFile == "" { + return body, nil + } + + content, err := os.ReadFile(bodyFile) + if err != nil { + return "", protocol.InvalidInput("failed to read body-file", err) + } + return string(content), nil +} diff --git a/packages/orch-runtime/internal/cli/orch/cancel.go b/packages/orch-runtime/internal/cli/orch/cancel.go new file mode 100644 index 0000000..74ce970 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/cancel.go @@ -0,0 +1,69 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type cancelOptions struct { + runID string + taskID string + reason string +} + +func newCancelCmd(root *rootOptions) *cobra.Command { + opts := &cancelOptions{} + + cmd := &cobra.Command{ + Use: "cancel", + Short: "Cancel a task or an entire run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).Cancel(ctx, store.CancelControlInput{ + RunID: opts.runID, + TaskID: opts.taskID, + Reason: opts.reason, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "cancel", + Data: map[string]any{ + "run": result.Run, + "cancelled_tasks": result.CancelledTasks, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if opts.taskID != "" { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled task %s in run %s\n", opts.taskID, opts.runID) + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled run %s (%d tasks)\n", opts.runID, len(result.CancelledTasks)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID") + cmd.Flags().StringVar(&opts.reason, "reason", "", "Cancellation reason") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/cleanup.go b/packages/orch-runtime/internal/cli/orch/cleanup.go new file mode 100644 index 0000000..d652041 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/cleanup.go @@ -0,0 +1,84 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type cleanupOptions struct { + runID string + taskID string + attemptNo int + allCompleted bool + force bool +} + +func newCleanupCmd(root *rootOptions) *cobra.Command { + opts := &cleanupOptions{} + + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Remove completed or abandoned attempt worktrees", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewOrchStore(sqlDB) + candidates, err := s.ListCleanupCandidates(ctx, store.CleanupInput{ + RunID: opts.runID, + TaskID: opts.taskID, + AttemptNo: opts.attemptNo, + AllCompleted: opts.allCompleted, + Force: opts.force, + }) + if err != nil { + return err + } + + records := make([]store.CleanupRecord, 0, len(candidates)) + for _, candidate := range candidates { + if err := cleanupAttemptWorktree(ctx, candidate.Attempt, opts.force); err != nil { + return err + } + records = append(records, store.CleanupRecord{Attempt: candidate.Attempt}) + } + + cleaned, err := s.MarkAttemptsCleaned(ctx, records) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "cleanup", + Data: map[string]any{ + "cleaned": cleaned, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "cleaned %d worktrees\n", len(cleaned)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID") + cmd.Flags().IntVar(&opts.attemptNo, "attempt", 0, "Specific attempt number") + cmd.Flags().BoolVar(&opts.allCompleted, "all-completed", false, "Clean all completed or abandoned worktrees in the run") + cmd.Flags().BoolVar(&opts.force, "force", false, "Force cleanup even for non-terminal worktrees") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/command_contracts_core_test.go b/packages/orch-runtime/internal/cli/orch/command_contracts_core_test.go new file mode 100644 index 0000000..f32523e --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/command_contracts_core_test.go @@ -0,0 +1,245 @@ +package orch + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestOrchRunShowReturnsRunSummaryAndTaskCounts(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_001", + "--goal", "Build blog MVP", + "--summary", "Public blog plus admin CRUD", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_001", + "--task", "T1", + "--title", "Implement retry policy", + "--summary", "Add retry policy to HTTP client", + "--default-to", "worker-a", + ) + + showOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "show", + "--run", "run_blog_001", + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "run", "run_id"); got != "run_blog_001" { + t.Fatalf("expected run id run_blog_001, got %q", got) + } + if got := nestedString(t, showResp, "data", "run", "status"); got != "ready" { + t.Fatalf("expected run status ready, got %q", got) + } + + taskCounts, ok := nestedValue(t, showResp, "data", "task_counts").(map[string]any) + if !ok { + t.Fatalf("expected task_counts object, got %#v", nestedValue(t, showResp, "data", "task_counts")) + } + if got, _ := taskCounts["ready"].(float64); got < 1 { + t.Fatalf("expected ready task count >= 1, got %#v", taskCounts["ready"]) + } + + data, ok := showResp["data"].(map[string]any) + if !ok { + t.Fatalf("expected data object, got %#v", showResp["data"]) + } + if _, exists := data["tasks"]; exists { + t.Fatalf("did not expect tasks array in run show response, got %#v", data["tasks"]) + } +} + +func TestOrchRunShowRejectsMissingRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "run", "show", + "--run", "run_blog_missing", + ) + if exitCode != 40 { + t.Fatalf("expected not_found exit code 40, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "not_found") +} + +func TestOrchTaskAddRejectsInvalidAcceptanceJSON(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_003", + "--goal", "Validate task add input guards", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_003", + "--task", "T1", + "--title", "Implement retry policy", + "--acceptance-json", `{"done":true`, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "acceptance-json must be valid JSON") +} + +func TestOrchTaskAddRejectsInvalidPriority(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_004", + "--goal", "Validate task priority input", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_004", + "--task", "T1", + "--title", "Implement retry policy", + "--priority", "urgent", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + assertErrorMessageContains(t, stdout, "priority must be one of low, normal, high") +} + +func TestOrchReadyOrdersByPriorityAndRespectsLimit(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_005", + "--goal", "Validate ready ordering", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_005", + "--task", "T1", + "--title", "Low priority task", + "--priority", "low", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_005", + "--task", "T2", + "--title", "Normal priority task", + "--priority", "normal", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_005", + "--task", "T3", + "--title", "High priority task", + "--priority", "high", + ) + + readyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_005", + "--limit", "2", + ) + + var readyResp map[string]any + mustDecodeJSON(t, readyOut, &readyResp) + readyTasks := nestedArray(t, readyResp, "data", "tasks") + if len(readyTasks) != 2 { + t.Fatalf("expected two ready tasks with limit 2, got %#v", readyTasks) + } + + firstTask, ok := readyTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected first ready task object, got %#v", readyTasks[0]) + } + secondTask, ok := readyTasks[1].(map[string]any) + if !ok { + t.Fatalf("expected second ready task object, got %#v", readyTasks[1]) + } + if got, _ := firstTask["task_id"].(string); got != "T3" { + t.Fatalf("expected first ready task T3, got %#v", firstTask["task_id"]) + } + if got, _ := secondTask["task_id"].(string); got != "T2" { + t.Fatalf("expected second ready task T2, got %#v", secondTask["task_id"]) + } + + for _, item := range readyTasks { + task, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected ready task object, got %#v", item) + } + if got, _ := task["task_id"].(string); got == "T1" { + t.Fatalf("did not expect low-priority task T1 within limited ready results") + } + } +} + +func assertErrorMessageContains(t *testing.T, raw string, want string) { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + message, _ := errorValue["message"].(string) + if !strings.Contains(message, want) { + t.Fatalf("expected error message to contain %q, got %q", want, message) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/command_contracts_edges_test.go b/packages/orch-runtime/internal/cli/orch/command_contracts_edges_test.go new file mode 100644 index 0000000..f03efcb --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/command_contracts_edges_test.go @@ -0,0 +1,219 @@ +package orch + +import ( + "path/filepath" + "testing" +) + +func TestOrchAnswerAcceptsPayloadJSONWithoutBody(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + threadID := seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_001", "T2", "worker-b") + + answerOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_001", + "--task", "T2", + "--payload-json", `{"decision":"stdout","source":"leader"}`, + ) + + var answerResp map[string]any + mustDecodeJSON(t, answerOut, &answerResp) + message, ok := nestedValue(t, answerResp, "data", "message").(map[string]any) + if !ok { + t.Fatalf("expected answer message object, got %#v", nestedValue(t, answerResp, "data", "message")) + } + if got, _ := message["kind"].(string); got != "answer" { + t.Fatalf("expected answer message kind, got %#v", message["kind"]) + } + payload, ok := message["payload_json"].(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", message["payload_json"]) + } + if got, _ := payload["decision"].(string); got != "stdout" { + t.Fatalf("expected payload decision stdout, got %#v", payload["decision"]) + } + if got, _ := payload["source"].(string); got != "leader" { + t.Fatalf("expected payload source leader, got %#v", payload["source"]) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages := nestedArray(t, showResp, "data", "messages") + if len(messages) == 0 { + t.Fatalf("expected messages in thread %s", threadID) + } + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected last message object, got %#v", messages[len(messages)-1]) + } + if got, _ := lastMessage["kind"].(string); got != "answer" { + t.Fatalf("expected latest message kind answer, got %#v", lastMessage["kind"]) + } + lastPayload, ok := lastMessage["payload_json"].(map[string]any) + if !ok { + t.Fatalf("expected latest payload_json object, got %#v", lastMessage["payload_json"]) + } + if got, _ := lastPayload["decision"].(string); got != "stdout" { + t.Fatalf("expected latest payload decision stdout, got %#v", lastPayload["decision"]) + } +} + +func TestOrchAnswerRejectsEmptyBodyAndPayload(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + _ = seedBlockedTaskForAnswerCleanupEdgeTests(t, dbPath, "run_blog_answer_002", "T2", "worker-b") + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_answer_002", + "--task", "T2", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +func TestOrchCleanupRejectsAttemptWithoutTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cleanup_002", + "--goal", "Validate cleanup selectors", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_002", + "--attempt", "1", + ) + if exitCode != 30 { + t.Fatalf("expected exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") +} + +func TestOrchCleanupReturnsNoMatchingWorkWhenFiltersMiss(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cleanup_003", + "--goal", "Validate cleanup empty result", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_cleanup_003", + "--task", "T1", + "--title", "Prepare cleanup target", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_003", + "--task", "T1", + ) + if exitCode != 10 { + t.Fatalf("expected exit code 10, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "no_matching_work") +} + +func seedBlockedTaskForAnswerCleanupEdgeTests(t *testing.T, dbPath, runID, taskID, agent string) string { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", runID, + "--goal", "Prepare blocked task for answer edge tests", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", runID, + "--task", taskID, + "--title", "Build frontend", + "--default-to", agent, + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", runID, + "--task", taskID, + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", agent, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", agent, + "--thread", threadID, + "--status", "blocked", + "--summary", "Need logging decision", + "--payload-json", `{"question":"stdout or stderr?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", runID, + ) + + return threadID +} diff --git a/packages/orch-runtime/internal/cli/orch/command_contracts_remaining_test.go b/packages/orch-runtime/internal/cli/orch/command_contracts_remaining_test.go new file mode 100644 index 0000000..5c093cf --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/command_contracts_remaining_test.go @@ -0,0 +1,723 @@ +package orch + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOrchRunInitCreatesNewRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + initOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_init_001", + "--goal", "Build blog MVP", + "--summary", "Public blog plus admin CRUD", + ) + + var initResp map[string]any + mustDecodeJSON(t, initOut, &initResp) + if got := nestedString(t, initResp, "data", "run", "run_id"); got != "run_blog_init_001" { + t.Fatalf("expected run id run_blog_init_001, got %q", got) + } + if got := nestedString(t, initResp, "data", "run", "goal"); got != "Build blog MVP" { + t.Fatalf("expected goal Build blog MVP, got %q", got) + } + if got := nestedString(t, initResp, "data", "run", "summary"); got != "Public blog plus admin CRUD" { + t.Fatalf("expected summary to round-trip, got %q", got) + } + if got := nestedString(t, initResp, "data", "run", "status"); got != "active" { + t.Fatalf("expected new run status active, got %q", got) + } + assertNonEmptyNestedString(t, initResp, "data", "run", "created_at") + assertNonEmptyNestedString(t, initResp, "data", "run", "updated_at") + + showOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "show", + "--run", "run_blog_init_001", + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "run", "run_id"); got != "run_blog_init_001" { + t.Fatalf("expected persisted run id run_blog_init_001, got %q", got) + } + if got := nestedString(t, showResp, "data", "run", "status"); got != "active" { + t.Fatalf("expected persisted run status active, got %q", got) + } +} + +func TestOrchDispatchCreatesAttemptAndThreadForReadyTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_dispatch_001", + "--goal", "Build blog MVP", + "--summary", "Public blog plus admin CRUD", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_dispatch_001", + "--task", "T1", + "--title", "Implement retry policy", + "--summary", "Add retry policy to HTTP client", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_dispatch_001", + "--task", "T1", + "--body", "Implement retry handling for the HTTP client.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + if got := nestedString(t, dispatchResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected dispatched task status, got %q", got) + } + if got := nestedValue(t, dispatchResp, "data", "attempt", "attempt_no").(float64); got != 1 { + t.Fatalf("expected attempt_no 1, got %#v", got) + } + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + if threadID == "" { + t.Fatal("expected non-empty attempt thread_id") + } + if got := nestedString(t, dispatchResp, "data", "attempt", "assigned_to"); got != "worker-a" { + t.Fatalf("expected assigned_to worker-a, got %q", got) + } + if got := nestedString(t, dispatchResp, "data", "thread", "thread_id"); got != threadID { + t.Fatalf("expected thread.thread_id %q, got %q", threadID, got) + } + if got := nestedString(t, dispatchResp, "data", "message", "kind"); got != "task" { + t.Fatalf("expected first dispatch message kind task, got %q", got) + } +} + +func TestOrchBlockedListsLatestQuestionForBlockedTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_blocked_001", + "--goal", "Build dependency-aware workflow", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_blocked_001", + "--task", "T1", + "--title", "Build backend", + "--summary", "Implement backend APIs", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_blocked_001", + "--task", "T2", + "--title", "Build frontend", + "--summary", "Implement frontend flows", + "--default-to", "worker-b", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_blocked_001", + "--task", "T2", + "--depends-on", "T1", + ) + + firstDispatch := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_blocked_001", + "--task", "T1", + ) + + var firstDispatchResp map[string]any + mustDecodeJSON(t, firstDispatch, &firstDispatchResp) + threadBackend := nestedString(t, firstDispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadBackend, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadBackend, + "--summary", "Backend complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_blocked_001", + ) + + secondDispatch := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_blocked_001", + "--task", "T2", + ) + + var secondDispatchResp map[string]any + mustDecodeJSON(t, secondDispatch, &secondDispatchResp) + threadFrontend := nestedString(t, secondDispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-b", + "--thread", threadFrontend, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-b", + "--thread", threadFrontend, + "--status", "blocked", + "--summary", "Need logging decision", + "--payload-json", `{"question":"stdout or stderr?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_blocked_001", + ) + + blockedOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "blocked", + "--run", "run_blog_blocked_001", + ) + + var blockedResp map[string]any + mustDecodeJSON(t, blockedOut, &blockedResp) + blockedTasks := nestedArray(t, blockedResp, "data", "blocked") + if len(blockedTasks) != 1 { + t.Fatalf("expected one blocked task, got %#v", blockedTasks) + } + blockedTask, ok := blockedTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected blocked task object, got %#v", blockedTasks[0]) + } + if got := nestedString(t, blockedTask, "task", "task_id"); got != "T2" { + t.Fatalf("expected blocked task T2, got %q", got) + } + if got := nestedString(t, blockedTask, "question", "kind"); got != "question" { + t.Fatalf("expected question.kind=question, got %q", got) + } + if got := nestedString(t, blockedTask, "question", "summary"); got != "Need logging decision" { + t.Fatalf("expected question summary to match latest blocked message, got %q", got) + } + questionPayload, ok := nestedValue(t, blockedTask, "question", "payload_json").(map[string]any) + if !ok { + t.Fatalf("expected question payload_json object, got %#v", nestedValue(t, blockedTask, "question", "payload_json")) + } + if got, _ := questionPayload["question"].(string); got != "stdout or stderr?" { + t.Fatalf("expected latest question payload, got %#v", questionPayload["question"]) + } +} + +func TestOrchStatusReturnsRunSummaryAndTaskList(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_status_001", + "--goal", "Build blog MVP", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_status_001", + "--task", "T1", + "--title", "Implement retry policy", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_status_001", + "--task", "T1", + "--body", "Implement retry handling for the HTTP client.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--body", "The HTTP client now retries transient failures.", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_status_001", + ) + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_status_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "run_id"); got != "run_blog_status_001" { + t.Fatalf("expected run_id run_blog_status_001, got %q", got) + } + if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" { + t.Fatalf("expected run status done, got %q", got) + } + taskCounts, ok := nestedValue(t, statusResp, "data", "task_counts").(map[string]any) + if !ok { + t.Fatalf("expected task_counts object, got %#v", nestedValue(t, statusResp, "data", "task_counts")) + } + if got, _ := taskCounts["done"].(float64); got != 1 { + t.Fatalf("expected done task count 1, got %#v", taskCounts["done"]) + } + tasks := nestedArray(t, statusResp, "data", "tasks") + if len(tasks) != 1 { + t.Fatalf("expected one task in status response, got %#v", tasks) + } + task, ok := tasks[0].(map[string]any) + if !ok { + t.Fatalf("expected task object, got %#v", tasks[0]) + } + if got, _ := task["task_id"].(string); got != "T1" { + t.Fatalf("expected task_id T1, got %#v", task["task_id"]) + } + if got, _ := task["status"].(string); got != "done" { + t.Fatalf("expected task status done, got %#v", task["status"]) + } +} + +func TestOrchReconcileMapsFailedThreadToTerminalTaskState(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_reconcile_001", + "--goal", "Build blog MVP", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_reconcile_001", + "--task", "T1", + "--title", "Implement retry policy", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_reconcile_001", + "--task", "T1", + "--body", "Implement retry handling for the HTTP client.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy failed", + "--body", "The HTTP client kept failing integration tests.", + ) + + reconcileOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_reconcile_001", + ) + + var reconcileResp map[string]any + mustDecodeJSON(t, reconcileOut, &reconcileResp) + updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks") + if len(updatedTasks) != 1 { + t.Fatalf("expected one updated task after failed reconcile, got %#v", updatedTasks) + } + task, ok := updatedTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected updated task object, got %#v", updatedTasks[0]) + } + if got, _ := task["task_id"].(string); got != "T1" { + t.Fatalf("expected updated task T1, got %#v", task["task_id"]) + } + if got, _ := task["status"].(string); got != "failed" { + t.Fatalf("expected reconciled task status failed, got %#v", task["status"]) + } + taskCounts, ok := nestedValue(t, reconcileResp, "data", "task_counts").(map[string]any) + if !ok { + t.Fatalf("expected task_counts object, got %#v", nestedValue(t, reconcileResp, "data", "task_counts")) + } + if got, _ := taskCounts["failed"].(float64); got != 1 { + t.Fatalf("expected failed task count 1 after reconcile, got %#v", taskCounts["failed"]) + } + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_reconcile_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "failed" { + t.Fatalf("expected run status failed after failed reconcile, got %q", got) + } +} + +func TestOrchWorkflowStrictWorktreeDispatchToCleanup(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_workflow_worktree_001", + "--goal", "Validate strict worktree dispatch", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_workflow_worktree_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_workflow_worktree_001", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + "--body", "Implement inside isolated worktree.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path") + if worktreePath == "" { + t.Fatal("expected non-empty worktree_path for strict worktree workflow") + } + if got := nestedString(t, dispatchResp, "data", "attempt", "workspace_status"); got != "created" { + t.Fatalf("expected workspace_status created, got %q", got) + } + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Backend complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_workflow_worktree_001", + ) + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_workflow_worktree_001", + "--task", "T1", + "--attempt", "1", + ) + + var cleanupResp map[string]any + mustDecodeJSON(t, cleanupOut, &cleanupResp) + cleaned := nestedArray(t, cleanupResp, "data", "cleaned") + if len(cleaned) != 1 { + t.Fatalf("expected one cleaned attempt, got %#v", cleaned) + } + cleanedAttempt, ok := cleaned[0].(map[string]any) + if !ok { + t.Fatalf("expected cleaned attempt object, got %#v", cleaned[0]) + } + if got, _ := cleanedAttempt["workspace_status"].(string); got != "cleaned" { + t.Fatalf("expected cleaned workspace_status, got %#v", cleanedAttempt["workspace_status"]) + } + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("expected cleaned worktree path to be removed, err=%v", err) + } +} + +func TestOrchWorkflowCouncilReviewEndToEnd(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_workflow_001" + + startOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the current blog architecture.", + ) + + var startResp map[string]any + mustDecodeJSON(t, startOut, &startResp) + reviewers := nestedArray(t, startResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three reviewers from council start, got %#v", reviewers) + } + + completeCouncilWorkflowReviewersForRemainingTests(t, dbPath, runID) + + waitOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "wait", + "--run", runID, + "--timeout-seconds", "2", + ) + + var waitResp map[string]any + mustDecodeJSON(t, waitOut, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke { + t.Fatalf("expected council wait to wake, got %#v", waitResp) + } + if allComplete, _ := nestedValue(t, waitResp, "data", "all_complete").(bool); !allComplete { + t.Fatalf("expected all reviewers complete, got %#v", waitResp) + } + + tallyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + "--similarity", "normal", + ) + + var tallyResp map[string]any + mustDecodeJSON(t, tallyOut, &tallyResp) + if got := nestedString(t, tallyResp, "data", "similarity"); got != "normal" { + t.Fatalf("expected normal tally similarity, got %q", got) + } + tallyCounts, ok := nestedValue(t, tallyResp, "data", "counts").(map[string]any) + if !ok { + t.Fatalf("expected tally counts object, got %#v", nestedValue(t, tallyResp, "data", "counts")) + } + if got, _ := tallyCounts["consensus"].(float64); got != 1 { + t.Fatalf("expected one consensus group, got %#v", tallyCounts["consensus"]) + } + if got, _ := tallyCounts["majority"].(float64); got != 1 { + t.Fatalf("expected one majority group, got %#v", tallyCounts["majority"]) + } + if got, _ := tallyCounts["minority"].(float64); got != 1 { + t.Fatalf("expected one minority group, got %#v", tallyCounts["minority"]) + } + + reportOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "report", + "--run", runID, + ) + + var reportResp map[string]any + mustDecodeJSON(t, reportOut, &reportResp) + show := nestedArray(t, reportResp, "data", "show") + if len(show) != 2 || show[0] != "consensus" || show[1] != "majority" { + t.Fatalf("expected default report show [consensus majority], got %#v", show) + } + grouped := nestedArray(t, reportResp, "data", "grouped_recommendations") + if len(grouped) != 2 { + t.Fatalf("expected default report to include consensus and majority groups, got %#v", grouped) + } + artifacts := nestedArray(t, reportResp, "data", "report_artifacts") + if len(artifacts) != 1 { + t.Fatalf("expected one report artifact, got %#v", artifacts) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected report artifact object, got %#v", artifacts[0]) + } + reportPath, _ := artifact["path"].(string) + if reportPath == "" { + t.Fatalf("expected report artifact path, got %#v", artifact["path"]) + } + if _, err := os.Stat(reportPath); err != nil { + t.Fatalf("expected report artifact to exist at %q: %v", reportPath, err) + } +} + +func assertNonEmptyNestedString(t *testing.T, value map[string]any, keys ...string) { + t.Helper() + + if got := nestedString(t, value, keys...); got == "" { + t.Fatalf("expected non-empty string at %v", keys) + } +} + +func completeCouncilWorkflowReviewersForRemainingTests(t *testing.T, dbPath, runID string) { + t.Helper() + + completeCouncilReviewer( + t, + dbPath, + runID, + "architecture-reviewer", + `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}},{"title":"Share helpers","summary":"Council report rendering paths are repeated.","proposal":"Introduce shared council coordinator helpers for report rendering.","rationale":"This keeps report assembly consistent.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "implementation-reviewer", + `{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"high","tags":["maintainability"],"target_refs":{"repo_path":"."}},{"title":"Reuse report helpers","summary":"Formatting logic should stay shared.","proposal":"Introduce shared council coordinator helpers for report rendering","rationale":"This avoids formatter drift.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "risk-reviewer", + `{"reviewer_role":"risk-reviewer","findings":[{"title":"Lock contracts","summary":"Contract drift becomes risky over time.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This reduces integration regressions.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}},{"title":"Cover JSON output","summary":"The council report response should stay stable.","proposal":"Add regression tests for council report JSON output.","rationale":"This catches contract regressions earlier.","confidence":"high","tags":["testing"],"target_refs":{"repo_path":"."}}]}`, + ) +} diff --git a/packages/orch-runtime/internal/cli/orch/council.go b/packages/orch-runtime/internal/cli/orch/council.go new file mode 100644 index 0000000..05fde1d --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council.go @@ -0,0 +1,16 @@ +package orch + +import "github.com/spf13/cobra" + +func newCouncilCmd(root *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "council", + Short: "Council review workflow commands", + } + + cmd.AddCommand(newCouncilStartCmd(root)) + cmd.AddCommand(newCouncilWaitCmd(root)) + cmd.AddCommand(newCouncilTallyCmd(root)) + cmd.AddCommand(newCouncilReportCmd(root)) + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/council_report.go b/packages/orch-runtime/internal/cli/orch/council_report.go new file mode 100644 index 0000000..ff1a0bf --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council_report.go @@ -0,0 +1,111 @@ +package orch + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type councilReportOptions struct { + runID string + show string +} + +func newCouncilReportCmd(root *rootOptions) *cobra.Command { + opts := &councilReportOptions{} + + cmd := &cobra.Command{ + Use: "report", + Short: "Render the final grouped council output", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + orchStore := store.NewOrchStore(sqlDB) + result, err := orchStore.BuildCouncilReport(ctx, store.CouncilReportInput{ + RunID: opts.runID, + Show: opts.show, + }) + if err != nil { + return err + } + + reportPath := councilReportArtifactPath(root.dbPath, result.RunID) + if err := os.MkdirAll(filepath.Dir(reportPath), 0o755); err != nil { + return fmt.Errorf("create council report directory: %w", err) + } + if err := os.WriteFile(reportPath, []byte(result.Markdown), 0o644); err != nil { + return fmt.Errorf("write council report artifact: %w", err) + } + + result.ReportArtifacts = []store.CouncilReportArtifact{ + { + Kind: "markdown", + Path: reportPath, + }, + } + if err := orchStore.PersistCouncilReport(ctx, store.CouncilPersistReportInput{ + RunID: result.RunID, + Show: result.Show, + Summary: result.Summary, + MarkdownPath: reportPath, + }); err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "council report", + Data: map[string]any{ + "run_id": result.RunID, + "show": result.Show, + "summary": result.Summary, + "grouped_recommendations": result.GroupedRecommendations, + "report_artifacts": result.ReportArtifacts, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprint(cmd.OutOrStdout(), result.Markdown) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID") + cmd.Flags().StringVar(&opts.show, "show", "", "Buckets to show: consensus,majority,minority,all") + _ = cmd.MarkFlagRequired("run") + + return cmd +} + +func councilReportArtifactPath(dbPath, runID string) string { + baseDir := "." + dbDir := filepath.Dir(dbPath) + switch { + case dbDir == "", dbDir == ".": + baseDir = "." + case filepath.Base(dbDir) == ".agents": + baseDir = filepath.Dir(dbDir) + default: + baseDir = dbDir + } + if baseDir == "" { + baseDir = "." + } + + fileName := strings.ReplaceAll(strings.TrimSpace(runID), string(os.PathSeparator), "_") + ".md" + return filepath.Join(baseDir, ".orch", "reports", fileName) +} diff --git a/packages/orch-runtime/internal/cli/orch/council_report_contracts_test.go b/packages/orch-runtime/internal/cli/orch/council_report_contracts_test.go new file mode 100644 index 0000000..5114b5c --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council_report_contracts_test.go @@ -0,0 +1,199 @@ +package orch + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOrchCouncilReportRejectsBeforeTally(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_010" + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the council reporting flow.", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "report", + "--run", runID, + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + + if msg := orchErrorMessage(t, stdout); !strings.Contains(msg, "run council tally first") { + t.Fatalf("expected error message to require council tally first, got %q", msg) + } +} + +func TestOrchCouncilReportRejectsInvalidShow(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_012" + seedCouncilReportRun(t, dbPath, runID) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "council", "report", + "--run", runID, + "--show", "consensus,invalid", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_input exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_input") + + msg := orchErrorMessage(t, stdout) + for _, expected := range []string{"consensus", "majority", "minority", "all"} { + if !strings.Contains(msg, expected) { + t.Fatalf("expected invalid --show message to mention %q, got %q", expected, msg) + } + } +} + +func TestOrchCouncilReportDefaultsToConsensusForOnlyUnanimousRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_011" + seedOnlyUnanimousCouncilReportRun(t, dbPath, runID) + + reportOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "report", + "--run", runID, + ) + + var reportResp map[string]any + mustDecodeJSON(t, reportOut, &reportResp) + if ok, _ := reportResp["ok"].(bool); !ok { + t.Fatalf("expected ok=true, got %#v", reportResp) + } + if got := nestedString(t, reportResp, "data", "run_id"); got != runID { + t.Fatalf("expected run id %q, got %q", runID, got) + } + + show := nestedArray(t, reportResp, "data", "show") + if len(show) != 1 || show[0] != "consensus" { + t.Fatalf("expected unanimous-only default show bucket [consensus], got %#v", show) + } + + summary, ok := nestedValue(t, reportResp, "data", "summary").(map[string]any) + if !ok { + t.Fatalf("expected summary object, got %#v", nestedValue(t, reportResp, "data", "summary")) + } + if got, _ := summary["consensus"].(float64); got != 1 { + t.Fatalf("expected one consensus group, got %#v", summary["consensus"]) + } + if got, _ := summary["majority"].(float64); got != 1 { + t.Fatalf("expected one majority group, got %#v", summary["majority"]) + } + if got, _ := summary["minority"].(float64); got != 1 { + t.Fatalf("expected one minority group, got %#v", summary["minority"]) + } + + groups := nestedArray(t, reportResp, "data", "grouped_recommendations") + if len(groups) != 1 { + t.Fatalf("expected one reported recommendation group, got %#v", groups) + } + group, ok := groups[0].(map[string]any) + if !ok { + t.Fatalf("expected grouped recommendation object, got %#v", groups[0]) + } + if got, _ := group["bucket"].(string); got != "consensus" { + t.Fatalf("expected only reported bucket to be consensus, got %#v", group["bucket"]) + } + + artifacts := nestedArray(t, reportResp, "data", "report_artifacts") + if len(artifacts) != 1 { + t.Fatalf("expected one report artifact, got %#v", artifacts) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected report artifact object, got %#v", artifacts[0]) + } + reportPath, _ := artifact["path"].(string) + if reportPath == "" { + t.Fatalf("expected markdown artifact path, got %#v", artifact["path"]) + } + if _, err := os.Stat(reportPath); err != nil { + t.Fatalf("expected markdown artifact to exist at %q: %v", reportPath, err) + } +} + +func orchErrorMessage(t *testing.T, raw string) string { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + msg, ok := errorValue["message"].(string) + if !ok { + t.Fatalf("expected error message string, got %#v", errorValue["message"]) + } + return msg +} + +func seedOnlyUnanimousCouncilReportRun(t *testing.T, dbPath, runID string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the council reporting flow.", + "--only-unanimous", + ) + + completeCouncilReviewer( + t, + dbPath, + runID, + "architecture-reviewer", + `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}},{"title":"Share helpers","summary":"Council report rendering paths are repeated.","proposal":"Introduce shared council coordinator helpers for report rendering.","rationale":"This keeps report assembly consistent.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "implementation-reviewer", + `{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"high","tags":["maintainability"],"target_refs":{"repo_path":"."}},{"title":"Reuse report helpers","summary":"Formatting logic should stay shared.","proposal":"Introduce shared council coordinator helpers for report rendering","rationale":"This avoids formatter drift.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "risk-reviewer", + `{"reviewer_role":"risk-reviewer","findings":[{"title":"Lock contracts","summary":"Contract drift becomes risky over time.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This reduces integration regressions.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}},{"title":"Cover JSON output","summary":"The council report response should stay stable.","proposal":"Add regression tests for council report JSON output.","rationale":"This catches contract regressions earlier.","confidence":"high","tags":["testing"],"target_refs":{"repo_path":"."}}]}`, + ) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + "--similarity", "normal", + ) +} diff --git a/packages/orch-runtime/internal/cli/orch/council_start.go b/packages/orch-runtime/internal/cli/orch/council_start.go new file mode 100644 index 0000000..105d5e4 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council_start.go @@ -0,0 +1,88 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type councilStartOptions struct { + runID string + target string + targetFile string + repoPath string + targetTaskID string + targetType string + mode string + outputMode string + onlyUnanimous bool +} + +func newCouncilStartCmd(root *rootOptions) *cobra.Command { + opts := &councilStartOptions{} + + cmd := &cobra.Command{ + Use: "start", + Short: "Create and dispatch a three-reviewer council run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).StartCouncil(ctx, store.CouncilStartInput{ + RunID: opts.runID, + Target: opts.target, + TargetFile: opts.targetFile, + RepoPath: opts.repoPath, + TargetTaskID: opts.targetTaskID, + TargetType: opts.targetType, + Mode: opts.mode, + OutputMode: opts.outputMode, + OnlyUnanimous: opts.onlyUnanimous, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "council start", + Data: map[string]any{ + "run_id": result.Run.RunID, + "mode": result.Run.Mode, + "target_type": result.Run.TargetType, + "output": result.Run.OutputMode, + "only_unanimous": result.Run.OnlyUnanimous, + "target": result.Input, + "reviewers": result.Reviewers, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "started council run %s with %d reviewers\n", result.Run.RunID, len(result.Reviewers)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.target, "target", "", "Inline target prompt") + cmd.Flags().StringVar(&opts.targetFile, "target-file", "", "Optional target context file") + cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Optional repository path for review context") + cmd.Flags().StringVar(&opts.targetTaskID, "task-id", "", "Optional related task ID") + cmd.Flags().StringVar(&opts.targetType, "target-type", "mixed", "Target type: text, repo, or mixed") + cmd.Flags().StringVar(&opts.mode, "mode", "brainstorm", "Council mode: brainstorm or review") + cmd.Flags().StringVar(&opts.outputMode, "output", "both", "Output mode: markdown, json, or both") + cmd.Flags().BoolVar(&opts.onlyUnanimous, "only-unanimous", false, "Show only unanimous recommendations in downstream report defaults") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/council_tally.go b/packages/orch-runtime/internal/cli/orch/council_tally.go new file mode 100644 index 0000000..a246003 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council_tally.go @@ -0,0 +1,64 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type councilTallyOptions struct { + runID string + similarity string +} + +func newCouncilTallyCmd(root *rootOptions) *cobra.Command { + opts := &councilTallyOptions{} + + cmd := &cobra.Command{ + Use: "tally", + Short: "Group reviewer findings and compute council support counts", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).TallyCouncil(ctx, store.CouncilTallyInput{ + RunID: opts.runID, + Similarity: opts.similarity, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "council tally", + Data: map[string]any{ + "run_id": result.RunID, + "similarity": result.Similarity, + "counts": result.Counts, + "grouped_recommendations": result.GroupedRecommendations, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "tallied council run %s into %d groups\n", result.RunID, len(result.GroupedRecommendations)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID") + cmd.Flags().StringVar(&opts.similarity, "similarity", "normal", "Grouping mode: strict or normal") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/council_wait.go b/packages/orch-runtime/internal/cli/orch/council_wait.go new file mode 100644 index 0000000..ce4417f --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/council_wait.go @@ -0,0 +1,69 @@ +package orch + +import ( + "fmt" + "time" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type councilWaitOptions struct { + runID string + timeoutSeconds int +} + +func newCouncilWaitCmd(root *rootOptions) *cobra.Command { + opts := &councilWaitOptions{} + + cmd := &cobra.Command{ + Use: "wait", + Short: "Block until all council reviewers complete or timeout is reached", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).WaitForCouncil(ctx, store.CouncilWaitInput{ + RunID: opts.runID, + Timeout: time.Duration(opts.timeoutSeconds) * time.Second, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "council wait", + Data: map[string]any{ + "run_id": result.RunID, + "woke": result.Woke, + "all_complete": result.AllComplete, + "reviewers": result.ReviewerStatuses, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if !result.Woke { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "council wait timed out for run %s\n", result.RunID) + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "all council reviewers completed for run %s\n", result.RunID) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Council run ID") + cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait before timing out") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/db.go b/packages/orch-runtime/internal/cli/orch/db.go new file mode 100644 index 0000000..9449570 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/db.go @@ -0,0 +1,22 @@ +package orch + +import ( + "context" + "database/sql" + + "ai-workflow-skill/packages/coord-core/db" +) + +func openOrchDB(ctx context.Context, dbPath string) (*sql.DB, error) { + sqlDB, err := db.Open(ctx, dbPath) + if err != nil { + return nil, err + } + + if err := db.ApplyMigrations(ctx, sqlDB); err != nil { + _ = sqlDB.Close() + return nil, err + } + + return sqlDB, nil +} diff --git a/packages/orch-runtime/internal/cli/orch/dep.go b/packages/orch-runtime/internal/cli/orch/dep.go new file mode 100644 index 0000000..5f2e7f1 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/dep.go @@ -0,0 +1,76 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type depAddOptions struct { + runID string + taskID string + dependsOn string +} + +func newDepCmd(root *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "dep", + Short: "Task dependency commands", + } + + cmd.AddCommand(newDepAddCmd(root)) + return cmd +} + +func newDepAddCmd(root *rootOptions) *cobra.Command { + opts := &depAddOptions{} + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a dependency edge to a task", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + dep, err := store.NewOrchStore(sqlDB).AddDependency(ctx, store.AddDependencyInput{ + RunID: opts.runID, + TaskID: opts.taskID, + DependsOnTaskID: opts.dependsOn, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "dep add", + Data: map[string]any{ + "dependency": dep, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "added dependency %s -> %s\n", dep.TaskID, dep.DependsOnTaskID) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.dependsOn, "depends-on", "", "Dependency task ID") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + _ = cmd.MarkFlagRequired("depends-on") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/dispatch.go b/packages/orch-runtime/internal/cli/orch/dispatch.go new file mode 100644 index 0000000..c90623e --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/dispatch.go @@ -0,0 +1,93 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type dispatchOptions struct { + runID string + taskID string + toAgent string + body string + bodyFile string + baseRef string + repoPath string + workspaceRoot string + strictWorktree bool +} + +func newDispatchCmd(root *rootOptions) *cobra.Command { + opts := &dispatchOptions{} + + cmd := &cobra.Command{ + Use: "dispatch", + Short: "Dispatch a ready task to a worker through inbox", + RunE: func(cmd *cobra.Command, args []string) error { + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + + ctx := cmd.Context() + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).DispatchTask(ctx, store.DispatchInput{ + RunID: opts.runID, + TaskID: opts.taskID, + ToAgent: opts.toAgent, + Body: body, + BaseRef: opts.baseRef, + PrepareWorkspace: newDispatchWorkspacePreparer(cmd, *opts), + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "dispatch", + Data: map[string]any{ + "task": result.Task, + "attempt": result.Attempt, + "thread": result.Thread, + "message": result.Message, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf( + cmd.OutOrStdout(), + "dispatched task %s to %s as thread %s\n", + result.Task.TaskID, + result.Attempt.AssignedTo, + result.Attempt.ThreadID, + ) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.toAgent, "to", "", "Worker agent override") + cmd.Flags().StringVar(&opts.body, "body", "", "Task message body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read task message body from file") + cmd.Flags().StringVar(&opts.baseRef, "base-ref", "", "Optional base ref to record on the attempt") + cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Source repository path for worktree dispatch") + cmd.Flags().StringVar(&opts.workspaceRoot, "workspace-root", "", "Workspace root for worktree dispatch") + cmd.Flags().BoolVar(&opts.strictWorktree, "strict-worktree", false, "Require strict worktree setup") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/execute.go b/packages/orch-runtime/internal/cli/orch/execute.go new file mode 100644 index 0000000..1e1ac1e --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/execute.go @@ -0,0 +1,113 @@ +package orch + +import ( + "errors" + "fmt" + "io" + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" +) + +func Execute(args []string, stdout, stderr io.Writer) int { + cmd := NewRootCmd() + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + jsonOutput := hasJSONFlag(args) + renderError(stdout, stderr, jsonOutput, err) + return exitCodeForError(err) + } + + return 0 +} + +func exitCodeForError(err error) int { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.ExitCode + } + + switch { + case isUsageError(err): + return 30 + case errors.Is(err, store.ErrLeaseConflict): + return 20 + case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound): + return 40 + case errors.Is(err, store.ErrInvalidInput), errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease): + return 30 + default: + return 50 + } +} + +func errorCodeForError(err error) string { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.Code + } + + switch { + case isUsageError(err): + return "invalid_input" + case errors.Is(err, store.ErrLeaseConflict): + return "conflict" + case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound), errors.Is(err, store.ErrMessageNotFound): + return "not_found" + case errors.Is(err, store.ErrInvalidInput): + return "invalid_input" + case errors.Is(err, store.ErrInvalidState), errors.Is(err, store.ErrNoActiveLease): + return "invalid_state" + default: + return "internal_error" + } +} + +func renderError(stdout, stderr io.Writer, jsonOutput bool, err error) { + message := errorMessage(err) + if jsonOutput { + _ = protocol.WriteJSON(stdout, protocol.Error{ + OK: false, + Error: protocol.ErrorPayload{ + Code: errorCodeForError(err), + Message: message, + }, + }) + return + } + + _, _ = fmt.Fprintln(stderr, message) +} + +func errorMessage(err error) string { + var cliErr *protocol.CLIError + if errors.As(err, &cliErr) { + return cliErr.Message + } + return err.Error() +} + +func hasJSONFlag(args []string) bool { + for _, arg := range args { + if arg == "--json" { + return true + } + if strings.HasPrefix(arg, "--json=") { + return !strings.HasSuffix(arg, "=false") + } + } + return false +} + +func isUsageError(err error) bool { + message := err.Error() + return strings.HasPrefix(message, "required flag(s)") || + strings.HasPrefix(message, "unknown flag:") || + strings.HasPrefix(message, "unknown command ") || + strings.Contains(message, " accepts ") || + strings.Contains(message, "invalid argument ") +} diff --git a/packages/orch-runtime/internal/cli/orch/git_test_helpers_test.go b/packages/orch-runtime/internal/cli/orch/git_test_helpers_test.go new file mode 100644 index 0000000..f8a6193 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/git_test_helpers_test.go @@ -0,0 +1,53 @@ +package orch + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func initGitRepo(t *testing.T) string { + t.Helper() + + repoPath := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoPath, 0o755); err != nil { + t.Fatalf("mkdir repo path: %v", err) + } + + runGitCommand(t, repoPath, "init") + runGitCommand(t, repoPath, "config", "user.email", "test@example.com") + runGitCommand(t, repoPath, "config", "user.name", "Test User") + + readmePath := filepath.Join(repoPath, "README.md") + if err := os.WriteFile(readmePath, []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README.md: %v", err) + } + + runGitCommand(t, repoPath, "add", "README.md") + runGitCommand(t, repoPath, "commit", "-m", "init") + + return repoPath +} + +func gitHeadCommit(t *testing.T, repoPath string) string { + t.Helper() + + cmd := exec.Command("git", "-C", repoPath, "rev-parse", "--verify", "HEAD^{commit}") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git rev-parse HEAD in %s: %v\n%s", repoPath, err, output) + } + return strings.TrimSpace(string(output)) +} + +func runGitCommand(t *testing.T, repoPath string, args ...string) { + t.Helper() + + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s: %v\n%s", args, repoPath, err, output) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/integration_test.go b/packages/orch-runtime/internal/cli/orch/integration_test.go new file mode 100644 index 0000000..37ecf03 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/integration_test.go @@ -0,0 +1,2164 @@ +package orch + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestOrchRunDispatchReconcileLifecycle(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_001", + "--goal", "Build blog MVP", + "--summary", "Public blog plus admin CRUD", + ) + + taskOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_001", + "--task", "T1", + "--title", "Implement retry policy", + "--summary", "Add retry policy to HTTP client", + "--default-to", "worker-a", + ) + + var taskResp map[string]any + mustDecodeJSON(t, taskOut, &taskResp) + if got := nestedString(t, taskResp, "data", "task", "status"); got != "ready" { + t.Fatalf("expected new task to become ready, got %q", got) + } + + readyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_001", + ) + + var readyResp map[string]any + mustDecodeJSON(t, readyOut, &readyResp) + readyTasks := nestedArray(t, readyResp, "data", "tasks") + if len(readyTasks) != 1 { + t.Fatalf("expected one ready task, got %#v", readyTasks) + } + readyTask, ok := readyTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected ready task object, got %#v", readyTasks[0]) + } + if taskID, _ := readyTask["task_id"].(string); taskID != "T1" { + t.Fatalf("expected ready task T1, got %#v", readyTask["task_id"]) + } + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_001", + "--task", "T1", + "--body", "Implement retry handling for the HTTP client.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + if got := nestedString(t, dispatchResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected dispatched task, got %q", got) + } + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Implementation started", + ) + + reconcileOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_001", + ) + + var reconcileResp map[string]any + mustDecodeJSON(t, reconcileOut, &reconcileResp) + updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks") + if len(updatedTasks) != 1 { + t.Fatalf("expected one updated task after running reconcile, got %#v", updatedTasks) + } + runningTask, ok := updatedTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected updated task object, got %#v", updatedTasks[0]) + } + if status, _ := runningTask["status"].(string); status != "running" { + t.Fatalf("expected running task after reconcile, got %#v", runningTask["status"]) + } + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Retry policy implemented", + "--body", "The HTTP client now retries transient failures.", + ) + + reconcileDoneOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_001", + ) + + var reconcileDoneResp map[string]any + mustDecodeJSON(t, reconcileDoneOut, &reconcileDoneResp) + updatedTasks = nestedArray(t, reconcileDoneResp, "data", "updated_tasks") + if len(updatedTasks) != 1 { + t.Fatalf("expected one updated task after done reconcile, got %#v", updatedTasks) + } + doneTask, ok := updatedTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected updated task object, got %#v", updatedTasks[0]) + } + if status, _ := doneTask["status"].(string); status != "done" { + t.Fatalf("expected done task after reconcile, got %#v", doneTask["status"]) + } + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" { + t.Fatalf("expected run status done, got %q", got) + } + tasks := nestedArray(t, statusResp, "data", "tasks") + if len(tasks) != 1 { + t.Fatalf("expected one task in status response, got %#v", tasks) + } + task, ok := tasks[0].(map[string]any) + if !ok { + t.Fatalf("expected task object, got %#v", tasks[0]) + } + if got, _ := task["status"].(string); got != "done" { + t.Fatalf("expected status task done, got %#v", task["status"]) + } +} + +func TestOrchDependencyBlockedAndAnswerFlow(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_002", + "--goal", "Build dependency-aware workflow", + ) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_002", + "--task", "T1", + "--title", "Build backend", + "--summary", "Implement backend APIs", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_002", + "--task", "T2", + "--title", "Build frontend", + "--summary", "Implement frontend flows", + "--default-to", "worker-b", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_002", + "--task", "T2", + "--depends-on", "T1", + ) + + readyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_002", + ) + + var readyResp map[string]any + mustDecodeJSON(t, readyOut, &readyResp) + readyTasks := nestedArray(t, readyResp, "data", "tasks") + if len(readyTasks) != 1 { + t.Fatalf("expected only dependency-free task ready, got %#v", readyTasks) + } + readyTask, ok := readyTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected ready task object, got %#v", readyTasks[0]) + } + if taskID, _ := readyTask["task_id"].(string); taskID != "T1" { + t.Fatalf("expected T1 ready before dependency clears, got %#v", readyTask["task_id"]) + } + + dispatchBackendOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_002", + "--task", "T1", + ) + + var dispatchBackendResp map[string]any + mustDecodeJSON(t, dispatchBackendOut, &dispatchBackendResp) + threadBackend := nestedString(t, dispatchBackendResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadBackend, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadBackend, + "--summary", "Backend complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_002", + ) + + readyAfterDoneOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "ready", + "--run", "run_blog_002", + ) + + var readyAfterDoneResp map[string]any + mustDecodeJSON(t, readyAfterDoneOut, &readyAfterDoneResp) + readyTasks = nestedArray(t, readyAfterDoneResp, "data", "tasks") + if len(readyTasks) != 1 { + t.Fatalf("expected dependent task to become ready, got %#v", readyTasks) + } + readyTask, ok = readyTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected ready task object, got %#v", readyTasks[0]) + } + if taskID, _ := readyTask["task_id"].(string); taskID != "T2" { + t.Fatalf("expected T2 ready after T1 completion, got %#v", readyTask["task_id"]) + } + + dispatchFrontendOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_002", + "--task", "T2", + ) + + var dispatchFrontendResp map[string]any + mustDecodeJSON(t, dispatchFrontendOut, &dispatchFrontendResp) + threadFrontend := nestedString(t, dispatchFrontendResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-b", + "--thread", threadFrontend, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-b", + "--thread", threadFrontend, + "--status", "blocked", + "--summary", "Need logging decision", + "--payload-json", `{"question":"stdout or stderr?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_002", + ) + + blockedOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "blocked", + "--run", "run_blog_002", + ) + + var blockedResp map[string]any + mustDecodeJSON(t, blockedOut, &blockedResp) + blockedTasks := nestedArray(t, blockedResp, "data", "blocked") + if len(blockedTasks) != 1 { + t.Fatalf("expected one blocked task, got %#v", blockedTasks) + } + blockedTask, ok := blockedTasks[0].(map[string]any) + if !ok { + t.Fatalf("expected blocked task object, got %#v", blockedTasks[0]) + } + question, ok := blockedTask["question"].(map[string]any) + if !ok { + t.Fatalf("expected blocked question object, got %#v", blockedTask["question"]) + } + if kind, _ := question["kind"].(string); kind != "question" { + t.Fatalf("expected blocked question kind, got %#v", question["kind"]) + } + + answerOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "answer", + "--run", "run_blog_002", + "--task", "T2", + "--body", "Use stdout for MVP.", + ) + + var answerResp map[string]any + mustDecodeJSON(t, answerOut, &answerResp) + if got := nestedString(t, answerResp, "data", "message", "kind"); got != "answer" { + t.Fatalf("expected answer message kind, got %q", got) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadFrontend, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + messages := nestedArray(t, showResp, "data", "messages") + if len(messages) < 4 { + t.Fatalf("expected answer to append a message, got %#v", messages) + } + lastMessage, ok := messages[len(messages)-1].(map[string]any) + if !ok { + t.Fatalf("expected last message object, got %#v", messages[len(messages)-1]) + } + if kind, _ := lastMessage["kind"].(string); kind != "answer" { + t.Fatalf("expected latest message to be answer, got %#v", lastMessage["kind"]) + } + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-b", + "--thread", threadFrontend, + "--status", "in_progress", + "--summary", "Decision applied", + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-b", + "--thread", threadFrontend, + "--summary", "Frontend complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_002", + ) + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_002", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" { + t.Fatalf("expected run status done after both tasks, got %q", got) + } +} + +func TestOrchDispatchRejectsNonReadyTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_003", + "--goal", "Validate ready gating", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_003", + "--task", "T1", + "--title", "Backend", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_003", + "--task", "T2", + "--title", "Frontend", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "dep", "add", + "--run", "run_blog_003", + "--task", "T2", + "--depends-on", "T1", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_003", + "--task", "T2", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") +} + +func TestOrchDispatchCreatesStrictWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_worktree_001", + "--goal", "Validate strict worktree dispatch", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_worktree_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_worktree_001", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + "--body", "Implement inside isolated worktree.", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + attempt, ok := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any) + if !ok { + t.Fatalf("expected attempt object, got %#v", nestedValue(t, dispatchResp, "data", "attempt")) + } + + if got, _ := attempt["base_ref"].(string); got != "HEAD" { + t.Fatalf("expected base_ref HEAD, got %#v", attempt["base_ref"]) + } + expectedCommit := gitHeadCommit(t, repoPath) + if got, _ := attempt["base_commit"].(string); got != expectedCommit { + t.Fatalf("expected base_commit %q, got %#v", expectedCommit, attempt["base_commit"]) + } + if got, _ := attempt["branch_name"].(string); got != "orch/run-blog-worktree-001/T1/attempt-1" { + t.Fatalf("unexpected branch name %#v", attempt["branch_name"]) + } + + worktreePath, _ := attempt["worktree_path"].(string) + if worktreePath == "" { + t.Fatalf("expected worktree_path, got %#v", attempt["worktree_path"]) + } + if got, _ := attempt["workspace_status"].(string); got != "created" { + t.Fatalf("expected workspace_status created, got %#v", attempt["workspace_status"]) + } + + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("stat worktree path %s: %v", worktreePath, err) + } + if _, err := os.Stat(filepath.Join(worktreePath, "README.md")); err != nil { + t.Fatalf("expected README.md in worktree: %v", err) + } + + message, ok := nestedValue(t, dispatchResp, "data", "message").(map[string]any) + if !ok { + t.Fatalf("expected message object, got %#v", nestedValue(t, dispatchResp, "data", "message")) + } + payload, ok := message["payload_json"].(map[string]any) + if !ok { + t.Fatalf("expected payload_json object, got %#v", message["payload_json"]) + } + if got, _ := payload["worktree_path"].(string); got != worktreePath { + t.Fatalf("expected payload worktree path %q, got %#v", worktreePath, payload["worktree_path"]) + } + + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "in_progress", + "--summary", "Started inside worktree", + ) + + reconcileOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_worktree_001", + ) + + var reconcileResp map[string]any + mustDecodeJSON(t, reconcileOut, &reconcileResp) + updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks") + if len(updatedTasks) != 1 { + t.Fatalf("expected one updated task after worktree reconcile, got %#v", updatedTasks) + } +} + +func TestOrchStrictWorktreeRejectsDirtyRepoWithoutBaseRef(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + if err := os.WriteFile(filepath.Join(repoPath, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_worktree_002", + "--goal", "Validate dirty repo rejection", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_worktree_002", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + stdout, _, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_worktree_002", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + ) + if exitCode != 30 { + t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout) + } + assertErrorJSON(t, stdout, "invalid_state") + + if _, err := os.Stat(filepath.Join(repoPath, ".orch", "worktrees", "run_blog_worktree_002", "T1", "attempt-1")); !os.IsNotExist(err) { + t.Fatalf("expected no worktree directory on strict failure, got err=%v", err) + } +} + +func TestOrchStrictWorktreeAllowsExplicitBaseRefOnDirtyRepo(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + baseCommit := gitHeadCommit(t, repoPath) + + if err := os.WriteFile(filepath.Join(repoPath, "dirty.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_worktree_003", + "--goal", "Validate explicit base ref on dirty repo", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_worktree_003", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_worktree_003", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + "--base-ref", "HEAD", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + if got := nestedString(t, dispatchResp, "data", "attempt", "base_ref"); got != "HEAD" { + t.Fatalf("expected explicit base_ref HEAD, got %q", got) + } + if got := nestedString(t, dispatchResp, "data", "attempt", "base_commit"); got != baseCommit { + t.Fatalf("expected base_commit %q, got %q", baseCommit, got) + } +} + +func TestOrchDispatchAutoEnablesWorktreeForCodeLikeTask(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_auto_worktree_001", + "--goal", "Validate auto worktree detection", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_auto_worktree_001", + "--task", "T1", + "--title", "Implement backend API", + "--default-to", "backend-worker", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_auto_worktree_001", + "--task", "T1", + "--repo-path", repoPath, + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + attempt := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any) + + worktreePath, _ := attempt["worktree_path"].(string) + if worktreePath == "" { + t.Fatalf("expected auto-detected code task to allocate a worktree, got %#v", attempt) + } + if got, _ := attempt["workspace_status"].(string); got != "created" { + t.Fatalf("expected created workspace status, got %#v", attempt["workspace_status"]) + } + if _, err := os.Stat(worktreePath); err != nil { + t.Fatalf("stat auto worktree path %s: %v", worktreePath, err) + } +} + +func TestOrchDispatchDoesNotAutoEnableWorktreeForNonCodeTask(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_auto_worktree_002", + "--goal", "Validate non-code dispatch fallback", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_auto_worktree_002", + "--task", "T1", + "--title", "Review QA findings", + "--summary", "Summarize test failures and next steps", + "--default-to", "qa-worker", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_auto_worktree_002", + "--task", "T1", + "--repo-path", repoPath, + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + attempt := nestedValue(t, dispatchResp, "data", "attempt").(map[string]any) + if got, _ := attempt["worktree_path"].(string); got != "" { + t.Fatalf("expected non-code task to stay on non-worktree path, got %#v", attempt["worktree_path"]) + } + if got, _ := attempt["workspace_status"].(string); got != "" { + t.Fatalf("expected no workspace status for non-code task, got %#v", attempt["workspace_status"]) + } +} + +func TestOrchWaitWakesOnBlockedEvent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_wait_001", + "--goal", "Validate wait wake behavior", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_wait_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_wait_001", + "--task", "T1", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + type waitResult struct { + stdout string + stderr string + exitCode int + } + resultCh := make(chan waitResult, 1) + go func() { + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "wait", + "--run", "run_blog_wait_001", + "--for", "task_blocked", + "--after-event", "0", + "--timeout-seconds", "2", + ) + resultCh <- waitResult{stdout: stdout, stderr: stderr, exitCode: exitCode} + }() + + time.Sleep(200 * time.Millisecond) + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommandEventually( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", threadID, + "--status", "blocked", + "--summary", "Need logging decision", + "--payload-json", `{"question":"stdout or stderr?"}`, + ) + + select { + case result := <-resultCh: + if result.exitCode != 0 { + t.Fatalf("wait exited with %d\nstderr:\n%s\nstdout:\n%s", result.exitCode, result.stderr, result.stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, result.stdout, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke { + t.Fatalf("expected wait to wake, got %#v", waitResp) + } + events := nestedArray(t, waitResp, "data", "events") + if len(events) != 1 { + t.Fatalf("expected one wait event, got %#v", events) + } + event, ok := events[0].(map[string]any) + if !ok { + t.Fatalf("expected wait event object, got %#v", events[0]) + } + if got, _ := event["type"].(string); got != "task_blocked" { + t.Fatalf("expected task_blocked event, got %#v", event["type"]) + } + if got, _ := event["summary"].(string); got != "Need logging decision" { + t.Fatalf("expected blocked summary to surface question summary, got %#v", event["summary"]) + } + payload, ok := event["payload"].(map[string]any) + if !ok { + t.Fatalf("expected event payload object, got %#v", event["payload"]) + } + if got, _ := payload["question"].(string); got != "stdout or stderr?" { + t.Fatalf("expected question payload, got %#v", payload["question"]) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for orch wait result") + } +} + +func TestOrchWaitTimesOutWithoutMatchingEvent(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_wait_002", + "--goal", "Validate wait timeout behavior", + ) + + stdout, stderr, exitCode := executeOrchCommand( + "--db", dbPath, + "--json", + "wait", + "--run", "run_blog_wait_002", + "--for", "task_done", + "--after-event", "0", + "--timeout-seconds", "1", + ) + if exitCode != 0 { + t.Fatalf("wait exited with %d\nstderr:\n%s\nstdout:\n%s", exitCode, stderr, stdout) + } + + var waitResp map[string]any + mustDecodeJSON(t, stdout, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); woke { + t.Fatalf("expected wait timeout, got %#v", waitResp) + } + if nextEventID, _ := nestedValue(t, waitResp, "data", "next_event_id").(float64); nextEventID != 0 { + t.Fatalf("expected next_event_id 0 on timeout, got %#v", nextEventID) + } +} + +func TestOrchRetryCreatesNewAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_retry_001", + "--goal", "Validate retry behavior", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_retry_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_retry_001", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + firstWorktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "fail", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Build failed", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_retry_001", + ) + + retryOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "retry", + "--run", "run_blog_retry_001", + "--task", "T1", + "--body", "Retry after fixing the failure.", + ) + + var retryResp map[string]any + mustDecodeJSON(t, retryOut, &retryResp) + if got := nestedString(t, retryResp, "data", "task", "status"); got != "dispatched" { + t.Fatalf("expected retried task to be dispatched, got %q", got) + } + if got := nestedValue(t, retryResp, "data", "attempt", "attempt_no").(float64); got != 2 { + t.Fatalf("expected retry attempt 2, got %#v", got) + } + secondThreadID := nestedString(t, retryResp, "data", "attempt", "thread_id") + if secondThreadID == threadID { + t.Fatalf("expected retry to create a new thread, got same thread %q", secondThreadID) + } + secondWorktreePath := nestedString(t, retryResp, "data", "attempt", "worktree_path") + if secondWorktreePath == firstWorktreePath { + t.Fatalf("expected retry to create a new worktree, got reused path %q", secondWorktreePath) + } + if _, err := os.Stat(secondWorktreePath); err != nil { + t.Fatalf("stat retry worktree %s: %v", secondWorktreePath, err) + } +} + +func TestOrchReassignCancelsOldThreadAndDispatchesNewAttempt(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_reassign_001", + "--goal", "Validate reassign behavior", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_reassign_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_reassign_001", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + originalThreadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", originalThreadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "update", + "--agent", "worker-a", + "--thread", originalThreadID, + "--status", "blocked", + "--summary", "Need product decision", + "--payload-json", `{"question":"Proceed with v1 scope?"}`, + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_reassign_001", + ) + + reassignOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "reassign", + "--run", "run_blog_reassign_001", + "--task", "T1", + "--to", "worker-b", + "--reason", "Try another worker with clearer ownership.", + ) + + var reassignResp map[string]any + mustDecodeJSON(t, reassignOut, &reassignResp) + if got := nestedString(t, reassignResp, "data", "attempt", "assigned_to"); got != "worker-b" { + t.Fatalf("expected reassigned attempt to target worker-b, got %q", got) + } + if got := nestedValue(t, reassignResp, "data", "attempt", "attempt_no").(float64); got != 2 { + t.Fatalf("expected reassign attempt 2, got %#v", got) + } + newThreadID := nestedString(t, reassignResp, "data", "attempt", "thread_id") + if newThreadID == originalThreadID { + t.Fatalf("expected reassignment to create a new thread, got %q", newThreadID) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", originalThreadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "thread", "status"); got != "cancelled" { + t.Fatalf("expected old reassigned thread to be cancelled, got %q", got) + } +} + +func TestOrchCancelTaskAndRun(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cancel_001", + "--goal", "Validate cancel behavior", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_cancel_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_cancel_001", + "--task", "T2", + "--title", "Implement frontend", + "--default-to", "worker-b", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_cancel_001", + "--task", "T1", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--run", "run_blog_cancel_001", + "--task", "T1", + "--reason", "Task is no longer needed.", + ) + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_cancel_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + tasks := nestedArray(t, statusResp, "data", "tasks") + taskStatuses := map[string]string{} + for _, item := range tasks { + task, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected task object, got %#v", item) + } + taskStatuses[task["task_id"].(string)] = task["status"].(string) + } + if taskStatuses["T1"] != "cancelled" { + t.Fatalf("expected T1 cancelled, got %q", taskStatuses["T1"]) + } + if taskStatuses["T2"] == "cancelled" { + t.Fatalf("expected T2 to remain active before run cancel, got %q", taskStatuses["T2"]) + } + + showOut := runInboxCommand( + t, + "--db", dbPath, + "--json", + "show", + "--thread", threadID, + ) + + var showResp map[string]any + mustDecodeJSON(t, showOut, &showResp) + if got := nestedString(t, showResp, "data", "thread", "status"); got != "cancelled" { + t.Fatalf("expected cancelled task thread to be cancelled, got %q", got) + } + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "cancel", + "--run", "run_blog_cancel_001", + "--reason", "Stop the run.", + ) + + statusOut = runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "run_blog_cancel_001", + ) + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "cancelled" { + t.Fatalf("expected cancelled run, got %q", got) + } + tasks = nestedArray(t, statusResp, "data", "tasks") + for _, item := range tasks { + task, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected task object, got %#v", item) + } + if got, _ := task["status"].(string); got != "cancelled" { + t.Fatalf("expected all tasks cancelled after run cancel, got %#v", task["status"]) + } + } +} + +func TestOrchCleanupRemovesCompletedWorktree(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + repoPath := initGitRepo(t) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "run", "init", + "--run", "run_blog_cleanup_001", + "--goal", "Validate cleanup behavior", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "task", "add", + "--run", "run_blog_cleanup_001", + "--task", "T1", + "--title", "Implement backend", + "--default-to", "worker-a", + ) + + dispatchOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "dispatch", + "--run", "run_blog_cleanup_001", + "--task", "T1", + "--repo-path", repoPath, + "--workspace-root", ".orch/worktrees", + "--strict-worktree", + ) + + var dispatchResp map[string]any + mustDecodeJSON(t, dispatchOut, &dispatchResp) + threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id") + worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path") + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", "worker-a", + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", "worker-a", + "--thread", threadID, + "--summary", "Backend complete", + ) + runOrchCommand( + t, + "--db", dbPath, + "--json", + "reconcile", + "--run", "run_blog_cleanup_001", + ) + + cleanupOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "cleanup", + "--run", "run_blog_cleanup_001", + "--task", "T1", + ) + + var cleanupResp map[string]any + mustDecodeJSON(t, cleanupOut, &cleanupResp) + cleaned := nestedArray(t, cleanupResp, "data", "cleaned") + if len(cleaned) != 1 { + t.Fatalf("expected one cleaned attempt, got %#v", cleaned) + } + if _, err := os.Stat(worktreePath); !os.IsNotExist(err) { + t.Fatalf("expected cleaned worktree path to be removed, err=%v", err) + } +} + +func TestOrchCouncilStartDispatchesThreeReviewers(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + startOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_001", + "--target", "Review the current blog architecture and propose optimizations.", + "--target-type", "mixed", + "--output", "both", + ) + + var startResp map[string]any + mustDecodeJSON(t, startOut, &startResp) + if got := nestedString(t, startResp, "data", "run_id"); got != "council_blog_001" { + t.Fatalf("expected council run id, got %q", got) + } + if got := nestedString(t, startResp, "data", "mode"); got != "brainstorm" { + t.Fatalf("expected default council mode brainstorm, got %q", got) + } + + reviewers := nestedArray(t, startResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three council reviewers, got %#v", reviewers) + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var ( + mode string + targetType string + outputMode string + onlyUnanimous int + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT mode, target_type, output_mode, only_unanimous + FROM council_runs + WHERE run_id = ?`, + "council_blog_001", + ).Scan(&mode, &targetType, &outputMode, &onlyUnanimous); err != nil { + t.Fatalf("query council_runs: %v", err) + } + if mode != "brainstorm" || targetType != "mixed" || outputMode != "both" || onlyUnanimous != 0 { + t.Fatalf("unexpected council run metadata: mode=%q targetType=%q outputMode=%q onlyUnanimous=%d", mode, targetType, outputMode, onlyUnanimous) + } + + var ( + prompt string + targetFile string + repoPath string + targetTaskID string + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT prompt, target_file, repo_path, target_task_id + FROM council_inputs + WHERE run_id = ?`, + "council_blog_001", + ).Scan(&prompt, &targetFile, &repoPath, &targetTaskID); err != nil { + t.Fatalf("query council_inputs: %v", err) + } + if prompt == "" || targetFile != "" || repoPath != "" || targetTaskID != "" { + t.Fatalf("unexpected council input row: prompt=%q targetFile=%q repoPath=%q targetTaskID=%q", prompt, targetFile, repoPath, targetTaskID) + } + + rows, err := sqlDB.QueryContext( + t.Context(), + `SELECT reviewer_role, task_id, status + FROM council_reviewers + WHERE run_id = ? + ORDER BY reviewer_role ASC`, + "council_blog_001", + ) + if err != nil { + t.Fatalf("query council_reviewers: %v", err) + } + defer rows.Close() + + var reviewerRows int + for rows.Next() { + var ( + reviewerRole string + taskID string + status string + ) + if err := rows.Scan(&reviewerRole, &taskID, &status); err != nil { + t.Fatalf("scan council reviewer row: %v", err) + } + reviewerRows++ + if status != "dispatched" { + t.Fatalf("expected council reviewer status dispatched, got %q for %s", status, reviewerRole) + } + + var worktreePath sql.NullString + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT a.worktree_path + FROM task_attempts a + WHERE a.run_id = ? AND a.task_id = ? AND a.attempt_no = 1`, + "council_blog_001", + taskID, + ).Scan(&worktreePath); err != nil { + t.Fatalf("query council attempt worktree path for %s: %v", taskID, err) + } + if worktreePath.Valid && worktreePath.String != "" { + t.Fatalf("expected council reviewer task %s to avoid worktree allocation, got %q", taskID, worktreePath.String) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate council reviewers: %v", err) + } + if reviewerRows != 3 { + t.Fatalf("expected three stored council reviewers, got %d", reviewerRows) + } + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "council_blog_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "running" { + t.Fatalf("expected council run status running, got %q", got) + } + tasks := nestedArray(t, statusResp, "data", "tasks") + if len(tasks) != 3 { + t.Fatalf("expected three council tasks, got %#v", tasks) + } +} + +func TestOrchCouncilWaitWakesWhenAllReviewersComplete(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + startOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_wait_001", + "--target", "Review the current blog architecture.", + ) + + var startResp map[string]any + mustDecodeJSON(t, startOut, &startResp) + reviewers := nestedArray(t, startResp, "data", "reviewers") + + for _, item := range reviewers { + reviewer, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected reviewer object, got %#v", item) + } + taskID, _ := reviewer["task_id"].(string) + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "council_blog_wait_001", + ) + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + tasks := nestedArray(t, statusResp, "data", "tasks") + + var threadID string + for _, taskItem := range tasks { + task, ok := taskItem.(map[string]any) + if !ok { + t.Fatalf("expected task object, got %#v", taskItem) + } + if task["task_id"] == taskID { + taskStatus := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "council_blog_wait_001", + ) + var taskStatusResp map[string]any + mustDecodeJSON(t, taskStatus, &taskStatusResp) + statusTasks := nestedArray(t, taskStatusResp, "data", "tasks") + for _, statusTaskItem := range statusTasks { + statusTask, ok := statusTaskItem.(map[string]any) + if !ok { + t.Fatalf("expected status task object, got %#v", statusTaskItem) + } + if statusTask["task_id"] == taskID { + break + } + } + } + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT thread_id + FROM task_attempts + WHERE run_id = ? AND task_id = ? AND attempt_no = 1`, + "council_blog_wait_001", + taskID, + ).Scan(&threadID); err != nil { + sqlDB.Close() + t.Fatalf("query council reviewer thread id: %v", err) + } + sqlDB.Close() + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", reviewer["reviewer_role"].(string), + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", reviewer["reviewer_role"].(string), + "--thread", threadID, + "--summary", "Review complete", + ) + } + + waitOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "wait", + "--run", "council_blog_wait_001", + "--timeout-seconds", "2", + ) + + var waitResp map[string]any + mustDecodeJSON(t, waitOut, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); !woke { + t.Fatalf("expected council wait to wake, got %#v", waitResp) + } + if allComplete, _ := nestedValue(t, waitResp, "data", "all_complete").(bool); !allComplete { + t.Fatalf("expected all reviewers complete, got %#v", waitResp) + } + reviewers = nestedArray(t, waitResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three council reviewer statuses, got %#v", reviewers) + } + for _, item := range reviewers { + reviewer, ok := item.(map[string]any) + if !ok { + t.Fatalf("expected reviewer object, got %#v", item) + } + if got, _ := reviewer["status"].(string); got != "done" { + t.Fatalf("expected done reviewer status, got %#v", reviewer["status"]) + } + } +} + +func TestOrchCouncilWaitTimesOutWhenReviewersIncomplete(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_wait_002", + "--target", "Review the current blog architecture.", + ) + + waitOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "wait", + "--run", "council_blog_wait_002", + "--timeout-seconds", "1", + ) + + var waitResp map[string]any + mustDecodeJSON(t, waitOut, &waitResp) + if woke, _ := nestedValue(t, waitResp, "data", "woke").(bool); woke { + t.Fatalf("expected council wait timeout, got %#v", waitResp) + } + if allComplete, _ := nestedValue(t, waitResp, "data", "all_complete").(bool); allComplete { + t.Fatalf("expected incomplete reviewer set on timeout, got %#v", waitResp) + } + reviewers := nestedArray(t, waitResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three reviewer statuses on timeout, got %#v", reviewers) + } +} + +func TestOrchCouncilTallyGroupsReviewerFindingsNormal(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_tally_001", + "--target", "Review the current blog architecture.", + ) + + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_001", + "architecture-reviewer", + `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture","coupling"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_001", + "implementation-reviewer", + `{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract API contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"medium","tags":["maintainability"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_001", + "risk-reviewer", + `{"reviewer_role":"risk-reviewer","findings":[{"title":"Add auth integration tests","summary":"Login regressions are hard to catch.","proposal":"Add integration tests for auth flows.","rationale":"This catches regressions earlier.","confidence":"high","tags":["risk","testing"],"target_refs":{"repo_path":"."}}]}`, + ) + + tallyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "tally", + "--run", "council_blog_tally_001", + "--similarity", "normal", + ) + + var tallyResp map[string]any + mustDecodeJSON(t, tallyOut, &tallyResp) + if got := nestedString(t, tallyResp, "data", "similarity"); got != "normal" { + t.Fatalf("expected normal similarity, got %q", got) + } + counts, ok := nestedValue(t, tallyResp, "data", "counts").(map[string]any) + if !ok { + t.Fatalf("expected counts object, got %#v", nestedValue(t, tallyResp, "data", "counts")) + } + if got, _ := counts["majority"].(float64); got != 1 { + t.Fatalf("expected one majority group, got %#v", counts["majority"]) + } + if got, _ := counts["minority"].(float64); got != 1 { + t.Fatalf("expected one minority group, got %#v", counts["minority"]) + } + + groups := nestedArray(t, tallyResp, "data", "grouped_recommendations") + if len(groups) != 2 { + t.Fatalf("expected two grouped recommendations, got %#v", groups) + } + firstGroup, ok := groups[0].(map[string]any) + if !ok { + t.Fatalf("expected group object, got %#v", groups[0]) + } + if got, _ := firstGroup["bucket"].(string); got != "majority" { + t.Fatalf("expected first group majority, got %#v", firstGroup["bucket"]) + } + if got, _ := firstGroup["support_count"].(float64); got != 2 { + t.Fatalf("expected support_count 2, got %#v", firstGroup["support_count"]) + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var findingsCount int + if err := sqlDB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM council_findings WHERE run_id = ?`, "council_blog_tally_001").Scan(&findingsCount); err != nil { + t.Fatalf("count council_findings: %v", err) + } + if findingsCount != 3 { + t.Fatalf("expected 3 council findings, got %d", findingsCount) + } + var groupsCount int + if err := sqlDB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM council_groups WHERE run_id = ?`, "council_blog_tally_001").Scan(&groupsCount); err != nil { + t.Fatalf("count council_groups: %v", err) + } + if groupsCount != 2 { + t.Fatalf("expected 2 council groups, got %d", groupsCount) + } +} + +func TestOrchCouncilTallyStrictKeepsDistinctProposals(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_tally_002", + "--target", "Review the current blog architecture.", + ) + + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_002", + "architecture-reviewer", + `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_002", + "implementation-reviewer", + `{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract API contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"medium","tags":["maintainability"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + "council_blog_tally_002", + "risk-reviewer", + `{"reviewer_role":"risk-reviewer","findings":[{"title":"Add auth integration tests","summary":"Login regressions are hard to catch.","proposal":"Add integration tests for auth flows.","rationale":"This catches regressions earlier.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}}]}`, + ) + + tallyOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "tally", + "--run", "council_blog_tally_002", + "--similarity", "strict", + ) + + var tallyResp map[string]any + mustDecodeJSON(t, tallyOut, &tallyResp) + counts, ok := nestedValue(t, tallyResp, "data", "counts").(map[string]any) + if !ok { + t.Fatalf("expected counts object, got %#v", nestedValue(t, tallyResp, "data", "counts")) + } + if got, _ := counts["minority"].(float64); got != 3 { + t.Fatalf("expected three minority groups in strict mode, got %#v", counts["minority"]) + } + groups := nestedArray(t, tallyResp, "data", "grouped_recommendations") + if len(groups) != 3 { + t.Fatalf("expected three distinct groups in strict mode, got %#v", groups) + } +} + +func TestOrchCouncilReportDefaultShowsConsensusAndMajority(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_001" + seedCouncilReportRun(t, dbPath, runID) + + reportOut := runOrchCommand( + t, + "--db", dbPath, + "council", "report", + "--run", runID, + ) + + if !strings.Contains(reportOut, "# Council Review Report") { + t.Fatalf("expected markdown report header, got %q", reportOut) + } + if !strings.Contains(reportOut, "## Consensus") { + t.Fatalf("expected consensus section, got %q", reportOut) + } + if !strings.Contains(reportOut, "## Majority") { + t.Fatalf("expected majority section, got %q", reportOut) + } + if strings.Contains(reportOut, "## Minority") { + t.Fatalf("did not expect minority section in default report, got %q", reportOut) + } + + reportPath := councilReportArtifactPath(dbPath, runID) + reportBytes, err := os.ReadFile(reportPath) + if err != nil { + t.Fatalf("read council report artifact: %v", err) + } + if string(reportBytes) != reportOut { + t.Fatalf("expected stdout markdown to match artifact contents") + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var showJSON string + var summaryJSON string + var markdownPath string + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT show_json, summary_json, markdown_path + FROM council_reports + WHERE run_id = ?`, + runID, + ).Scan(&showJSON, &summaryJSON, &markdownPath); err != nil { + t.Fatalf("query council report metadata: %v", err) + } + if markdownPath != reportPath { + t.Fatalf("expected report path %q, got %q", reportPath, markdownPath) + } + + var show []string + mustDecodeJSON(t, showJSON, &show) + if len(show) != 2 || show[0] != "consensus" || show[1] != "majority" { + t.Fatalf("expected default show buckets [consensus majority], got %#v", show) + } + + var summary map[string]any + mustDecodeJSON(t, summaryJSON, &summary) + if got, _ := summary["consensus"].(float64); got != 1 { + t.Fatalf("expected one consensus group, got %#v", summary["consensus"]) + } + if got, _ := summary["majority"].(float64); got != 1 { + t.Fatalf("expected one majority group, got %#v", summary["majority"]) + } + if got, _ := summary["minority"].(float64); got != 1 { + t.Fatalf("expected one minority group, got %#v", summary["minority"]) + } +} + +func TestOrchCouncilReportShowAllIncludesMinority(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_002" + seedCouncilReportRun(t, dbPath, runID) + + reportOut := runOrchCommand( + t, + "--db", dbPath, + "council", "report", + "--run", runID, + "--show", "all", + ) + + if !strings.Contains(reportOut, "## Consensus") { + t.Fatalf("expected consensus section, got %q", reportOut) + } + if !strings.Contains(reportOut, "## Majority") { + t.Fatalf("expected majority section, got %q", reportOut) + } + if !strings.Contains(reportOut, "## Minority") { + t.Fatalf("expected minority section when --show all is used, got %q", reportOut) + } + if !strings.Contains(reportOut, "Add regression tests for council report JSON output.") { + t.Fatalf("expected minority proposal in report output, got %q", reportOut) + } +} + +func TestOrchCouncilReportJSONShape(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + runID := "council_blog_report_003" + seedCouncilReportRun(t, dbPath, runID) + + reportOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "report", + "--run", runID, + ) + + var reportResp map[string]any + mustDecodeJSON(t, reportOut, &reportResp) + if ok, _ := reportResp["ok"].(bool); !ok { + t.Fatalf("expected ok=true, got %#v", reportResp) + } + if got, _ := reportResp["command"].(string); got != "council report" { + t.Fatalf("expected command council report, got %#v", reportResp["command"]) + } + if got := nestedString(t, reportResp, "data", "run_id"); got != runID { + t.Fatalf("expected run id %q, got %q", runID, got) + } + + show := nestedArray(t, reportResp, "data", "show") + if len(show) != 2 || show[0] != "consensus" || show[1] != "majority" { + t.Fatalf("expected default show buckets in JSON output, got %#v", show) + } + + summary, ok := nestedValue(t, reportResp, "data", "summary").(map[string]any) + if !ok { + t.Fatalf("expected summary object, got %#v", nestedValue(t, reportResp, "data", "summary")) + } + if got, _ := summary["consensus"].(float64); got != 1 { + t.Fatalf("expected one consensus group, got %#v", summary["consensus"]) + } + if got, _ := summary["majority"].(float64); got != 1 { + t.Fatalf("expected one majority group, got %#v", summary["majority"]) + } + if got, _ := summary["minority"].(float64); got != 1 { + t.Fatalf("expected one minority group, got %#v", summary["minority"]) + } + + artifacts := nestedArray(t, reportResp, "data", "report_artifacts") + if len(artifacts) != 1 { + t.Fatalf("expected one report artifact, got %#v", artifacts) + } + artifact, ok := artifacts[0].(map[string]any) + if !ok { + t.Fatalf("expected report artifact object, got %#v", artifacts[0]) + } + if got, _ := artifact["kind"].(string); got != "markdown" { + t.Fatalf("expected markdown artifact, got %#v", artifact["kind"]) + } + if got, _ := artifact["path"].(string); got != councilReportArtifactPath(dbPath, runID) { + t.Fatalf("expected report artifact path %q, got %#v", councilReportArtifactPath(dbPath, runID), artifact["path"]) + } + + groups := nestedArray(t, reportResp, "data", "grouped_recommendations") + if len(groups) != 2 { + t.Fatalf("expected two grouped recommendations in default JSON report, got %#v", groups) + } + firstGroup, ok := groups[0].(map[string]any) + if !ok { + t.Fatalf("expected first group object, got %#v", groups[0]) + } + if got, _ := firstGroup["bucket"].(string); got != "consensus" { + t.Fatalf("expected first reported group to be consensus, got %#v", firstGroup["bucket"]) + } +} + +func completeCouncilReviewer(t *testing.T, dbPath, runID, reviewerRole, bodyJSON string) { + t.Helper() + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + + var threadID string + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT a.thread_id + FROM council_reviewers cr + JOIN task_attempts a + ON a.run_id = cr.run_id + AND a.task_id = cr.task_id + AND a.attempt_no = 1 + WHERE cr.run_id = ? AND cr.reviewer_role = ?`, + runID, + reviewerRole, + ).Scan(&threadID); err != nil { + sqlDB.Close() + t.Fatalf("query council reviewer thread: %v", err) + } + sqlDB.Close() + + runInboxCommand( + t, + "--db", dbPath, + "--json", + "claim", + "--agent", reviewerRole, + "--thread", threadID, + ) + runInboxCommand( + t, + "--db", dbPath, + "--json", + "done", + "--agent", reviewerRole, + "--thread", threadID, + "--summary", "Review complete", + "--body", bodyJSON, + ) +} + +func seedCouncilReportRun(t *testing.T, dbPath, runID string) { + t.Helper() + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", runID, + "--target", "Review the council reporting flow.", + ) + + completeCouncilReviewer( + t, + dbPath, + runID, + "architecture-reviewer", + `{"reviewer_role":"architecture-reviewer","findings":[{"title":"Split contracts","summary":"Transport contracts are mixed into UI code.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This lowers coupling.","confidence":"high","tags":["architecture"],"target_refs":{"repo_path":"."}},{"title":"Share helpers","summary":"Council report rendering paths are repeated.","proposal":"Introduce shared council coordinator helpers for report rendering.","rationale":"This keeps report assembly consistent.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "implementation-reviewer", + `{"reviewer_role":"implementation-reviewer","findings":[{"title":"Extract contracts","summary":"Shared transport shapes are duplicated.","proposal":"Move API contract definitions into dedicated module","rationale":"This reduces duplication.","confidence":"high","tags":["maintainability"],"target_refs":{"repo_path":"."}},{"title":"Reuse report helpers","summary":"Formatting logic should stay shared.","proposal":"Introduce shared council coordinator helpers for report rendering","rationale":"This avoids formatter drift.","confidence":"medium","tags":["reporting"],"target_refs":{"repo_path":"."}}]}`, + ) + completeCouncilReviewer( + t, + dbPath, + runID, + "risk-reviewer", + `{"reviewer_role":"risk-reviewer","findings":[{"title":"Lock contracts","summary":"Contract drift becomes risky over time.","proposal":"Move API contract definitions into a dedicated module.","rationale":"This reduces integration regressions.","confidence":"high","tags":["risk"],"target_refs":{"repo_path":"."}},{"title":"Cover JSON output","summary":"The council report response should stay stable.","proposal":"Add regression tests for council report JSON output.","rationale":"This catches contract regressions earlier.","confidence":"high","tags":["testing"],"target_refs":{"repo_path":"."}}]}`, + ) + + runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "tally", + "--run", runID, + "--similarity", "normal", + ) +} + +func runInboxCommandEventually(t *testing.T, args ...string) string { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + var lastStdout, lastStderr string + var lastExit int + for { + lastStdout, lastStderr, lastExit = executeInboxCommand(args...) + if lastExit == 0 { + return lastStdout + } + if time.Now().After(deadline) || !isSQLiteBusyPayload(lastStdout) { + t.Fatalf("execute inbox command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, lastExit, lastStderr, lastStdout) + } + time.Sleep(25 * time.Millisecond) + } +} + +func isSQLiteBusyPayload(stdout string) bool { + return strings.Contains(strings.ToLower(stdout), "sqlite_busy") || + strings.Contains(strings.ToLower(stdout), "database is locked") +} diff --git a/packages/orch-runtime/internal/cli/orch/ready.go b/packages/orch-runtime/internal/cli/orch/ready.go new file mode 100644 index 0000000..99645de --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/ready.go @@ -0,0 +1,69 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type readyOptions struct { + runID string + limit int +} + +func newReadyCmd(root *rootOptions) *cobra.Command { + opts := &readyOptions{} + + cmd := &cobra.Command{ + Use: "ready", + Short: "List tasks that are ready for dispatch", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + tasks, err := store.NewOrchStore(sqlDB).ListReadyTasks(ctx, store.ListReadyInput{ + RunID: opts.runID, + Limit: opts.limit, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "ready", + Data: map[string]any{ + "tasks": tasks, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if len(tasks) == 0 { + _, err = fmt.Fprintln(cmd.OutOrStdout(), "no ready tasks") + return err + } + for _, task := range tasks { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", task.TaskID, task.Priority, task.Title); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().IntVar(&opts.limit, "limit", 20, "Maximum number of tasks to list") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/reassign.go b/packages/orch-runtime/internal/cli/orch/reassign.go new file mode 100644 index 0000000..f872100 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/reassign.go @@ -0,0 +1,80 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type reassignOptions struct { + runID string + taskID string + toAgent string + reason string +} + +func newReassignCmd(root *rootOptions) *cobra.Command { + opts := &reassignOptions{} + + cmd := &cobra.Command{ + Use: "reassign", + Short: "Reassign a blocked or failed task to another worker", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewOrchStore(sqlDB) + task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID) + if err != nil { + return err + } + + result, err := s.ReassignTask(ctx, store.ReassignInput{ + RunID: opts.runID, + TaskID: opts.taskID, + ToAgent: opts.toAgent, + Reason: opts.reason, + PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt), + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "reassign", + Data: map[string]any{ + "task": result.Task, + "attempt": result.Attempt, + "thread": result.Thread, + "message": result.Message, + "previous_attempt": result.PreviousAttempt, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "reassigned task %s to %s as attempt %d\n", result.Task.TaskID, result.Attempt.AssignedTo, result.Attempt.AttemptNo) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.toAgent, "to", "", "Destination worker agent") + cmd.Flags().StringVar(&opts.reason, "reason", "", "Reason for reassignment") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + _ = cmd.MarkFlagRequired("to") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/reconcile.go b/packages/orch-runtime/internal/cli/orch/reconcile.go new file mode 100644 index 0000000..d1e218c --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/reconcile.go @@ -0,0 +1,58 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type reconcileOptions struct { + runID string +} + +func newReconcileCmd(root *rootOptions) *cobra.Command { + opts := &reconcileOptions{} + + cmd := &cobra.Command{ + Use: "reconcile", + Short: "Reconcile inbox thread state back into orch task state", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).ReconcileRun(ctx, opts.runID) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "reconcile", + Data: map[string]any{ + "run": result.Run, + "task_counts": result.TaskCounts, + "updated_tasks": result.UpdatedTasks, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "reconciled run %s (%d updated tasks)\n", result.Run.RunID, len(result.UpdatedTasks)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/retry.go b/packages/orch-runtime/internal/cli/orch/retry.go new file mode 100644 index 0000000..704294c --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/retry.go @@ -0,0 +1,85 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type retryOptions struct { + runID string + taskID string + toAgent string + body string + bodyFile string +} + +func newRetryCmd(root *rootOptions) *cobra.Command { + opts := &retryOptions{} + + cmd := &cobra.Command{ + Use: "retry", + Short: "Retry a failed task by creating a new attempt", + RunE: func(cmd *cobra.Command, args []string) error { + body, err := resolveBodyValue(opts.body, opts.bodyFile) + if err != nil { + return err + } + + ctx := cmd.Context() + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + s := store.NewOrchStore(sqlDB) + task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID) + if err != nil { + return err + } + + result, err := s.RetryTask(ctx, store.RetryInput{ + RunID: opts.runID, + TaskID: opts.taskID, + ToAgent: opts.toAgent, + Body: body, + PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt), + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "retry", + Data: map[string]any{ + "task": result.Task, + "attempt": result.Attempt, + "thread": result.Thread, + "message": result.Message, + "previous_attempt": result.PreviousAttempt, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "retried task %s as attempt %d\n", result.Task.TaskID, result.Attempt.AttemptNo) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.toAgent, "to", "", "Optional worker agent override") + cmd.Flags().StringVar(&opts.body, "body", "", "Retry instruction body") + cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read retry instruction body from file") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/root.go b/packages/orch-runtime/internal/cli/orch/root.go new file mode 100644 index 0000000..912c5a3 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/root.go @@ -0,0 +1,42 @@ +package orch + +import ( + "github.com/spf13/cobra" +) + +type rootOptions struct { + dbPath string + json bool +} + +func NewRootCmd() *cobra.Command { + opts := &rootOptions{} + + cmd := &cobra.Command{ + Use: "orch", + Short: "Leader-facing scheduler and control plane", + SilenceErrors: true, + SilenceUsage: true, + } + + cmd.PersistentFlags().StringVar(&opts.dbPath, "db", ".agents/coord.db", "SQLite database path") + cmd.PersistentFlags().BoolVar(&opts.json, "json", false, "Emit machine-readable JSON") + + cmd.AddCommand(newRunCmd(opts)) + cmd.AddCommand(newTaskCmd(opts)) + cmd.AddCommand(newDepCmd(opts)) + cmd.AddCommand(newReadyCmd(opts)) + cmd.AddCommand(newDispatchCmd(opts)) + cmd.AddCommand(newReconcileCmd(opts)) + cmd.AddCommand(newWaitCmd(opts)) + cmd.AddCommand(newRetryCmd(opts)) + cmd.AddCommand(newReassignCmd(opts)) + cmd.AddCommand(newCancelCmd(opts)) + cmd.AddCommand(newCleanupCmd(opts)) + cmd.AddCommand(newCouncilCmd(opts)) + cmd.AddCommand(newBlockedCmd(opts)) + cmd.AddCommand(newAnswerCmd(opts)) + cmd.AddCommand(newStatusCmd(opts)) + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/run.go b/packages/orch-runtime/internal/cli/orch/run.go new file mode 100644 index 0000000..8a4b226 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/run.go @@ -0,0 +1,124 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type runInitOptions struct { + runID string + goal string + summary string +} + +type runShowOptions struct { + runID string +} + +func newRunCmd(root *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Run management commands", + } + + cmd.AddCommand(newRunInitCmd(root)) + cmd.AddCommand(newRunShowCmd(root)) + + return cmd +} + +func newRunInitCmd(root *rootOptions) *cobra.Command { + opts := &runInitOptions{} + + cmd := &cobra.Command{ + Use: "init", + Short: "Create a new orchestration run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + run, err := store.NewOrchStore(sqlDB).CreateRun(ctx, store.CreateRunInput{ + RunID: opts.runID, + Goal: opts.goal, + Summary: opts.summary, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "run init", + Data: map[string]any{ + "run": run, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "created run %s\n", run.RunID) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.goal, "goal", "", "Run goal") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Run summary") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("goal") + + return cmd +} + +func newRunShowCmd(root *rootOptions) *cobra.Command { + opts := &runShowOptions{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show run metadata and aggregate state", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + overview, err := store.NewOrchStore(sqlDB).GetRunOverview(ctx, opts.runID) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "run show", + Data: map[string]any{ + "run": overview.Run, + "task_counts": overview.TaskCounts, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "run %s status %s\n", overview.Run.RunID, overview.Run.Status) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/status.go b/packages/orch-runtime/internal/cli/orch/status.go new file mode 100644 index 0000000..4c93b61 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/status.go @@ -0,0 +1,65 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type statusOptions struct { + runID string +} + +func newStatusCmd(root *rootOptions) *cobra.Command { + opts := &statusOptions{} + + cmd := &cobra.Command{ + Use: "status", + Short: "Show task state summary for the run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + overview, err := store.NewOrchStore(sqlDB).GetRunOverview(ctx, opts.runID) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "status", + Data: map[string]any{ + "run": overview.Run, + "task_counts": overview.TaskCounts, + "tasks": overview.Tasks, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "run %s status %s\n", overview.Run.RunID, overview.Run.Status); err != nil { + return err + } + for _, task := range overview.Tasks { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", task.TaskID, task.Status, task.Title); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/task.go b/packages/orch-runtime/internal/cli/orch/task.go new file mode 100644 index 0000000..76187f5 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/task.go @@ -0,0 +1,88 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type taskAddOptions struct { + runID string + taskID string + title string + summary string + defaultTo string + acceptanceJSON string + priority string +} + +func newTaskCmd(root *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "task", + Short: "Task management commands", + } + + cmd.AddCommand(newTaskAddCmd(root)) + return cmd +} + +func newTaskAddCmd(root *rootOptions) *cobra.Command { + opts := &taskAddOptions{} + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a task to a run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + task, err := store.NewOrchStore(sqlDB).AddTask(ctx, store.AddTaskInput{ + RunID: opts.runID, + TaskID: opts.taskID, + Title: opts.title, + Summary: opts.summary, + DefaultTo: opts.defaultTo, + AcceptanceJSON: opts.acceptanceJSON, + Priority: opts.priority, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "task add", + Data: map[string]any{ + "task": task, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "added task %s to run %s\n", task.TaskID, task.RunID) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID") + cmd.Flags().StringVar(&opts.title, "title", "", "Task title") + cmd.Flags().StringVar(&opts.summary, "summary", "", "Task summary") + cmd.Flags().StringVar(&opts.defaultTo, "default-to", "", "Default worker agent") + cmd.Flags().StringVar(&opts.acceptanceJSON, "acceptance-json", "", "Acceptance criteria JSON") + cmd.Flags().StringVar(&opts.priority, "priority", "normal", "Task priority") + _ = cmd.MarkFlagRequired("run") + _ = cmd.MarkFlagRequired("task") + _ = cmd.MarkFlagRequired("title") + + return cmd +} diff --git a/packages/orch-runtime/internal/cli/orch/test_helpers_test.go b/packages/orch-runtime/internal/cli/orch/test_helpers_test.go new file mode 100644 index 0000000..2136652 --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/test_helpers_test.go @@ -0,0 +1,131 @@ +package orch + +import ( + "bytes" + "encoding/json" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +func runOrchCommand(t *testing.T, args ...string) string { + t.Helper() + + stdout, stderr, exitCode := executeOrchCommand(args...) + if exitCode != 0 { + t.Fatalf("execute orch command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout) + } + + return stdout +} + +func executeOrchCommand(args ...string) (string, string, int) { + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := Execute(args, &stdout, &stderr) + return stdout.String(), stderr.String(), exitCode +} + +func runInboxCommand(t *testing.T, args ...string) string { + t.Helper() + + stdout, stderr, exitCode := executeInboxCommand(args...) + if exitCode != 0 { + t.Fatalf("execute inbox command %v: exit=%d\nstderr:\n%s\nstdout:\n%s", args, exitCode, stderr, stdout) + } + + return stdout +} + +func executeInboxCommand(args ...string) (string, string, int) { + cmd := exec.Command("go", append([]string{"run", inboxCommandPath()}, args...)...) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + return stdout.String(), stderr.String(), 0 + } + + if exitError, ok := err.(*exec.ExitError); ok { + return stdout.String(), stderr.String(), exitError.ExitCode() + } + + return stdout.String(), stderr.String(), 1 +} + +func inboxCommandPath() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + panic("unable to determine orch test helper path") + } + + return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..", "cmd", "inbox") +} + +func mustDecodeJSON(t *testing.T, raw string, target any) { + t.Helper() + + if err := json.Unmarshal([]byte(raw), target); err != nil { + t.Fatalf("decode json %q: %v", raw, err) + } +} + +func nestedString(t *testing.T, value map[string]any, keys ...string) string { + t.Helper() + + current := nestedValue(t, value, keys...) + str, ok := current.(string) + if !ok { + t.Fatalf("expected string at %v, got %#v", keys, current) + } + return str +} + +func nestedValue(t *testing.T, value map[string]any, keys ...string) any { + t.Helper() + + var current any = value + for _, key := range keys { + obj, ok := current.(map[string]any) + if !ok { + t.Fatalf("expected object at %q in %v, got %#v", key, keys, current) + } + current, ok = obj[key] + if !ok { + t.Fatalf("missing key %q in %v", key, keys) + } + } + return current +} + +func nestedArray(t *testing.T, value map[string]any, keys ...string) []any { + t.Helper() + + current := nestedValue(t, value, keys...) + items, ok := current.([]any) + if !ok { + t.Fatalf("expected array at %v, got %#v", keys, current) + } + return items +} + +func assertErrorJSON(t *testing.T, raw string, expectedCode string) { + t.Helper() + + var payload map[string]any + mustDecodeJSON(t, raw, &payload) + if ok, _ := payload["ok"].(bool); ok { + t.Fatalf("expected ok=false error payload, got %#v", payload) + } + errorValue, ok := payload["error"].(map[string]any) + if !ok { + t.Fatalf("expected error object, got %#v", payload["error"]) + } + if code, _ := errorValue["code"].(string); code != expectedCode { + t.Fatalf("expected error code %q, got %#v", expectedCode, errorValue["code"]) + } +} diff --git a/packages/orch-runtime/internal/cli/orch/wait.go b/packages/orch-runtime/internal/cli/orch/wait.go new file mode 100644 index 0000000..9041d6e --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/wait.go @@ -0,0 +1,97 @@ +package orch + +import ( + "fmt" + "strings" + "time" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +type waitOptions struct { + runID string + eventTypesRaw string + afterEventID int64 + timeoutSeconds int +} + +func newWaitCmd(root *rootOptions) *cobra.Command { + opts := &waitOptions{} + + cmd := &cobra.Command{ + Use: "wait", + Short: "Block until matching run-scoped task events become available", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).WaitForEvents(ctx, store.WaitInput{ + RunID: opts.runID, + EventTypes: splitCommaList(opts.eventTypesRaw), + AfterEventID: opts.afterEventID, + Timeout: time.Duration(opts.timeoutSeconds) * time.Second, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "wait", + Data: map[string]any{ + "run_id": opts.runID, + "woke": result.Woke, + "next_event_id": result.NextEventID, + "events": result.Events, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + if !result.Woke { + _, err = fmt.Fprintf(cmd.OutOrStdout(), "wait timed out after event %d\n", result.NextEventID) + return err + } + for _, event := range result.Events { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\n", event.EventID, event.Type, event.TaskID, event.Summary); err != nil { + return err + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.eventTypesRaw, "for", "task_ready,task_blocked,task_done,task_failed", "Comma-separated event types to wait for") + cmd.Flags().Int64Var(&opts.afterEventID, "after-event", 0, "Only wait for events after this event ID") + cmd.Flags().IntVar(&opts.timeoutSeconds, "timeout-seconds", 0, "Maximum time to wait before timing out") + _ = cmd.MarkFlagRequired("run") + + return cmd +} + +func splitCommaList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + result = append(result, part) + } + return result +} diff --git a/packages/orch-runtime/internal/cli/orch/worktree.go b/packages/orch-runtime/internal/cli/orch/worktree.go new file mode 100644 index 0000000..380518a --- /dev/null +++ b/packages/orch-runtime/internal/cli/orch/worktree.go @@ -0,0 +1,503 @@ +package orch + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "ai-workflow-skill/packages/coord-core/protocol" + "ai-workflow-skill/packages/coord-core/store" + + "github.com/spf13/cobra" +) + +func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) store.DispatchWorkspacePreparer { + ctx := cmd.Context() + + return func(task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) { + effectiveOpts, useWorktree := resolveDispatchWorktreeOptions(task, opts) + if !useWorktree { + return store.DispatchWorkspace{}, func() {}, nil + } + return provisionDispatchWorkspace(ctx, effectiveOpts, task, attemptNo) + } +} + +func newAttemptReuseWorkspacePreparer(cmd *cobra.Command, task store.Task, attempt *store.TaskAttempt) store.DispatchWorkspacePreparer { + if attempt == nil || attempt.WorktreePath == "" { + return nil + } + + workspaceRoot, ok := deriveWorkspaceRootFromAttempt(task.RunID, task.TaskID, attempt.WorktreePath) + if !ok { + return nil + } + + baseRef := attempt.BaseRef + if strings.TrimSpace(baseRef) == "" { + baseRef = attempt.BaseCommit + } + + opts := dispatchOptions{ + repoPath: attempt.WorktreePath, + workspaceRoot: workspaceRoot, + strictWorktree: true, + baseRef: baseRef, + } + + return newDispatchWorkspacePreparer(cmd, opts) +} + +func dispatchUsesWorktree(opts dispatchOptions) bool { + return strings.TrimSpace(opts.workspaceRoot) != "" || + opts.strictWorktree +} + +func resolveDispatchWorktreeOptions(task store.Task, opts dispatchOptions) (dispatchOptions, bool) { + if dispatchUsesWorktree(opts) { + return opts, true + } + if !taskLooksLikeCodeWork(task) { + return opts, false + } + + auto := opts + auto.strictWorktree = true + return auto, true +} + +func taskLooksLikeCodeWork(task store.Task) bool { + if acceptanceJSONLooksCodeLike(task.AcceptanceJSON) { + return true + } + return roleLooksCodeLike(task.DefaultTo) +} + +func acceptanceJSONLooksCodeLike(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return false + } + return acceptanceValueLooksCodeLike(value) +} + +func acceptanceValueLooksCodeLike(value any) bool { + switch typed := value.(type) { + case map[string]any: + for key, raw := range typed { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + switch lowerKey { + case "code", "code_task", "writes_code", "worktree": + if boolValue, ok := raw.(bool); ok && boolValue { + return true + } + case "kind", "task_type", "mode", "type": + if stringValue, ok := raw.(string); ok && isCodeLikeMarker(stringValue) { + return true + } + } + if acceptanceValueLooksCodeLike(raw) { + return true + } + } + case []any: + for _, item := range typed { + if acceptanceValueLooksCodeLike(item) { + return true + } + } + case string: + return isCodeLikeMarker(typed) + } + return false +} + +func roleLooksCodeLike(role string) bool { + role = strings.ToLower(strings.TrimSpace(role)) + if role == "" { + return false + } + + for _, token := range splitIdentifierTokens(role) { + switch token { + case "backend", "frontend", "front", "admin", "ui", "fullstack", "foundation", "db", "database", "mobile", "ios", "android", "web", "platform", "infra", "api": + return true + } + } + return false +} + +func isCodeLikeMarker(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "code", "code_task", "code-task", "code-change", "code_change", "implementation", "patch", "diff", "repo": + return true + default: + return false + } +} + +func splitIdentifierTokens(value string) []string { + return strings.FieldsFunc(value, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) + }) +} + +func provisionDispatchWorkspace(ctx context.Context, opts dispatchOptions, task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) { + repoRoot, err := resolveRepoRoot(ctx, opts.repoPath) + if err != nil { + return store.DispatchWorkspace{}, nil, err + } + + workspaceRoot := resolveWorkspaceRoot(repoRoot, opts.workspaceRoot) + if err := ensureWorkspaceRootIgnored(repoRoot, workspaceRoot); err != nil { + return store.DispatchWorkspace{}, nil, err + } + + baseRef, baseCommit, err := resolveDispatchBase(ctx, repoRoot, workspaceRoot, opts.baseRef, opts.strictWorktree) + if err != nil { + return store.DispatchWorkspace{}, nil, err + } + + branchName := buildAttemptBranchName(task.RunID, task.TaskID, attemptNo) + worktreePath := buildAttemptWorktreePath(workspaceRoot, task.RunID, task.TaskID, attemptNo) + + if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { + return store.DispatchWorkspace{}, nil, fmt.Errorf("create worktree parent dir: %w", err) + } + if _, err := os.Stat(worktreePath); err == nil { + return store.DispatchWorkspace{}, nil, fmt.Errorf("%w: worktree path already exists: %s", store.ErrInvalidState, worktreePath) + } else if err != nil && !os.IsNotExist(err) { + return store.DispatchWorkspace{}, nil, fmt.Errorf("stat worktree path: %w", err) + } + + if _, _, err := runGit(ctx, repoRoot, "worktree", "add", "-b", branchName, worktreePath, baseCommit); err != nil { + return store.DispatchWorkspace{}, nil, err + } + + cleanup := func() { + _, _, _ = runGit(context.Background(), repoRoot, "worktree", "remove", "--force", worktreePath) + _, _, _ = runGit(context.Background(), repoRoot, "branch", "-D", branchName) + _ = os.RemoveAll(worktreePath) + } + + return store.DispatchWorkspace{ + BaseRef: baseRef, + BaseCommit: baseCommit, + BranchName: branchName, + WorktreePath: worktreePath, + WorkspaceStatus: "created", + }, cleanup, nil +} + +func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) { + startPath := strings.TrimSpace(repoPath) + if startPath == "" { + var err error + startPath, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("get current working directory: %w", err) + } + } + + absPath, err := filepath.Abs(startPath) + if err != nil { + return "", fmt.Errorf("resolve repo path: %w", err) + } + + if _, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel"); err != nil { + return "", protocol.InvalidInput("repo-path must point to a Git worktree", err) + } + + commonDir, err := resolveCommonGitDir(ctx, absPath) + if err != nil { + return "", protocol.InvalidInput("repo-path must point to a Git worktree", err) + } + return filepath.Dir(commonDir), nil +} + +func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) { + baseRef := strings.TrimSpace(requestedBaseRef) + if baseRef != "" { + baseCommit, err := resolveCommit(ctx, repoRoot, baseRef) + if err != nil { + return "", "", protocol.InvalidInput("base-ref must resolve to a commit", err) + } + return baseRef, baseCommit, nil + } + + if strict { + dirty, err := repoHasUncommittedChanges(ctx, repoRoot, workspaceRoot) + if err != nil { + return "", "", err + } + if dirty { + return "", "", fmt.Errorf("%w: repository has uncommitted changes; specify --base-ref or clean the repo", store.ErrInvalidState) + } + } + + baseCommit, err := resolveCommit(ctx, repoRoot, "HEAD") + if err != nil { + return "", "", protocol.InvalidInput("failed to resolve HEAD commit", err) + } + return "HEAD", baseCommit, nil +} + +func resolveCommit(ctx context.Context, repoRoot, ref string) (string, error) { + stdout, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + return "", err + } + return strings.TrimSpace(stdout), nil +} + +func repoHasUncommittedChanges(ctx context.Context, repoRoot, workspaceRoot string) (bool, error) { + stdout, _, err := runGit(ctx, repoRoot, "status", "--porcelain") + if err != nil { + return false, fmt.Errorf("check repository status: %w", err) + } + + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if len(line) >= 3 { + path := strings.TrimSpace(line[3:]) + if shouldIgnoreStatusPath(repoRoot, workspaceRoot, path) { + continue + } + } + return true, nil + } + + return false, nil +} + +func resolveWorkspaceRoot(repoRoot, configuredRoot string) string { + root := strings.TrimSpace(configuredRoot) + if root == "" { + return filepath.Join(repoRoot, ".orch", "worktrees") + } + if filepath.IsAbs(root) { + return root + } + return filepath.Join(repoRoot, root) +} + +func ensureWorkspaceRootIgnored(repoRoot, workspaceRoot string) error { + relative, err := filepath.Rel(repoRoot, workspaceRoot) + if err != nil { + return fmt.Errorf("resolve workspace root exclude path: %w", err) + } + if relative == "." || strings.HasPrefix(relative, "..") { + return nil + } + + pattern := filepath.ToSlash(relative) + if !strings.HasSuffix(pattern, "/") { + pattern += "/" + } + + excludePath := filepath.Join(repoRoot, ".git", "info", "exclude") + if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil { + return fmt.Errorf("create git info dir: %w", err) + } + + content, err := os.ReadFile(excludePath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read git exclude file: %w", err) + } + if strings.Contains(string(content), pattern) { + return nil + } + + appendContent := pattern + "\n" + if len(content) > 0 && !strings.HasSuffix(string(content), "\n") { + appendContent = "\n" + appendContent + } + file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open git exclude file: %w", err) + } + defer file.Close() + + if _, err := file.WriteString(appendContent); err != nil { + return fmt.Errorf("append git exclude file: %w", err) + } + return nil +} + +func shouldIgnoreStatusPath(repoRoot, workspaceRoot, statusPath string) bool { + relative, err := filepath.Rel(repoRoot, workspaceRoot) + if err != nil || relative == "." || strings.HasPrefix(relative, "..") { + return false + } + + relative = filepath.ToSlash(relative) + statusPath = filepath.ToSlash(strings.Trim(statusPath, `"`)) + return statusPath == relative || strings.HasPrefix(statusPath, relative+"/") +} + +func buildAttemptBranchName(runID, taskID string, attemptNo int) string { + return fmt.Sprintf( + "orch/%s/%s/attempt-%d", + sanitizeGitSegment(runID), + sanitizeGitSegment(taskID), + attemptNo, + ) +} + +func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int) string { + return filepath.Join( + workspaceRoot, + sanitizePathSegment(runID), + sanitizePathSegment(taskID), + fmt.Sprintf("attempt-%d", attemptNo), + ) +} + +func deriveWorkspaceRootFromAttempt(runID, taskID, worktreePath string) (string, bool) { + suffix := filepath.Join( + sanitizePathSegment(runID), + sanitizePathSegment(taskID), + filepath.Base(worktreePath), + ) + parent := filepath.Dir(worktreePath) + if filepath.Base(parent) != sanitizePathSegment(taskID) { + return "", false + } + runDir := filepath.Dir(parent) + if filepath.Base(runDir) != sanitizePathSegment(runID) { + return "", false + } + root := filepath.Dir(runDir) + if filepath.Clean(filepath.Join(root, suffix)) != filepath.Clean(worktreePath) { + return "", false + } + return root, true +} + +func sanitizeGitSegment(value string) string { + return sanitizeSegment(value) +} + +func sanitizePathSegment(value string) string { + return sanitizeSegment(value) +} + +func sanitizeSegment(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "item" + } + + var b strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if r == '-' || r == '_' || r == '.' { + if !lastDash { + b.WriteByte('-') + lastDash = true + } + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + + result := strings.Trim(b.String(), "-.") + if result == "" { + return "item" + } + if strings.HasSuffix(result, ".lock") { + result = strings.TrimSuffix(result, ".lock") + "-lock" + } + return result +} + +func runGit(ctx context.Context, repoRoot string, args ...string) (string, string, error) { + cmdArgs := append([]string{"-C", repoRoot}, args...) + cmd := exec.CommandContext(ctx, "git", cmdArgs...) + output, err := cmd.CombinedOutput() + if err == nil { + return string(output), "", nil + } + + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return "", message, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) +} + +func cleanupAttemptWorktree(ctx context.Context, attempt store.TaskAttempt, force bool) error { + if strings.TrimSpace(attempt.WorktreePath) == "" { + return nil + } + + if _, err := os.Stat(attempt.WorktreePath); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("stat worktree path: %w", err) + } + + repoRoot, err := resolveRepoRootFromExistingWorktree(ctx, attempt.WorktreePath) + if err != nil { + if force { + return os.RemoveAll(attempt.WorktreePath) + } + return err + } + + args := []string{"worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, attempt.WorktreePath) + if _, _, err := runGit(ctx, repoRoot, args...); err != nil { + if force { + return os.RemoveAll(attempt.WorktreePath) + } + return err + } + + return nil +} + +func resolveRepoRootFromExistingWorktree(ctx context.Context, worktreePath string) (string, error) { + commonDir, err := resolveCommonGitDir(ctx, worktreePath) + if err != nil { + return "", err + } + return filepath.Dir(commonDir), nil +} + +func resolveCommonGitDir(ctx context.Context, repoPath string) (string, error) { + stdout, _, err := runGit(ctx, repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir") + if err != nil { + return "", err + } + + commonDir := strings.TrimSpace(stdout) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(repoPath, commonDir) + } + return filepath.Clean(commonDir), nil +} diff --git a/scripts/package_skill_clis.sh b/scripts/package_skill_clis.sh index 2492400..e5433d1 100755 --- a/scripts/package_skill_clis.sh +++ b/scripts/package_skill_clis.sh @@ -3,70 +3,5 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/skill-clis.XXXXXX")" -cleanup() { - rm -rf "${BUILD_DIR}" -} - -trap cleanup EXIT INT TERM - -require_command() { - local cmd="$1" - if ! command -v "${cmd}" >/dev/null 2>&1; then - printf 'missing required command: %s\n' "${cmd}" >&2 - exit 1 - fi -} - -build_binary() { - local name="$1" - local package_path="$2" - local output_path="${BUILD_DIR}/${name}" - - printf 'building %s from %s\n' "${name}" "${package_path}" >&2 - ( - cd "${REPO_ROOT}" - go build -trimpath -o "${output_path}" "${package_path}" - ) - - printf '%s\n' "${output_path}" -} - -install_binary() { - local source_path="$1" - shift - - local destination_path - for destination_path in "$@"; do - mkdir -p "$(dirname "${destination_path}")" - install -m 0755 "${source_path}" "${destination_path}" - printf 'installed %s\n' "${destination_path}" - done -} - -main() { - require_command go - require_command install - require_command mktemp - - local inbox_binary - local orch_binary - - inbox_binary="$(build_binary inbox ./cmd/inbox)" - orch_binary="$(build_binary orch ./cmd/orch)" - - install_binary \ - "${inbox_binary}" \ - "${REPO_ROOT}/skills/inbox/assets/inbox" - - install_binary \ - "${orch_binary}" \ - "${REPO_ROOT}/skills/orch/assets/orch" \ - "${REPO_ROOT}/skills/council-review/assets/orch" - - printf 'skill CLI packaging complete\n' -} - -main "$@" +exec bash "${SCRIPT_DIR}/package_skill_runtimes.sh" package "$@" diff --git a/scripts/package_skill_runtimes.sh b/scripts/package_skill_runtimes.sh index 13294d1..e66d37d 100644 --- a/scripts/package_skill_runtimes.sh +++ b/scripts/package_skill_runtimes.sh @@ -5,15 +5,17 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" readonly MANIFEST_PATH="${REPO_ROOT}/scripts/skill-bundles.json" +BUILD_DIR="" usage() { cat <<'EOF' Usage: package_skill_runtimes.sh plan package_skill_runtimes.sh validate + package_skill_runtimes.sh package -Phase 1 only bootstraps declarative bundle metadata. -Actual runtime packaging will be added once package-owned entrypoints exist. +The bundle manifest is package-oriented. Bundles in state=planned are skipped by +the package command until their package-owned entrypoints exist. EOF } @@ -44,6 +46,12 @@ emit_bundles() { ' "${MANIFEST_PATH}" } +cleanup() { + if [[ -n "${BUILD_DIR}" && -d "${BUILD_DIR}" ]]; then + rm -rf "${BUILD_DIR}" + fi +} + plan() { printf 'skill bundle plan from %s\n' "${MANIFEST_PATH}" while IFS=$'\t' read -r skill type runtime_package entrypoint output build_state; do @@ -98,6 +106,37 @@ validate() { printf 'skill bundle manifest validated\n' } +package_bundles() { + validate + + require_command go + require_command install + require_command mktemp + + BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/skill-runtimes.XXXXXX")" + trap cleanup EXIT INT TERM + + while IFS=$'\t' read -r skill type runtime_package entrypoint output build_state; do + if [[ "${build_state}" != "ready" ]]; then + printf 'skipping %s (%s)\n' "${skill}" "${build_state}" + continue + fi + + local output_path="${REPO_ROOT}/${output}" + local artifact_path="${BUILD_DIR}/${skill}" + + printf 'building %s from %s\n' "${skill}" "${entrypoint}" + ( + cd "${REPO_ROOT}" + go build -trimpath -o "${artifact_path}" "${entrypoint}" + ) + + mkdir -p "$(dirname "${output_path}")" + install -m 0755 "${artifact_path}" "${output_path}" + printf 'installed %s\n' "${output}" + done < <(emit_bundles) +} + main() { require_command node @@ -109,6 +148,9 @@ main() { validate) validate ;; + package) + package_bundles + ;; ""|-h|--help|help) usage ;; diff --git a/scripts/skill-bundles.json b/scripts/skill-bundles.json index 19f8bdf..2236369 100644 --- a/scripts/skill-bundles.json +++ b/scripts/skill-bundles.json @@ -7,7 +7,7 @@ "runtimePackage": "./packages/inbox-runtime", "entrypoint": "./packages/inbox-runtime/cmd/inbox", "output": "skills/inbox/assets/inbox", - "buildState": "planned" + "buildState": "ready" }, { "skill": "orch", @@ -15,7 +15,7 @@ "runtimePackage": "./packages/orch-runtime", "entrypoint": "./packages/orch-runtime/cmd/orch", "output": "skills/orch/assets/orch", - "buildState": "planned" + "buildState": "ready" }, { "skill": "council-review", @@ -23,7 +23,7 @@ "runtimePackage": "./packages/orch-runtime", "entrypoint": "./packages/orch-runtime/cmd/orch", "output": "skills/council-review/assets/orch", - "buildState": "planned" + "buildState": "ready" }, { "skill": "repo-memory", diff --git a/skills/council-review/assets/orch b/skills/council-review/assets/orch index 77cebc9..2e4b7c9 100755 Binary files a/skills/council-review/assets/orch and b/skills/council-review/assets/orch differ diff --git a/skills/inbox/assets/inbox b/skills/inbox/assets/inbox index ff68f32..312afcf 100755 Binary files a/skills/inbox/assets/inbox and b/skills/inbox/assets/inbox differ diff --git a/skills/orch/assets/orch b/skills/orch/assets/orch index 77cebc9..2e4b7c9 100755 Binary files a/skills/orch/assets/orch and b/skills/orch/assets/orch differ