From d00b2a30ee1ca705a80a7345de049cc0784c0ed8 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 20 Mar 2026 13:47:53 +0800 Subject: [PATCH] refactor(monorepo): remove legacy root runtime ownership --- cmd/inbox/main.go | 2 +- cmd/orch/main.go | 2 +- cmd/orchd/main.go | 59 +- docs/implementation-roadmap.md | 24 +- .../skill-workspace-monorepo-migration.md | 16 +- go.mod | 9 + internal/app/web.go | 39 - internal/cli/inbox/artifact.go | 78 - internal/cli/inbox/body.go | 22 - internal/cli/inbox/cancel.go | 83 - internal/cli/inbox/cancel_integration_test.go | 148 - internal/cli/inbox/claim.go | 80 - internal/cli/inbox/claim_integration_test.go | 112 - internal/cli/inbox/db.go | 22 - internal/cli/inbox/done.go | 106 - internal/cli/inbox/done_integration_test.go | 140 - internal/cli/inbox/execute.go | 113 - internal/cli/inbox/fail_integration_test.go | 143 - internal/cli/inbox/fetch.go | 97 - internal/cli/inbox/fetch_integration_test.go | 187 -- internal/cli/inbox/init.go | 41 - internal/cli/inbox/init_integration_test.go | 83 - internal/cli/inbox/integration_test.go | 734 ----- internal/cli/inbox/list.go | 83 - internal/cli/inbox/list_integration_test.go | 183 -- internal/cli/inbox/renew.go | 76 - internal/cli/inbox/renew_integration_test.go | 103 - internal/cli/inbox/reply.go | 104 - internal/cli/inbox/reply_integration_test.go | 138 - internal/cli/inbox/root.go | 43 - internal/cli/inbox/send.go | 117 - internal/cli/inbox/send_integration_test.go | 230 -- internal/cli/inbox/show.go | 78 - internal/cli/inbox/show_integration_test.go | 196 -- internal/cli/inbox/test_helpers_test.go | 78 - internal/cli/inbox/update.go | 101 - internal/cli/inbox/update_integration_test.go | 226 -- internal/cli/inbox/wait_reply.go | 85 - .../cli/inbox/wait_reply_integration_test.go | 221 -- internal/cli/inbox/watch.go | 90 - internal/cli/inbox/watch_integration_test.go | 171 -- internal/cli/orch/answer.go | 77 - internal/cli/orch/blocked.go | 64 - internal/cli/orch/body.go | 22 - internal/cli/orch/cancel.go | 69 - internal/cli/orch/cleanup.go | 84 - .../cli/orch/command_contracts_core_test.go | 245 -- .../cli/orch/command_contracts_edges_test.go | 219 -- .../orch/command_contracts_remaining_test.go | 723 ----- internal/cli/orch/council.go | 16 - internal/cli/orch/council_report.go | 111 - .../cli/orch/council_report_contracts_test.go | 199 -- internal/cli/orch/council_start.go | 88 - internal/cli/orch/council_tally.go | 64 - internal/cli/orch/council_wait.go | 69 - internal/cli/orch/db.go | 22 - internal/cli/orch/dep.go | 76 - internal/cli/orch/dispatch.go | 93 - internal/cli/orch/execute.go | 113 - internal/cli/orch/git_test_helpers_test.go | 53 - internal/cli/orch/integration_test.go | 2164 -------------- internal/cli/orch/ready.go | 69 - internal/cli/orch/reassign.go | 80 - internal/cli/orch/reconcile.go | 58 - internal/cli/orch/retry.go | 85 - internal/cli/orch/root.go | 42 - internal/cli/orch/run.go | 124 - internal/cli/orch/status.go | 65 - internal/cli/orch/task.go | 88 - internal/cli/orch/test_helpers_test.go | 109 - internal/cli/orch/wait.go | 97 - internal/cli/orch/worktree.go | 503 ---- internal/db/migrate.go | 50 - internal/db/open.go | 38 - internal/db/pragmas.go | 23 - internal/db/schema/001_inbox.sql | 51 - internal/db/schema/002_orch.sql | 52 - internal/db/schema/003_events.sql | 18 - internal/db/schema/004_council.sql | 45 - internal/db/schema/005_inbox_reads.sql | 12 - internal/db/schema/006_council_inputs.sql | 9 - internal/db/schema/007_council_reports.sql | 8 - internal/httpapi/response.go | 58 - internal/httpapi/router.go | 87 - internal/httpapi/router_test.go | 196 -- internal/protocol/cli_error.go | 33 - internal/protocol/json.go | 28 - internal/query/read_service.go | 211 -- internal/store/council.go | 1503 ---------- internal/store/doc.go | 3 - internal/store/inbox.go | 1932 ------------ internal/store/inbox_test.go | 107 - internal/store/orch.go | 2579 ----------------- packages/inbox-runtime/cli/inbox/execute.go | 11 + packages/inbox-runtime/cmd/inbox/main.go | 2 +- packages/orch-runtime/cli/orch/execute.go | 11 + packages/orch-runtime/cmd/orch/main.go | 2 +- packages/orchd-runtime/cmd/orchd/main.go | 59 +- packages/orchd-runtime/server/execute.go | 81 + 99 files changed, 144 insertions(+), 17619 deletions(-) rename docs/roadmaps/{active => archive}/skill-workspace-monorepo-migration.md (62%) delete mode 100644 internal/app/web.go delete mode 100644 internal/cli/inbox/artifact.go delete mode 100644 internal/cli/inbox/body.go delete mode 100644 internal/cli/inbox/cancel.go delete mode 100644 internal/cli/inbox/cancel_integration_test.go delete mode 100644 internal/cli/inbox/claim.go delete mode 100644 internal/cli/inbox/claim_integration_test.go delete mode 100644 internal/cli/inbox/db.go delete mode 100644 internal/cli/inbox/done.go delete mode 100644 internal/cli/inbox/done_integration_test.go delete mode 100644 internal/cli/inbox/execute.go delete mode 100644 internal/cli/inbox/fail_integration_test.go delete mode 100644 internal/cli/inbox/fetch.go delete mode 100644 internal/cli/inbox/fetch_integration_test.go delete mode 100644 internal/cli/inbox/init.go delete mode 100644 internal/cli/inbox/init_integration_test.go delete mode 100644 internal/cli/inbox/integration_test.go delete mode 100644 internal/cli/inbox/list.go delete mode 100644 internal/cli/inbox/list_integration_test.go delete mode 100644 internal/cli/inbox/renew.go delete mode 100644 internal/cli/inbox/renew_integration_test.go delete mode 100644 internal/cli/inbox/reply.go delete mode 100644 internal/cli/inbox/reply_integration_test.go delete mode 100644 internal/cli/inbox/root.go delete mode 100644 internal/cli/inbox/send.go delete mode 100644 internal/cli/inbox/send_integration_test.go delete mode 100644 internal/cli/inbox/show.go delete mode 100644 internal/cli/inbox/show_integration_test.go delete mode 100644 internal/cli/inbox/test_helpers_test.go delete mode 100644 internal/cli/inbox/update.go delete mode 100644 internal/cli/inbox/update_integration_test.go delete mode 100644 internal/cli/inbox/wait_reply.go delete mode 100644 internal/cli/inbox/wait_reply_integration_test.go delete mode 100644 internal/cli/inbox/watch.go delete mode 100644 internal/cli/inbox/watch_integration_test.go delete mode 100644 internal/cli/orch/answer.go delete mode 100644 internal/cli/orch/blocked.go delete mode 100644 internal/cli/orch/body.go delete mode 100644 internal/cli/orch/cancel.go delete mode 100644 internal/cli/orch/cleanup.go delete mode 100644 internal/cli/orch/command_contracts_core_test.go delete mode 100644 internal/cli/orch/command_contracts_edges_test.go delete mode 100644 internal/cli/orch/command_contracts_remaining_test.go delete mode 100644 internal/cli/orch/council.go delete mode 100644 internal/cli/orch/council_report.go delete mode 100644 internal/cli/orch/council_report_contracts_test.go delete mode 100644 internal/cli/orch/council_start.go delete mode 100644 internal/cli/orch/council_tally.go delete mode 100644 internal/cli/orch/council_wait.go delete mode 100644 internal/cli/orch/db.go delete mode 100644 internal/cli/orch/dep.go delete mode 100644 internal/cli/orch/dispatch.go delete mode 100644 internal/cli/orch/execute.go delete mode 100644 internal/cli/orch/git_test_helpers_test.go delete mode 100644 internal/cli/orch/integration_test.go delete mode 100644 internal/cli/orch/ready.go delete mode 100644 internal/cli/orch/reassign.go delete mode 100644 internal/cli/orch/reconcile.go delete mode 100644 internal/cli/orch/retry.go delete mode 100644 internal/cli/orch/root.go delete mode 100644 internal/cli/orch/run.go delete mode 100644 internal/cli/orch/status.go delete mode 100644 internal/cli/orch/task.go delete mode 100644 internal/cli/orch/test_helpers_test.go delete mode 100644 internal/cli/orch/wait.go delete mode 100644 internal/cli/orch/worktree.go delete mode 100644 internal/db/migrate.go delete mode 100644 internal/db/open.go delete mode 100644 internal/db/pragmas.go delete mode 100644 internal/db/schema/001_inbox.sql delete mode 100644 internal/db/schema/002_orch.sql delete mode 100644 internal/db/schema/003_events.sql delete mode 100644 internal/db/schema/004_council.sql delete mode 100644 internal/db/schema/005_inbox_reads.sql delete mode 100644 internal/db/schema/006_council_inputs.sql delete mode 100644 internal/db/schema/007_council_reports.sql delete mode 100644 internal/httpapi/response.go delete mode 100644 internal/httpapi/router.go delete mode 100644 internal/httpapi/router_test.go delete mode 100644 internal/protocol/cli_error.go delete mode 100644 internal/protocol/json.go delete mode 100644 internal/query/read_service.go delete mode 100644 internal/store/council.go delete mode 100644 internal/store/doc.go delete mode 100644 internal/store/inbox.go delete mode 100644 internal/store/inbox_test.go delete mode 100644 internal/store/orch.go create mode 100644 packages/inbox-runtime/cli/inbox/execute.go create mode 100644 packages/orch-runtime/cli/orch/execute.go create mode 100644 packages/orchd-runtime/server/execute.go diff --git a/cmd/inbox/main.go b/cmd/inbox/main.go index 3c45b94..2f0a3e6 100644 --- a/cmd/inbox/main.go +++ b/cmd/inbox/main.go @@ -3,7 +3,7 @@ package main import ( "os" - inboxcli "ai-workflow-skill/internal/cli/inbox" + inboxcli "ai-workflow-skill/packages/inbox-runtime/cli/inbox" ) func main() { diff --git a/cmd/orch/main.go b/cmd/orch/main.go index caa5019..a294cbd 100644 --- a/cmd/orch/main.go +++ b/cmd/orch/main.go @@ -3,7 +3,7 @@ package main import ( "os" - orchcli "ai-workflow-skill/internal/cli/orch" + orchcli "ai-workflow-skill/packages/orch-runtime/cli/orch" ) func main() { diff --git a/cmd/orchd/main.go b/cmd/orchd/main.go index d305bb2..430e0c8 100644 --- a/cmd/orchd/main.go +++ b/cmd/orchd/main.go @@ -1,66 +1,11 @@ package main import ( - "context" - "errors" - "flag" - "log" - "net/http" "os" - "os/signal" - "syscall" - "time" - "ai-workflow-skill/internal/app" - "ai-workflow-skill/packages/coord-core/db" - "ai-workflow-skill/internal/httpapi" + "ai-workflow-skill/packages/orchd-runtime/server" ) func main() { - var ( - dbPath string - listen string - shutdown time.Duration - ) - - flag.StringVar(&dbPath, "db", ".agents/coord.db", "SQLite database path") - flag.StringVar(&listen, "listen", ":8080", "HTTP listen address") - flag.DurationVar(&shutdown, "shutdown-timeout", 5*time.Second, "Graceful shutdown timeout") - flag.Parse() - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - sqlDB, err := db.Open(ctx, dbPath) - if err != nil { - log.Fatalf("open database: %v", err) - } - defer sqlDB.Close() - - if err := db.ApplyMigrations(ctx, sqlDB); err != nil { - log.Fatalf("apply migrations: %v", err) - } - - webApp := app.NewWebService(sqlDB) - server := &http.Server{ - Addr: listen, - Handler: httpapi.NewRouter(webApp), - ReadHeaderTimeout: 5 * time.Second, - } - - go func() { - <-ctx.Done() - - shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdown) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Printf("http shutdown: %v", err) - } - }() - - log.Printf("orchd listening on %s", listen) - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("serve http api: %v", err) - } + os.Exit(server.Execute(os.Args[1:], os.Stderr)) } diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 95a6592..4e0bfb0 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -101,9 +101,9 @@ Current implementation status: - `Milestone 7: Council Review` is complete - `Milestone 8: Web Product Phase 1 Skeleton` is complete - `Milestone 9: Web Product Phase 2 Read-Only Operator UI` is complete for the initial operator surface -- `Milestone 10: Skill Workspace Monorepo Migration` is in progress +- `Milestone 10: Skill Workspace Monorepo Migration` is complete -The council review v1 surface is complete, the first web-product skeleton now exists as a separate monorepo workspace plus read-only HTTP backend slice, the first real operator-facing Phase 2 read-only web views now exist on top of the internal Cadence UI component library, and the repository has now started the actual migration into a true skill workspace monorepo. +The council review v1 surface is complete, the first web-product skeleton now exists as a separate monorepo workspace plus read-only HTTP backend slice, the first real operator-facing Phase 2 read-only web views now exist on top of the internal Cadence UI component library, and the repository has now completed the migration into a package-owned skill workspace monorepo for the current runtime set. ### Milestone 1: Go Skeleton @@ -487,7 +487,7 @@ Definition of done: Status: -- in progress +- completed Completed so far: @@ -513,24 +513,24 @@ Completed so far: - `packages/repo-memory-runtime/cmd/briefdb` plus its package-local `internal/brief` and `internal/store` now provide a package-owned repo-memory runtime and pass `go test ./...` - `skills/repo-memory/` now exists with `SKILL.md`, `agents/openai.yaml`, and a bundled `assets/briefdb` binary produced by the declarative packaging flow - `docs/tests/repo-memory-skill/` now exists with a README plus an initial forward-test case covering search-before-add and durable entry retrieval through the bundled skill +- root `cmd/inbox`, `cmd/orch`, and `cmd/orchd` now act as compatibility shims that invoke package-owned runtimes instead of root-owned runtime implementations +- legacy root runtime implementation directories under `internal/` have been removed now that package-owned runtimes and package-backed skill bundles are in place Remaining: -- remove or reduce the remaining legacy root runtime ownership under `cmd/` and `internal/` -- decide whether the external `../tmp/briefdb` prototype should be archived, documented as superseded, or deleted after the package import is considered stable +- none for the current migration slice +- optional follow-up: archive or delete the external `../tmp/briefdb` prototype once the imported runtime is treated as the sole maintained source ## Immediate Next Task 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. treat `coord-core`, `inbox-runtime`, `orch-runtime`, `orchd-runtime`, and `repo-memory-runtime` as the package-owned runtime baseline and start Phase 6 by removing or shrinking the remaining root-owned runtime paths -5. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as runtime ownership moves from root paths to package paths -6. decide how to retire or archive the external `../tmp/briefdb` prototype once the imported runtime is stable enough to be the sole source of truth +1. treat `Milestone 10: Skill Workspace Monorepo Migration` as complete for the current runtime set and keep new runtime work inside `packages/` plus `skills/` rather than recreating root-owned implementations +2. resume product-facing work on top of the new workspace, beginning with the next highest-priority feature slice rather than more repository reshuffling +3. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as new skills or runtime-backed bundles are added +4. optionally archive or delete the external `../tmp/briefdb` prototype after confirming no remaining workflow depends on it -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 plus the shared coordination, inbox, orch, orchd, and repo-memory package-runtime extraction phases of the skill monorepo migration, so the next step should be removing legacy root ownership and finalizing source-of-truth boundaries rather than continuing to accrete new root-owned runtime paths. +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 now uses package-owned runtimes plus agent-facing skill bundles as its primary structure, so the next step should be feature work and incremental new skill additions on top of that workspace rather than further structural churn. ## Recommended Driver Choices diff --git a/docs/roadmaps/active/skill-workspace-monorepo-migration.md b/docs/roadmaps/archive/skill-workspace-monorepo-migration.md similarity index 62% rename from docs/roadmaps/active/skill-workspace-monorepo-migration.md rename to docs/roadmaps/archive/skill-workspace-monorepo-migration.md index b9fd082..1be8819 100644 --- a/docs/roadmaps/active/skill-workspace-monorepo-migration.md +++ b/docs/roadmaps/archive/skill-workspace-monorepo-migration.md @@ -2,7 +2,7 @@ ## Status -- `in_progress` +- `completed` ## Owner @@ -30,11 +30,11 @@ - [x] Phase 3: extract `inbox-runtime` and `orch-runtime` - [x] Phase 4: extract `orchd-runtime` - [x] Phase 5: import `repo-memory-runtime` and add `skills/repo-memory` -- [ ] Phase 6: remove root runtime ownership and normalize package-based packaging +- [x] Phase 6: remove root runtime ownership and normalize package-based packaging ## Files -- `docs/roadmaps/active/skill-workspace-monorepo-migration.md` +- `docs/roadmaps/archive/skill-workspace-monorepo-migration.md` - `docs/skill-workspace-monorepo.md` - `docs/implementation-roadmap.md` - `go.work` @@ -54,4 +54,12 @@ ## Next Step -- start Phase 6 by removing or reducing legacy root runtime ownership now that `coord-core`, `inbox-runtime`, `orch-runtime`, `orchd-runtime`, and `repo-memory-runtime` all exist as package-owned runtimes +- resume product work on top of the package-owned runtime workspace, starting with whichever user-facing feature slice has priority next + +## Completion Summary + +- bootstrapped the repository as a multi-package workspace with `go.work`, expanded JS workspace discovery, and declarative skill bundle metadata +- extracted the shared coordination kernel into `packages/coord-core` +- extracted package-owned runtimes for `inbox`, `orch`, `orchd`, and `repo-memory` +- switched the root skill packaging flow to build from package entrypoints, including the new `repo-memory` bundle +- removed the legacy root runtime implementation directories under `internal/`, leaving root `cmd/*` as compatibility shims while package-owned runtimes remain the source of truth diff --git a/go.mod b/go.mod index f66f5e4..d08da4d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module ai-workflow-skill go 1.26 require ( + ai-workflow-skill/packages/inbox-runtime v0.0.0 + ai-workflow-skill/packages/orch-runtime v0.0.0 + ai-workflow-skill/packages/orchd-runtime v0.0.0 github.com/go-chi/chi/v5 v5.2.5 github.com/spf13/cobra v1.10.1 modernc.org/sqlite v1.40.1 @@ -22,3 +25,9 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace ai-workflow-skill/packages/inbox-runtime => ./packages/inbox-runtime + +replace ai-workflow-skill/packages/orch-runtime => ./packages/orch-runtime + +replace ai-workflow-skill/packages/orchd-runtime => ./packages/orchd-runtime diff --git a/internal/app/web.go b/internal/app/web.go deleted file mode 100644 index d0ea287..0000000 --- a/internal/app/web.go +++ /dev/null @@ -1,39 +0,0 @@ -package app - -import ( - "context" - "database/sql" - - "ai-workflow-skill/internal/query" - "ai-workflow-skill/packages/coord-core/store" -) - -type WebService struct { - reads *query.ReadService -} - -func NewWebService(db *sql.DB) *WebService { - return &WebService{ - reads: query.NewReadService(db), - } -} - -func (s *WebService) ListRuns(ctx context.Context) ([]query.RunListItem, error) { - return s.reads.ListRuns(ctx) -} - -func (s *WebService) GetRunDetail(ctx context.Context, runID string) (query.RunDetail, error) { - return s.reads.GetRunDetail(ctx, runID) -} - -func (s *WebService) ListRunTasks(ctx context.Context, runID string) ([]store.Task, error) { - return s.reads.ListRunTasks(ctx, runID) -} - -func (s *WebService) ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error) { - return s.reads.ListBlockedTasks(ctx, runID) -} - -func (s *WebService) GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error) { - return s.reads.GetThreadDetail(ctx, threadID) -} diff --git a/internal/cli/inbox/artifact.go b/internal/cli/inbox/artifact.go deleted file mode 100644 index 54a6eb0..0000000 --- a/internal/cli/inbox/artifact.go +++ /dev/null @@ -1,78 +0,0 @@ -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/internal/cli/inbox/body.go b/internal/cli/inbox/body.go deleted file mode 100644 index 3dfe9f1..0000000 --- a/internal/cli/inbox/body.go +++ /dev/null @@ -1,22 +0,0 @@ -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/internal/cli/inbox/cancel.go b/internal/cli/inbox/cancel.go deleted file mode 100644 index c980f67..0000000 --- a/internal/cli/inbox/cancel.go +++ /dev/null @@ -1,83 +0,0 @@ -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/internal/cli/inbox/cancel_integration_test.go b/internal/cli/inbox/cancel_integration_test.go deleted file mode 100644 index 0197d4d..0000000 --- a/internal/cli/inbox/cancel_integration_test.go +++ /dev/null @@ -1,148 +0,0 @@ -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/internal/cli/inbox/claim.go b/internal/cli/inbox/claim.go deleted file mode 100644 index 0ddb6d9..0000000 --- a/internal/cli/inbox/claim.go +++ /dev/null @@ -1,80 +0,0 @@ -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/internal/cli/inbox/claim_integration_test.go b/internal/cli/inbox/claim_integration_test.go deleted file mode 100644 index 2ee5875..0000000 --- a/internal/cli/inbox/claim_integration_test.go +++ /dev/null @@ -1,112 +0,0 @@ -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/internal/cli/inbox/db.go b/internal/cli/inbox/db.go deleted file mode 100644 index c187f8c..0000000 --- a/internal/cli/inbox/db.go +++ /dev/null @@ -1,22 +0,0 @@ -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/internal/cli/inbox/done.go b/internal/cli/inbox/done.go deleted file mode 100644 index 8ba49db..0000000 --- a/internal/cli/inbox/done.go +++ /dev/null @@ -1,106 +0,0 @@ -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/internal/cli/inbox/done_integration_test.go b/internal/cli/inbox/done_integration_test.go deleted file mode 100644 index f0041e9..0000000 --- a/internal/cli/inbox/done_integration_test.go +++ /dev/null @@ -1,140 +0,0 @@ -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/internal/cli/inbox/execute.go b/internal/cli/inbox/execute.go deleted file mode 100644 index 67e5561..0000000 --- a/internal/cli/inbox/execute.go +++ /dev/null @@ -1,113 +0,0 @@ -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/internal/cli/inbox/fail_integration_test.go b/internal/cli/inbox/fail_integration_test.go deleted file mode 100644 index 62bf9d1..0000000 --- a/internal/cli/inbox/fail_integration_test.go +++ /dev/null @@ -1,143 +0,0 @@ -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/internal/cli/inbox/fetch.go b/internal/cli/inbox/fetch.go deleted file mode 100644 index 0564621..0000000 --- a/internal/cli/inbox/fetch.go +++ /dev/null @@ -1,97 +0,0 @@ -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/internal/cli/inbox/fetch_integration_test.go b/internal/cli/inbox/fetch_integration_test.go deleted file mode 100644 index efdbf4e..0000000 --- a/internal/cli/inbox/fetch_integration_test.go +++ /dev/null @@ -1,187 +0,0 @@ -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/internal/cli/inbox/init.go b/internal/cli/inbox/init.go deleted file mode 100644 index 2be90d4..0000000 --- a/internal/cli/inbox/init.go +++ /dev/null @@ -1,41 +0,0 @@ -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/internal/cli/inbox/init_integration_test.go b/internal/cli/inbox/init_integration_test.go deleted file mode 100644 index 99b8bb0..0000000 --- a/internal/cli/inbox/init_integration_test.go +++ /dev/null @@ -1,83 +0,0 @@ -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/internal/cli/inbox/integration_test.go b/internal/cli/inbox/integration_test.go deleted file mode 100644 index da84f05..0000000 --- a/internal/cli/inbox/integration_test.go +++ /dev/null @@ -1,734 +0,0 @@ -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/internal/cli/inbox/list.go b/internal/cli/inbox/list.go deleted file mode 100644 index 4c99f28..0000000 --- a/internal/cli/inbox/list.go +++ /dev/null @@ -1,83 +0,0 @@ -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/internal/cli/inbox/list_integration_test.go b/internal/cli/inbox/list_integration_test.go deleted file mode 100644 index cd6081e..0000000 --- a/internal/cli/inbox/list_integration_test.go +++ /dev/null @@ -1,183 +0,0 @@ -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/internal/cli/inbox/renew.go b/internal/cli/inbox/renew.go deleted file mode 100644 index 735d1b0..0000000 --- a/internal/cli/inbox/renew.go +++ /dev/null @@ -1,76 +0,0 @@ -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/internal/cli/inbox/renew_integration_test.go b/internal/cli/inbox/renew_integration_test.go deleted file mode 100644 index 05ed1ee..0000000 --- a/internal/cli/inbox/renew_integration_test.go +++ /dev/null @@ -1,103 +0,0 @@ -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/internal/cli/inbox/reply.go b/internal/cli/inbox/reply.go deleted file mode 100644 index db1387a..0000000 --- a/internal/cli/inbox/reply.go +++ /dev/null @@ -1,104 +0,0 @@ -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/internal/cli/inbox/reply_integration_test.go b/internal/cli/inbox/reply_integration_test.go deleted file mode 100644 index 3f68a62..0000000 --- a/internal/cli/inbox/reply_integration_test.go +++ /dev/null @@ -1,138 +0,0 @@ -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/internal/cli/inbox/root.go b/internal/cli/inbox/root.go deleted file mode 100644 index 56045e6..0000000 --- a/internal/cli/inbox/root.go +++ /dev/null @@ -1,43 +0,0 @@ -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/internal/cli/inbox/send.go b/internal/cli/inbox/send.go deleted file mode 100644 index cf46a03..0000000 --- a/internal/cli/inbox/send.go +++ /dev/null @@ -1,117 +0,0 @@ -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/internal/cli/inbox/send_integration_test.go b/internal/cli/inbox/send_integration_test.go deleted file mode 100644 index ef167e4..0000000 --- a/internal/cli/inbox/send_integration_test.go +++ /dev/null @@ -1,230 +0,0 @@ -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/internal/cli/inbox/show.go b/internal/cli/inbox/show.go deleted file mode 100644 index f08dba0..0000000 --- a/internal/cli/inbox/show.go +++ /dev/null @@ -1,78 +0,0 @@ -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/internal/cli/inbox/show_integration_test.go b/internal/cli/inbox/show_integration_test.go deleted file mode 100644 index f22868c..0000000 --- a/internal/cli/inbox/show_integration_test.go +++ /dev/null @@ -1,196 +0,0 @@ -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/internal/cli/inbox/test_helpers_test.go b/internal/cli/inbox/test_helpers_test.go deleted file mode 100644 index 518882c..0000000 --- a/internal/cli/inbox/test_helpers_test.go +++ /dev/null @@ -1,78 +0,0 @@ -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/internal/cli/inbox/update.go b/internal/cli/inbox/update.go deleted file mode 100644 index 4cdfe17..0000000 --- a/internal/cli/inbox/update.go +++ /dev/null @@ -1,101 +0,0 @@ -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/internal/cli/inbox/update_integration_test.go b/internal/cli/inbox/update_integration_test.go deleted file mode 100644 index b0185b8..0000000 --- a/internal/cli/inbox/update_integration_test.go +++ /dev/null @@ -1,226 +0,0 @@ -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/internal/cli/inbox/wait_reply.go b/internal/cli/inbox/wait_reply.go deleted file mode 100644 index 63c3b1e..0000000 --- a/internal/cli/inbox/wait_reply.go +++ /dev/null @@ -1,85 +0,0 @@ -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/internal/cli/inbox/wait_reply_integration_test.go b/internal/cli/inbox/wait_reply_integration_test.go deleted file mode 100644 index a2b31a1..0000000 --- a/internal/cli/inbox/wait_reply_integration_test.go +++ /dev/null @@ -1,221 +0,0 @@ -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/internal/cli/inbox/watch.go b/internal/cli/inbox/watch.go deleted file mode 100644 index 2a128f8..0000000 --- a/internal/cli/inbox/watch.go +++ /dev/null @@ -1,90 +0,0 @@ -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/internal/cli/inbox/watch_integration_test.go b/internal/cli/inbox/watch_integration_test.go deleted file mode 100644 index 8d87eb5..0000000 --- a/internal/cli/inbox/watch_integration_test.go +++ /dev/null @@ -1,171 +0,0 @@ -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/internal/cli/orch/answer.go b/internal/cli/orch/answer.go deleted file mode 100644 index 5f80508..0000000 --- a/internal/cli/orch/answer.go +++ /dev/null @@ -1,77 +0,0 @@ -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/internal/cli/orch/blocked.go b/internal/cli/orch/blocked.go deleted file mode 100644 index dd59880..0000000 --- a/internal/cli/orch/blocked.go +++ /dev/null @@ -1,64 +0,0 @@ -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/internal/cli/orch/body.go b/internal/cli/orch/body.go deleted file mode 100644 index 0a53195..0000000 --- a/internal/cli/orch/body.go +++ /dev/null @@ -1,22 +0,0 @@ -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/internal/cli/orch/cancel.go b/internal/cli/orch/cancel.go deleted file mode 100644 index 74ce970..0000000 --- a/internal/cli/orch/cancel.go +++ /dev/null @@ -1,69 +0,0 @@ -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/internal/cli/orch/cleanup.go b/internal/cli/orch/cleanup.go deleted file mode 100644 index d652041..0000000 --- a/internal/cli/orch/cleanup.go +++ /dev/null @@ -1,84 +0,0 @@ -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/internal/cli/orch/command_contracts_core_test.go b/internal/cli/orch/command_contracts_core_test.go deleted file mode 100644 index f32523e..0000000 --- a/internal/cli/orch/command_contracts_core_test.go +++ /dev/null @@ -1,245 +0,0 @@ -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/internal/cli/orch/command_contracts_edges_test.go b/internal/cli/orch/command_contracts_edges_test.go deleted file mode 100644 index f03efcb..0000000 --- a/internal/cli/orch/command_contracts_edges_test.go +++ /dev/null @@ -1,219 +0,0 @@ -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/internal/cli/orch/command_contracts_remaining_test.go b/internal/cli/orch/command_contracts_remaining_test.go deleted file mode 100644 index 5c093cf..0000000 --- a/internal/cli/orch/command_contracts_remaining_test.go +++ /dev/null @@ -1,723 +0,0 @@ -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/internal/cli/orch/council.go b/internal/cli/orch/council.go deleted file mode 100644 index 05fde1d..0000000 --- a/internal/cli/orch/council.go +++ /dev/null @@ -1,16 +0,0 @@ -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/internal/cli/orch/council_report.go b/internal/cli/orch/council_report.go deleted file mode 100644 index ff1a0bf..0000000 --- a/internal/cli/orch/council_report.go +++ /dev/null @@ -1,111 +0,0 @@ -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/internal/cli/orch/council_report_contracts_test.go b/internal/cli/orch/council_report_contracts_test.go deleted file mode 100644 index 5114b5c..0000000 --- a/internal/cli/orch/council_report_contracts_test.go +++ /dev/null @@ -1,199 +0,0 @@ -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/internal/cli/orch/council_start.go b/internal/cli/orch/council_start.go deleted file mode 100644 index 105d5e4..0000000 --- a/internal/cli/orch/council_start.go +++ /dev/null @@ -1,88 +0,0 @@ -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/internal/cli/orch/council_tally.go b/internal/cli/orch/council_tally.go deleted file mode 100644 index a246003..0000000 --- a/internal/cli/orch/council_tally.go +++ /dev/null @@ -1,64 +0,0 @@ -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/internal/cli/orch/council_wait.go b/internal/cli/orch/council_wait.go deleted file mode 100644 index ce4417f..0000000 --- a/internal/cli/orch/council_wait.go +++ /dev/null @@ -1,69 +0,0 @@ -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/internal/cli/orch/db.go b/internal/cli/orch/db.go deleted file mode 100644 index 9449570..0000000 --- a/internal/cli/orch/db.go +++ /dev/null @@ -1,22 +0,0 @@ -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/internal/cli/orch/dep.go b/internal/cli/orch/dep.go deleted file mode 100644 index 5f2e7f1..0000000 --- a/internal/cli/orch/dep.go +++ /dev/null @@ -1,76 +0,0 @@ -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/internal/cli/orch/dispatch.go b/internal/cli/orch/dispatch.go deleted file mode 100644 index c90623e..0000000 --- a/internal/cli/orch/dispatch.go +++ /dev/null @@ -1,93 +0,0 @@ -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/internal/cli/orch/execute.go b/internal/cli/orch/execute.go deleted file mode 100644 index 1e1ac1e..0000000 --- a/internal/cli/orch/execute.go +++ /dev/null @@ -1,113 +0,0 @@ -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/internal/cli/orch/git_test_helpers_test.go b/internal/cli/orch/git_test_helpers_test.go deleted file mode 100644 index f8a6193..0000000 --- a/internal/cli/orch/git_test_helpers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -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/internal/cli/orch/integration_test.go b/internal/cli/orch/integration_test.go deleted file mode 100644 index 37ecf03..0000000 --- a/internal/cli/orch/integration_test.go +++ /dev/null @@ -1,2164 +0,0 @@ -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/internal/cli/orch/ready.go b/internal/cli/orch/ready.go deleted file mode 100644 index 99645de..0000000 --- a/internal/cli/orch/ready.go +++ /dev/null @@ -1,69 +0,0 @@ -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/internal/cli/orch/reassign.go b/internal/cli/orch/reassign.go deleted file mode 100644 index f872100..0000000 --- a/internal/cli/orch/reassign.go +++ /dev/null @@ -1,80 +0,0 @@ -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/internal/cli/orch/reconcile.go b/internal/cli/orch/reconcile.go deleted file mode 100644 index d1e218c..0000000 --- a/internal/cli/orch/reconcile.go +++ /dev/null @@ -1,58 +0,0 @@ -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/internal/cli/orch/retry.go b/internal/cli/orch/retry.go deleted file mode 100644 index 704294c..0000000 --- a/internal/cli/orch/retry.go +++ /dev/null @@ -1,85 +0,0 @@ -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/internal/cli/orch/root.go b/internal/cli/orch/root.go deleted file mode 100644 index 912c5a3..0000000 --- a/internal/cli/orch/root.go +++ /dev/null @@ -1,42 +0,0 @@ -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/internal/cli/orch/run.go b/internal/cli/orch/run.go deleted file mode 100644 index 8a4b226..0000000 --- a/internal/cli/orch/run.go +++ /dev/null @@ -1,124 +0,0 @@ -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/internal/cli/orch/status.go b/internal/cli/orch/status.go deleted file mode 100644 index 4c93b61..0000000 --- a/internal/cli/orch/status.go +++ /dev/null @@ -1,65 +0,0 @@ -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/internal/cli/orch/task.go b/internal/cli/orch/task.go deleted file mode 100644 index 76187f5..0000000 --- a/internal/cli/orch/task.go +++ /dev/null @@ -1,88 +0,0 @@ -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/internal/cli/orch/test_helpers_test.go b/internal/cli/orch/test_helpers_test.go deleted file mode 100644 index 32b1cd1..0000000 --- a/internal/cli/orch/test_helpers_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package orch - -import ( - "bytes" - "encoding/json" - "testing" - - inboxcli "ai-workflow-skill/internal/cli/inbox" -) - -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) { - var stdout bytes.Buffer - var stderr bytes.Buffer - exitCode := inboxcli.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 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/internal/cli/orch/wait.go b/internal/cli/orch/wait.go deleted file mode 100644 index 9041d6e..0000000 --- a/internal/cli/orch/wait.go +++ /dev/null @@ -1,97 +0,0 @@ -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/internal/cli/orch/worktree.go b/internal/cli/orch/worktree.go deleted file mode 100644 index 380518a..0000000 --- a/internal/cli/orch/worktree.go +++ /dev/null @@ -1,503 +0,0 @@ -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/internal/db/migrate.go b/internal/db/migrate.go deleted file mode 100644 index 1cf8e11..0000000 --- a/internal/db/migrate.go +++ /dev/null @@ -1,50 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "embed" - "fmt" - "sort" -) - -//go:embed schema/*.sql -var schemaFS embed.FS - -func ApplyMigrations(ctx context.Context, db *sql.DB) error { - files, err := schemaFS.ReadDir("schema") - if err != nil { - return fmt.Errorf("read embedded schema directory: %w", err) - } - - names := make([]string, 0, len(files)) - for _, file := range files { - if file.IsDir() { - continue - } - names = append(names, file.Name()) - } - sort.Strings(names) - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin schema transaction: %w", err) - } - defer tx.Rollback() - - for _, name := range names { - content, err := schemaFS.ReadFile("schema/" + name) - if err != nil { - return fmt.Errorf("read embedded schema file %q: %w", name, err) - } - if _, err := tx.ExecContext(ctx, string(content)); err != nil { - return fmt.Errorf("apply schema file %q: %w", name, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit schema transaction: %w", err) - } - - return nil -} diff --git a/internal/db/open.go b/internal/db/open.go deleted file mode 100644 index 401a937..0000000 --- a/internal/db/open.go +++ /dev/null @@ -1,38 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - - _ "modernc.org/sqlite" -) - -func Open(ctx context.Context, dbPath string) (*sql.DB, error) { - if err := ensureParentDir(dbPath); err != nil { - return nil, err - } - - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("open sqlite database: %w", err) - } - - if err := applyPragmas(ctx, db); err != nil { - _ = db.Close() - return nil, err - } - - return db, nil -} - -func ensureParentDir(dbPath string) error { - parent := filepath.Dir(dbPath) - if parent == "." || parent == "" { - return nil - } - - return os.MkdirAll(parent, 0o755) -} diff --git a/internal/db/pragmas.go b/internal/db/pragmas.go deleted file mode 100644 index a6efc84..0000000 --- a/internal/db/pragmas.go +++ /dev/null @@ -1,23 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "fmt" -) - -func applyPragmas(ctx context.Context, db *sql.DB) error { - pragmas := []string{ - "PRAGMA foreign_keys = ON;", - "PRAGMA journal_mode = WAL;", - "PRAGMA busy_timeout = 5000;", - } - - for _, pragma := range pragmas { - if _, err := db.ExecContext(ctx, pragma); err != nil { - return fmt.Errorf("apply pragma %q: %w", pragma, err) - } - } - - return nil -} diff --git a/internal/db/schema/001_inbox.sql b/internal/db/schema/001_inbox.sql deleted file mode 100644 index 6ebf2b2..0000000 --- a/internal/db/schema/001_inbox.sql +++ /dev/null @@ -1,51 +0,0 @@ -CREATE TABLE IF NOT EXISTS threads ( - thread_id TEXT PRIMARY KEY, - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - subject TEXT NOT NULL, - created_by TEXT NOT NULL, - assigned_to TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL DEFAULT 'normal', - latest_message_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS messages ( - message_id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL, - from_agent TEXT NOT NULL, - to_agent TEXT NOT NULL, - kind TEXT NOT NULL, - summary TEXT NOT NULL, - body TEXT NOT NULL DEFAULT '', - payload_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - FOREIGN KEY(thread_id) REFERENCES threads(thread_id) -); - -CREATE TABLE IF NOT EXISTS leases ( - thread_id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - lease_token TEXT NOT NULL, - claimed_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - released_at TEXT -); - -CREATE TABLE IF NOT EXISTS artifacts ( - artifact_id TEXT PRIMARY KEY, - message_id TEXT NOT NULL, - path TEXT NOT NULL, - kind TEXT NOT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - FOREIGN KEY(message_id) REFERENCES messages(message_id) -); - -CREATE INDEX IF NOT EXISTS idx_threads_status_assigned - ON threads(status, assigned_to, updated_at); - -CREATE INDEX IF NOT EXISTS idx_messages_thread_created - ON messages(thread_id, created_at); diff --git a/internal/db/schema/002_orch.sql b/internal/db/schema/002_orch.sql deleted file mode 100644 index e0cfeaf..0000000 --- a/internal/db/schema/002_orch.sql +++ /dev/null @@ -1,52 +0,0 @@ -CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - goal TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS tasks ( - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - title TEXT NOT NULL, - summary TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL, - default_to TEXT, - priority TEXT NOT NULL DEFAULT 'normal', - acceptance_json TEXT NOT NULL DEFAULT '[]', - latest_attempt_no INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (run_id, task_id), - FOREIGN KEY(run_id) REFERENCES runs(run_id) -); - -CREATE TABLE IF NOT EXISTS task_dependencies ( - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - depends_on_task_id TEXT NOT NULL, - PRIMARY KEY (run_id, task_id, depends_on_task_id) -); - -CREATE TABLE IF NOT EXISTS task_attempts ( - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - attempt_no INTEGER NOT NULL, - assigned_to TEXT NOT NULL, - thread_id TEXT NOT NULL, - base_ref TEXT, - base_commit TEXT, - branch_name TEXT, - worktree_path TEXT, - workspace_status TEXT, - result_commit TEXT, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (run_id, task_id, attempt_no) -); - -CREATE INDEX IF NOT EXISTS idx_tasks_run_status - ON tasks(run_id, status, priority, updated_at); diff --git a/internal/db/schema/003_events.sql b/internal/db/schema/003_events.sql deleted file mode 100644 index f1ceb3a..0000000 --- a/internal/db/schema/003_events.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS events ( - event_id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - task_id TEXT NOT NULL, - thread_id TEXT, - source TEXT NOT NULL, - event_type TEXT NOT NULL, - message_id TEXT, - summary TEXT NOT NULL DEFAULT '', - payload_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_events_run_event - ON events(run_id, event_id); - -CREATE INDEX IF NOT EXISTS idx_events_thread_event - ON events(thread_id, event_id); diff --git a/internal/db/schema/004_council.sql b/internal/db/schema/004_council.sql deleted file mode 100644 index 21695c8..0000000 --- a/internal/db/schema/004_council.sql +++ /dev/null @@ -1,45 +0,0 @@ -CREATE TABLE IF NOT EXISTS council_runs ( - run_id TEXT PRIMARY KEY, - mode TEXT NOT NULL, - target_type TEXT NOT NULL, - output_mode TEXT NOT NULL, - only_unanimous INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS council_reviewers ( - run_id TEXT NOT NULL, - reviewer_role TEXT NOT NULL, - task_id TEXT NOT NULL, - status TEXT NOT NULL, - PRIMARY KEY (run_id, reviewer_role) -); - -CREATE TABLE IF NOT EXISTS council_findings ( - run_id TEXT NOT NULL, - reviewer_role TEXT NOT NULL, - finding_id TEXT NOT NULL, - title TEXT NOT NULL, - summary TEXT NOT NULL, - proposal TEXT NOT NULL, - rationale TEXT NOT NULL, - confidence TEXT NOT NULL, - tags_json TEXT NOT NULL DEFAULT '[]', - target_refs_json TEXT NOT NULL DEFAULT '{}', - PRIMARY KEY (run_id, reviewer_role, finding_id) -); - -CREATE TABLE IF NOT EXISTS council_groups ( - run_id TEXT NOT NULL, - group_id TEXT NOT NULL, - proposal TEXT NOT NULL, - bucket TEXT NOT NULL, - support_count INTEGER NOT NULL, - supporters_json TEXT NOT NULL DEFAULT '[]', - dissenters_json TEXT NOT NULL DEFAULT '[]', - rationale_summary TEXT NOT NULL DEFAULT '', - tags_json TEXT NOT NULL DEFAULT '[]', - source_finding_ids_json TEXT NOT NULL DEFAULT '[]', - PRIMARY KEY (run_id, group_id) -); diff --git a/internal/db/schema/005_inbox_reads.sql b/internal/db/schema/005_inbox_reads.sql deleted file mode 100644 index d830226..0000000 --- a/internal/db/schema/005_inbox_reads.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS thread_reads ( - thread_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - last_read_message_id TEXT NOT NULL, - last_read_at TEXT NOT NULL, - PRIMARY KEY(thread_id, agent_id), - FOREIGN KEY(thread_id) REFERENCES threads(thread_id), - FOREIGN KEY(last_read_message_id) REFERENCES messages(message_id) -); - -CREATE INDEX IF NOT EXISTS idx_thread_reads_agent - ON thread_reads(agent_id, last_read_at); diff --git a/internal/db/schema/006_council_inputs.sql b/internal/db/schema/006_council_inputs.sql deleted file mode 100644 index b1709b1..0000000 --- a/internal/db/schema/006_council_inputs.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS council_inputs ( - run_id TEXT PRIMARY KEY, - prompt TEXT NOT NULL DEFAULT '', - target_file TEXT NOT NULL DEFAULT '', - repo_path TEXT NOT NULL DEFAULT '', - target_task_id TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/internal/db/schema/007_council_reports.sql b/internal/db/schema/007_council_reports.sql deleted file mode 100644 index cec16f4..0000000 --- a/internal/db/schema/007_council_reports.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS council_reports ( - run_id TEXT PRIMARY KEY, - show_json TEXT NOT NULL DEFAULT '[]', - summary_json TEXT NOT NULL DEFAULT '{}', - markdown_path TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/internal/httpapi/response.go b/internal/httpapi/response.go deleted file mode 100644 index 1b3aa3e..0000000 --- a/internal/httpapi/response.go +++ /dev/null @@ -1,58 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - - "ai-workflow-skill/packages/coord-core/store" -) - -type errorEnvelope struct { - Error errorPayload `json:"error"` -} - -type errorPayload struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func writeJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - _ = enc.Encode(payload) -} - -func writeError(w http.ResponseWriter, err error) { - status, code := classifyError(err) - writeJSON(w, status, errorEnvelope{ - Error: errorPayload{ - Code: code, - Message: errorMessage(err), - }, - }) -} - -func classifyError(err error) (int, string) { - switch { - case errors.Is(err, store.ErrInvalidInput): - return http.StatusBadRequest, "invalid_input" - case errors.Is(err, store.ErrRunNotFound), errors.Is(err, store.ErrTaskNotFound), errors.Is(err, store.ErrThreadNotFound): - return http.StatusNotFound, "not_found" - case errors.Is(err, store.ErrInvalidState): - return http.StatusConflict, "invalid_state" - default: - return http.StatusInternalServerError, "internal_error" - } -} - -func errorMessage(err error) string { - if err == nil { - return "unknown error" - } - return fmt.Sprintf("%v", err) -} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go deleted file mode 100644 index d9d8f55..0000000 --- a/internal/httpapi/router.go +++ /dev/null @@ -1,87 +0,0 @@ -package httpapi - -import ( - "context" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - - "ai-workflow-skill/internal/query" - "ai-workflow-skill/packages/coord-core/store" -) - -type readService interface { - ListRuns(ctx context.Context) ([]query.RunListItem, error) - GetRunDetail(ctx context.Context, runID string) (query.RunDetail, error) - ListRunTasks(ctx context.Context, runID string) ([]store.Task, error) - ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error) - GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error) -} - -func NewRouter(service readService) http.Handler { - router := chi.NewRouter() - router.Use(middleware.RequestID) - router.Use(middleware.Recoverer) - router.Use(middleware.Timeout(30 * time.Second)) - - router.Get("/health", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - }) - }) - - router.Route("/api", func(r chi.Router) { - r.Get("/runs", func(w http.ResponseWriter, r *http.Request) { - runs, err := service.ListRuns(r.Context()) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) - }) - - r.Get("/runs/{runID}", func(w http.ResponseWriter, r *http.Request) { - runID := chi.URLParam(r, "runID") - run, err := service.GetRunDetail(r.Context(), runID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"run": run}) - }) - - r.Get("/runs/{runID}/tasks", func(w http.ResponseWriter, r *http.Request) { - runID := chi.URLParam(r, "runID") - tasks, err := service.ListRunTasks(r.Context(), runID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks}) - }) - - r.Get("/runs/{runID}/blocked", func(w http.ResponseWriter, r *http.Request) { - runID := chi.URLParam(r, "runID") - blocked, err := service.ListBlockedTasks(r.Context(), runID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"blocked": blocked}) - }) - - r.Get("/threads/{threadID}", func(w http.ResponseWriter, r *http.Request) { - threadID := chi.URLParam(r, "threadID") - thread, err := service.GetThreadDetail(r.Context(), threadID) - if err != nil { - writeError(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"thread": thread}) - }) - }) - - return router -} diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go deleted file mode 100644 index b1376e0..0000000 --- a/internal/httpapi/router_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package httpapi - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "ai-workflow-skill/internal/app" - dbpkg "ai-workflow-skill/packages/coord-core/db" - "ai-workflow-skill/packages/coord-core/store" -) - -func TestRouterExposesReadOnlyWebEndpoints(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - dbPath := filepath.Join(t.TempDir(), "coord.db") - sqlDB, err := dbpkg.Open(ctx, dbPath) - if err != nil { - t.Fatalf("open db: %v", err) - } - defer sqlDB.Close() - - if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil { - t.Fatalf("apply migrations: %v", err) - } - - orchStore := store.NewOrchStore(sqlDB) - inboxStore := store.NewInboxStore(sqlDB) - - _, err = orchStore.CreateRun(ctx, store.CreateRunInput{ - RunID: "run_web_001", - Goal: "Build the web control plane", - Summary: "Initial HTTP slice", - }) - if err != nil { - t.Fatalf("create run: %v", err) - } - - _, err = orchStore.AddTask(ctx, store.AddTaskInput{ - RunID: "run_web_001", - TaskID: "T1", - Title: "Implement read API", - Summary: "Expose run state over HTTP", - DefaultTo: "worker-a", - }) - if err != nil { - t.Fatalf("add task T1: %v", err) - } - - _, err = orchStore.AddTask(ctx, store.AddTaskInput{ - RunID: "run_web_001", - TaskID: "T2", - Title: "Build React shell", - Summary: "Scaffold the frontend workspace", - DefaultTo: "worker-b", - }) - if err != nil { - t.Fatalf("add task T2: %v", err) - } - - dispatch, err := orchStore.DispatchTask(ctx, store.DispatchInput{ - RunID: "run_web_001", - TaskID: "T1", - ToAgent: "worker-a", - Body: "Expose the initial HTTP API.", - }) - if err != nil { - t.Fatalf("dispatch task: %v", err) - } - - if _, err := inboxStore.ClaimThread(ctx, store.ClaimInput{ - ThreadID: dispatch.Attempt.ThreadID, - Agent: "worker-a", - LeaseSeconds: 300, - }); err != nil { - t.Fatalf("claim thread: %v", err) - } - - if _, _, err := inboxStore.UpdateThreadStatus(ctx, store.UpdateInput{ - ThreadID: dispatch.Attempt.ThreadID, - Agent: "worker-a", - Status: "blocked", - Summary: "Need the API shape", - Body: "Confirm whether run detail should include blocked tasks.", - }); err != nil { - t.Fatalf("mark thread blocked: %v", err) - } - - if _, err := orchStore.ReconcileRun(ctx, "run_web_001"); err != nil { - t.Fatalf("reconcile run: %v", err) - } - - handler := NewRouter(app.NewWebService(sqlDB)) - - assertStatusAndJSONField(t, handler, "/health", http.StatusOK, []string{"status"}, "ok") - assertStatusAndJSONField(t, handler, "/api/runs", http.StatusOK, []string{"runs", "0", "run", "run_id"}, "run_web_001") - assertStatusAndJSONField(t, handler, "/api/runs/run_web_001", http.StatusOK, []string{"run", "run", "run_id"}, "run_web_001") - assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/tasks", http.StatusOK, []string{"tasks", "0", "task_id"}, "T1") - assertStatusAndJSONField(t, handler, "/api/runs/run_web_001/blocked", http.StatusOK, []string{"blocked", "0", "task", "task_id"}, "T1") - assertStatusAndJSONField(t, handler, "/api/threads/"+dispatch.Attempt.ThreadID, http.StatusOK, []string{"thread", "thread", "thread_id"}, dispatch.Attempt.ThreadID) -} - -func TestRouterMapsNotFoundErrors(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - dbPath := filepath.Join(t.TempDir(), "coord.db") - sqlDB, err := dbpkg.Open(ctx, dbPath) - if err != nil { - t.Fatalf("open db: %v", err) - } - defer sqlDB.Close() - - if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil { - t.Fatalf("apply migrations: %v", err) - } - - handler := NewRouter(app.NewWebService(sqlDB)) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/runs/missing-run", nil) - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", rec.Code) - } - - var payload map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { - t.Fatalf("decode response: %v", err) - } - - code := nestedString(t, payload, "error", "code") - if code != "not_found" { - t.Fatalf("expected not_found error code, got %q", code) - } -} - -func assertStatusAndJSONField(t *testing.T, handler http.Handler, path string, wantStatus int, fieldPath []string, want string) { - t.Helper() - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, path, nil) - handler.ServeHTTP(rec, req) - - if rec.Code != wantStatus { - t.Fatalf("GET %s: expected status %d, got %d", path, wantStatus, rec.Code) - } - - var payload map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { - t.Fatalf("GET %s: decode response: %v", path, err) - } - - got := nestedString(t, payload, fieldPath...) - if got != want { - t.Fatalf("GET %s: expected %q at %v, got %q", path, want, fieldPath, got) - } -} - -func nestedString(t *testing.T, value any, path ...string) string { - t.Helper() - - current := value - for _, part := range path { - switch typed := current.(type) { - case map[string]any: - current = typed[part] - case []any: - if len(part) != 1 || part[0] < '0' || part[0] > '9' { - t.Fatalf("path segment %q is not a numeric index", part) - } - index := int(part[0] - '0') - if index >= len(typed) { - t.Fatalf("index %d out of range for path %v", index, path) - } - current = typed[index] - default: - t.Fatalf("unsupported type %T at path %v", current, path) - } - } - - got, ok := current.(string) - if !ok { - t.Fatalf("expected string at path %v, got %T", path, current) - } - return got -} diff --git a/internal/protocol/cli_error.go b/internal/protocol/cli_error.go deleted file mode 100644 index 75fd04d..0000000 --- a/internal/protocol/cli_error.go +++ /dev/null @@ -1,33 +0,0 @@ -package protocol - -type CLIError struct { - Code string - ExitCode int - Message string - Err error -} - -func (e *CLIError) Error() string { - return e.Message -} - -func (e *CLIError) Unwrap() error { - return e.Err -} - -func NewCLIError(code string, exitCode int, message string, err error) error { - return &CLIError{ - Code: code, - ExitCode: exitCode, - Message: message, - Err: err, - } -} - -func InvalidInput(message string, err error) error { - return NewCLIError("invalid_input", 30, message, err) -} - -func NoMatchingWork(message string) error { - return NewCLIError("no_matching_work", 10, message, nil) -} diff --git a/internal/protocol/json.go b/internal/protocol/json.go deleted file mode 100644 index 9cb20db..0000000 --- a/internal/protocol/json.go +++ /dev/null @@ -1,28 +0,0 @@ -package protocol - -import ( - "encoding/json" - "io" -) - -type Success struct { - OK bool `json:"ok"` - Command string `json:"command"` - Data map[string]any `json:"data,omitempty"` -} - -type Error struct { - OK bool `json:"ok"` - Error ErrorPayload `json:"error"` -} - -type ErrorPayload struct { - Code string `json:"code"` - Message string `json:"message"` -} - -func WriteJSON(w io.Writer, v any) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/internal/query/read_service.go b/internal/query/read_service.go deleted file mode 100644 index bc4093d..0000000 --- a/internal/query/read_service.go +++ /dev/null @@ -1,211 +0,0 @@ -package query - -import ( - "context" - "database/sql" - "fmt" - "time" - - "ai-workflow-skill/packages/coord-core/store" -) - -type ReadService struct { - db *sql.DB - orch *store.OrchStore - inbox *store.InboxStore -} - -type RunListItem struct { - Run store.Run `json:"run"` - TaskCounts map[string]int `json:"task_counts"` - TotalTasks int `json:"total_tasks"` -} - -type RunDetail struct { - Run store.Run `json:"run"` - TaskCounts map[string]int `json:"task_counts"` - TotalTasks int `json:"total_tasks"` - Tasks []store.Task `json:"tasks"` - BlockedTasks []store.BlockedTask `json:"blocked_tasks"` -} - -func NewReadService(db *sql.DB) *ReadService { - return &ReadService{ - db: db, - orch: store.NewOrchStore(db), - inbox: store.NewInboxStore(db), - } -} - -func (s *ReadService) ListRuns(ctx context.Context) ([]RunListItem, error) { - rows, err := s.db.QueryContext( - ctx, - `SELECT run_id, goal, summary, status, created_at, updated_at - FROM runs - ORDER BY updated_at DESC, created_at DESC`, - ) - if err != nil { - return nil, fmt.Errorf("query runs: %w", err) - } - defer rows.Close() - - var runs []store.Run - runIDs := make([]string, 0) - for rows.Next() { - var ( - run store.Run - createdAt, updated string - ) - if err := rows.Scan( - &run.RunID, - &run.Goal, - &run.Summary, - &run.Status, - &createdAt, - &updated, - ); err != nil { - return nil, fmt.Errorf("scan run list row: %w", err) - } - - run.CreatedAt = parseRFC3339(createdAt) - run.UpdatedAt = parseRFC3339(updated) - runs = append(runs, run) - runIDs = append(runIDs, run.RunID) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate runs: %w", err) - } - - countsByRunID, err := s.collectTaskCounts(ctx, runIDs) - if err != nil { - return nil, err - } - - items := make([]RunListItem, 0, len(runs)) - for _, run := range runs { - taskCounts := countsByRunID[run.RunID] - if taskCounts == nil { - taskCounts = map[string]int{} - } - - items = append(items, RunListItem{ - Run: run, - TaskCounts: taskCounts, - TotalTasks: totalTasks(taskCounts), - }) - } - - return items, nil -} - -func (s *ReadService) GetRunDetail(ctx context.Context, runID string) (RunDetail, error) { - overview, err := s.orch.GetRunOverview(ctx, runID) - if err != nil { - return RunDetail{}, err - } - - blocked, err := s.orch.ListBlockedTasks(ctx, runID) - if err != nil { - return RunDetail{}, err - } - - return RunDetail{ - Run: overview.Run, - TaskCounts: overview.TaskCounts, - TotalTasks: totalTasks(overview.TaskCounts), - Tasks: overview.Tasks, - BlockedTasks: blocked, - }, nil -} - -func (s *ReadService) ListRunTasks(ctx context.Context, runID string) ([]store.Task, error) { - detail, err := s.GetRunDetail(ctx, runID) - if err != nil { - return nil, err - } - return detail.Tasks, nil -} - -func (s *ReadService) ListBlockedTasks(ctx context.Context, runID string) ([]store.BlockedTask, error) { - return s.orch.ListBlockedTasks(ctx, runID) -} - -func (s *ReadService) GetThreadDetail(ctx context.Context, threadID string) (store.ThreadDetail, error) { - return s.inbox.GetThread(ctx, threadID) -} - -func (s *ReadService) collectTaskCounts(ctx context.Context, runIDs []string) (map[string]map[string]int, error) { - result := make(map[string]map[string]int, len(runIDs)) - if len(runIDs) == 0 { - return result, nil - } - - args := make([]any, 0, len(runIDs)) - for _, runID := range runIDs { - args = append(args, runID) - } - - rows, err := s.db.QueryContext( - ctx, - `SELECT run_id, status, COUNT(*) - FROM tasks - WHERE run_id IN (`+placeholders(len(runIDs))+`) - GROUP BY run_id, status`, - args..., - ) - if err != nil { - return nil, fmt.Errorf("query task counts for runs: %w", err) - } - defer rows.Close() - - for rows.Next() { - var ( - runID string - status string - count int - ) - if err := rows.Scan(&runID, &status, &count); err != nil { - return nil, fmt.Errorf("scan run task count: %w", err) - } - if result[runID] == nil { - result[runID] = make(map[string]int) - } - result[runID][status] = count - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate run task counts: %w", err) - } - - return result, nil -} - -func totalTasks(counts map[string]int) int { - total := 0 - for _, count := range counts { - total += count - } - return total -} - -func placeholders(count int) string { - if count <= 0 { - return "" - } - - buf := make([]byte, 0, count*2-1) - for i := 0; i < count; i++ { - if i > 0 { - buf = append(buf, ',') - } - buf = append(buf, '?') - } - return string(buf) -} - -func parseRFC3339(value string) time.Time { - parsed, err := time.Parse(time.RFC3339Nano, value) - if err != nil { - return time.Time{} - } - return parsed -} diff --git a/internal/store/council.go b/internal/store/council.go deleted file mode 100644 index 628501d..0000000 --- a/internal/store/council.go +++ /dev/null @@ -1,1503 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" -) - -var councilReviewerRoles = []string{ - "architecture-reviewer", - "implementation-reviewer", - "risk-reviewer", -} - -type CouncilRun struct { - RunID string `json:"run_id"` - Mode string `json:"mode"` - TargetType string `json:"target_type"` - OutputMode string `json:"output_mode"` - OnlyUnanimous bool `json:"only_unanimous"` -} - -type CouncilInput struct { - RunID string `json:"run_id"` - Prompt string `json:"prompt,omitempty"` - TargetFile string `json:"target_file,omitempty"` - RepoPath string `json:"repo_path,omitempty"` - TargetTaskID string `json:"task_id,omitempty"` -} - -type CouncilReviewer struct { - ReviewerRole string `json:"reviewer_role"` - TaskID string `json:"task_id"` - Status string `json:"status"` -} - -type CouncilStartInput struct { - RunID string - Target string - TargetFile string - RepoPath string - TargetTaskID string - TargetType string - Mode string - OutputMode string - OnlyUnanimous bool -} - -type CouncilStartResult struct { - Run CouncilRun `json:"run"` - Input CouncilInput `json:"input"` - Reviewers []CouncilReviewer `json:"reviewers"` -} - -type CouncilWaitInput struct { - RunID string - Timeout time.Duration -} - -type CouncilWaitResult struct { - Woke bool `json:"woke"` - RunID string `json:"run_id"` - AllComplete bool `json:"all_complete"` - ReviewerStatuses []CouncilReviewer `json:"reviewers"` -} - -type CouncilFinding struct { - RunID string `json:"run_id"` - ReviewerRole string `json:"reviewer_role"` - FindingID string `json:"finding_id"` - Title string `json:"title"` - Summary string `json:"summary"` - Proposal string `json:"proposal"` - Rationale string `json:"rationale"` - Confidence string `json:"confidence"` - TagsJSON json.RawMessage `json:"tags_json"` - TargetRefsJSON json.RawMessage `json:"target_refs_json"` -} - -type CouncilGroup struct { - RunID string `json:"run_id"` - GroupID string `json:"group_id"` - Proposal string `json:"proposal"` - Bucket string `json:"bucket"` - SupportCount int `json:"support_count"` - SupportersJSON json.RawMessage `json:"supporters_json"` - DissentersJSON json.RawMessage `json:"dissenters_json"` - RationaleSummary string `json:"rationale_summary"` - TagsJSON json.RawMessage `json:"tags_json"` - SourceFindingIDsJSON json.RawMessage `json:"source_finding_ids_json"` -} - -type CouncilTallyInput struct { - RunID string - Similarity string -} - -type CouncilTallyResult struct { - RunID string `json:"run_id"` - Similarity string `json:"similarity"` - Counts map[string]int `json:"counts"` - GroupedRecommendations []CouncilGroup `json:"grouped_recommendations"` -} - -type CouncilReportInput struct { - RunID string - Show string -} - -type CouncilReportArtifact struct { - Kind string `json:"kind"` - Path string `json:"path"` -} - -type CouncilReportResult struct { - RunID string `json:"run_id"` - Show []string `json:"show"` - Summary map[string]int `json:"summary"` - GroupedRecommendations []CouncilGroup `json:"grouped_recommendations"` - Markdown string `json:"markdown,omitempty"` - ReportArtifacts []CouncilReportArtifact `json:"report_artifacts,omitempty"` -} - -type CouncilPersistReportInput struct { - RunID string - Show []string - Summary map[string]int - MarkdownPath string -} - -type councilReviewerOutput struct { - ReviewerRole string `json:"reviewer_role"` - Findings []councilFindingOutput `json:"findings"` -} - -type councilFindingOutput struct { - Title string `json:"title"` - Summary string `json:"summary"` - Proposal string `json:"proposal"` - Rationale string `json:"rationale"` - Confidence string `json:"confidence"` - Tags json.RawMessage `json:"tags"` - TargetRefs json.RawMessage `json:"target_refs"` -} - -func (s *OrchStore) StartCouncil(ctx context.Context, input CouncilStartInput) (CouncilStartResult, error) { - runID := strings.TrimSpace(input.RunID) - if runID == "" { - return CouncilStartResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - councilInput, err := normalizeCouncilInput(input) - if err != nil { - return CouncilStartResult{}, err - } - - councilRun, err := normalizeCouncilRun(input) - if err != nil { - return CouncilStartResult{}, err - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("begin council start transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, runID); err == nil { - return CouncilStartResult{}, fmt.Errorf("%w: run %s already exists", ErrInvalidState, runID) - } else if !errors.Is(err, ErrRunNotFound) { - return CouncilStartResult{}, err - } - - goal := buildCouncilRunGoal(councilInput) - summary := buildCouncilRunSummary(councilRun, councilInput) - - _, err = tx.ExecContext( - ctx, - `INSERT INTO runs (run_id, goal, summary, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?)`, - runID, - goal, - summary, - "active", - formatTime(now), - formatTime(now), - ) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("insert council run into runs: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - Source: "orch", - EventType: "run_initialized", - Summary: summary, - PayloadJSON: marshalJSON(map[string]any{"goal": goal, "summary": summary}), - CreatedAt: now, - }); err != nil { - return CouncilStartResult{}, err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO council_runs ( - run_id, mode, target_type, output_mode, only_unanimous, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - runID, - councilRun.Mode, - councilRun.TargetType, - councilRun.OutputMode, - boolToInt(councilRun.OnlyUnanimous), - formatTime(now), - formatTime(now), - ) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("insert council run metadata: %w", err) - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO council_inputs ( - run_id, prompt, target_file, repo_path, target_task_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - runID, - councilInput.Prompt, - councilInput.TargetFile, - councilInput.RepoPath, - councilInput.TargetTaskID, - formatTime(now), - formatTime(now), - ) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("insert council input metadata: %w", err) - } - - reviewers := make([]CouncilReviewer, 0, len(councilReviewerRoles)) - for i, reviewerRole := range councilReviewerRoles { - taskID := fmt.Sprintf("CR%d", i+1) - task := Task{ - RunID: runID, - TaskID: taskID, - Title: buildCouncilTaskTitle(reviewerRole), - Summary: buildCouncilTaskSummary(reviewerRole), - Status: "ready", - DefaultTo: reviewerRole, - Priority: "normal", - AcceptanceJSON: []byte(buildCouncilTaskAcceptanceJSON(councilRun, councilInput, reviewerRole)), - CreatedAt: now, - UpdatedAt: now, - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO tasks ( - run_id, task_id, title, summary, status, default_to, priority, - acceptance_json, latest_attempt_no, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`, - task.RunID, - task.TaskID, - task.Title, - task.Summary, - task.Status, - nullIfEmpty(task.DefaultTo), - task.Priority, - string(task.AcceptanceJSON), - formatTime(task.CreatedAt), - formatTime(task.UpdatedAt), - ) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("insert council reviewer task: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - TaskID: taskID, - Source: "orch", - EventType: "task_added", - Summary: task.Title, - PayloadJSON: marshalJSON(map[string]any{"title": task.Title, "priority": task.Priority}), - CreatedAt: now, - }); err != nil { - return CouncilStartResult{}, err - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - TaskID: taskID, - Source: "orch", - EventType: "task_ready", - Summary: task.Title, - PayloadJSON: marshalJSON(map[string]any{"task_id": taskID}), - CreatedAt: now, - }); err != nil { - return CouncilStartResult{}, err - } - - dispatchResult, finalizeWorkspace, err := s.dispatchTaskTx( - ctx, - tx, - task, - reviewerRole, - buildCouncilTaskBody(councilRun, councilInput, reviewerRole), - "", - nil, - now, - ) - if err != nil { - return CouncilStartResult{}, err - } - defer finalizeWorkspace(false) - - _, err = tx.ExecContext( - ctx, - `INSERT INTO council_reviewers (run_id, reviewer_role, task_id, status) - VALUES (?, ?, ?, ?)`, - runID, - reviewerRole, - taskID, - dispatchResult.Task.Status, - ) - if err != nil { - return CouncilStartResult{}, fmt.Errorf("insert council reviewer row: %w", err) - } - - reviewers = append(reviewers, CouncilReviewer{ - ReviewerRole: reviewerRole, - TaskID: taskID, - Status: dispatchResult.Task.Status, - }) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - Source: "orch", - EventType: "council_started", - Summary: "council reviewers dispatched", - PayloadJSON: marshalJSON(map[string]any{ - "mode": councilRun.Mode, - "target_type": councilRun.TargetType, - "output_mode": councilRun.OutputMode, - "only_unanimous": councilRun.OnlyUnanimous, - "reviewers": reviewers, - }), - CreatedAt: now, - }); err != nil { - return CouncilStartResult{}, err - } - - if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil { - return CouncilStartResult{}, err - } - - if err := tx.Commit(); err != nil { - return CouncilStartResult{}, fmt.Errorf("commit council start transaction: %w", err) - } - - return CouncilStartResult{ - Run: councilRun, - Input: councilInput, - Reviewers: reviewers, - }, nil -} - -func normalizeCouncilRun(input CouncilStartInput) (CouncilRun, error) { - mode := defaultString(strings.TrimSpace(input.Mode), "brainstorm") - switch mode { - case "brainstorm", "review": - default: - return CouncilRun{}, fmt.Errorf("%w: mode must be brainstorm or review", ErrInvalidInput) - } - - targetType := defaultString(strings.TrimSpace(input.TargetType), "mixed") - switch targetType { - case "text", "repo", "mixed": - default: - return CouncilRun{}, fmt.Errorf("%w: target-type must be text, repo, or mixed", ErrInvalidInput) - } - - outputMode := defaultString(strings.TrimSpace(input.OutputMode), "both") - switch outputMode { - case "markdown", "json", "both": - default: - return CouncilRun{}, fmt.Errorf("%w: output must be markdown, json, or both", ErrInvalidInput) - } - - return CouncilRun{ - RunID: strings.TrimSpace(input.RunID), - Mode: mode, - TargetType: targetType, - OutputMode: outputMode, - OnlyUnanimous: input.OnlyUnanimous, - }, nil -} - -func normalizeCouncilInput(input CouncilStartInput) (CouncilInput, error) { - result := CouncilInput{ - RunID: strings.TrimSpace(input.RunID), - Prompt: strings.TrimSpace(input.Target), - TargetFile: strings.TrimSpace(input.TargetFile), - RepoPath: strings.TrimSpace(input.RepoPath), - TargetTaskID: strings.TrimSpace(input.TargetTaskID), - } - - if result.Prompt == "" && result.TargetFile == "" && result.RepoPath == "" && result.TargetTaskID == "" { - return CouncilInput{}, fmt.Errorf("%w: at least one of target, target-file, repo-path, or task-id is required", ErrInvalidInput) - } - - return result, nil -} - -func buildCouncilRunGoal(input CouncilInput) string { - switch { - case input.Prompt != "": - return "Council review: " + truncateSingleLine(input.Prompt, 80) - case input.TargetTaskID != "": - return "Council review for task " + input.TargetTaskID - case input.TargetFile != "": - return "Council review for " + input.TargetFile - case input.RepoPath != "": - return "Council review for repo " + input.RepoPath - default: - return "Council review" - } -} - -func buildCouncilRunSummary(run CouncilRun, input CouncilInput) string { - return fmt.Sprintf("%s council (%s)", run.Mode, run.TargetType) -} - -func buildCouncilTaskTitle(reviewerRole string) string { - switch reviewerRole { - case "architecture-reviewer": - return "Council architecture review" - case "implementation-reviewer": - return "Council implementation review" - case "risk-reviewer": - return "Council risk review" - default: - return "Council review" - } -} - -func buildCouncilTaskSummary(reviewerRole string) string { - switch reviewerRole { - case "architecture-reviewer": - return "Review the target for architecture, boundaries, and interfaces" - case "implementation-reviewer": - return "Review the target for simplicity, maintainability, and practicality" - case "risk-reviewer": - return "Review the target for regressions, correctness, and operability risks" - default: - return "Review the target" - } -} - -func buildCouncilTaskAcceptanceJSON(run CouncilRun, input CouncilInput, reviewerRole string) string { - return marshalJSON(map[string]any{ - "mode": "analysis", - "council": map[string]any{ - "reviewer_role": reviewerRole, - "council_mode": run.Mode, - "target_type": run.TargetType, - "output_mode": run.OutputMode, - "only_unanimous": run.OnlyUnanimous, - "target": map[string]any{ - "prompt": input.Prompt, - "target_file": input.TargetFile, - "repo_path": input.RepoPath, - "task_id": input.TargetTaskID, - }, - "response_format": map[string]any{ - "reviewer_role": reviewerRole, - "findings": []map[string]any{ - { - "title": "string", - "summary": "string", - "proposal": "string", - "rationale": "string", - "confidence": "low|medium|high", - "tags": []string{}, - "target_refs": map[string]any{}, - }, - }, - }, - }, - }) -} - -func buildCouncilTaskBody(run CouncilRun, input CouncilInput, reviewerRole string) string { - parts := []string{ - fmt.Sprintf("Reviewer role: %s", reviewerRole), - fmt.Sprintf("Council mode: %s", run.Mode), - fmt.Sprintf("Target type: %s", run.TargetType), - "Analyze the target from your assigned reviewer perspective.", - "Return structured findings with title, summary, proposal, rationale, confidence, tags, and optional target references.", - } - - if input.Prompt != "" { - parts = append(parts, "", "Prompt:", input.Prompt) - } - if input.TargetFile != "" { - parts = append(parts, "", "Target file:", input.TargetFile) - } - if input.RepoPath != "" { - parts = append(parts, "", "Repo path:", input.RepoPath) - } - if input.TargetTaskID != "" { - parts = append(parts, "", "Related task id:", input.TargetTaskID) - } - - return strings.Join(parts, "\n") -} - -func truncateSingleLine(value string, maxLen int) string { - value = strings.TrimSpace(value) - value = strings.ReplaceAll(value, "\n", " ") - value = strings.ReplaceAll(value, "\r", " ") - value = strings.Join(strings.Fields(value), " ") - if maxLen <= 0 || len(value) <= maxLen { - return value - } - if maxLen <= 3 { - return value[:maxLen] - } - return value[:maxLen-3] + "..." -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func (s *OrchStore) WaitForCouncil(ctx context.Context, input CouncilWaitInput) (CouncilWaitResult, error) { - runID := strings.TrimSpace(input.RunID) - if runID == "" { - return CouncilWaitResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - if _, err := s.GetCouncilRun(ctx, runID); err != nil { - return CouncilWaitResult{}, err - } - - waitCtx := ctx - cancel := func() {} - if input.Timeout > 0 { - waitCtx, cancel = context.WithTimeout(ctx, input.Timeout) - } - defer cancel() - - for { - reviewers, allComplete, err := s.GetCouncilReviewerStatuses(waitCtx, runID) - if err != nil { - if isDeadlineExceeded(waitCtx) { - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - return CouncilWaitResult{}, err - } - if allComplete { - return CouncilWaitResult{ - Woke: true, - RunID: runID, - AllComplete: true, - ReviewerStatuses: reviewers, - }, nil - } - - if _, err := s.ReconcileRun(waitCtx, runID); err != nil { - if isSQLiteBusyError(err) { - ok, waitErr := waitForNextPoll(waitCtx, 25*time.Millisecond) - if waitErr != nil { - if errors.Is(waitErr, context.DeadlineExceeded) { - reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID) - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - return CouncilWaitResult{}, waitErr - } - if !ok { - reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID) - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - continue - } - if isDeadlineExceeded(waitCtx) { - reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID) - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - return CouncilWaitResult{}, err - } - - ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID) - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - return CouncilWaitResult{}, err - } - if !ok { - reviewers, _, _ := s.GetCouncilReviewerStatuses(ctx, runID) - return CouncilWaitResult{ - Woke: false, - RunID: runID, - AllComplete: false, - ReviewerStatuses: reviewers, - }, nil - } - } -} - -func (s *OrchStore) GetCouncilRun(ctx context.Context, runID string) (CouncilRun, error) { - row := s.db.QueryRowContext( - ctx, - `SELECT run_id, mode, target_type, output_mode, only_unanimous - FROM council_runs - WHERE run_id = ?`, - runID, - ) - - var ( - run CouncilRun - onlyUnanimous int - ) - err := row.Scan(&run.RunID, &run.Mode, &run.TargetType, &run.OutputMode, &onlyUnanimous) - if errors.Is(err, sql.ErrNoRows) { - return CouncilRun{}, fmt.Errorf("%w: council run %s not found", ErrRunNotFound, runID) - } - if err != nil { - return CouncilRun{}, fmt.Errorf("scan council run: %w", err) - } - run.OnlyUnanimous = onlyUnanimous != 0 - return run, nil -} - -func (s *OrchStore) GetCouncilReviewerStatuses(ctx context.Context, runID string) ([]CouncilReviewer, bool, error) { - rows, err := s.db.QueryContext( - ctx, - `SELECT cr.reviewer_role, cr.task_id, t.status - FROM council_reviewers cr - JOIN tasks t - ON t.run_id = cr.run_id - AND t.task_id = cr.task_id - WHERE cr.run_id = ? - ORDER BY cr.reviewer_role ASC`, - runID, - ) - if err != nil { - return nil, false, fmt.Errorf("query council reviewer statuses: %w", err) - } - defer rows.Close() - - reviewers := make([]CouncilReviewer, 0, len(councilReviewerRoles)) - allComplete := true - for rows.Next() { - var reviewer CouncilReviewer - if err := rows.Scan(&reviewer.ReviewerRole, &reviewer.TaskID, &reviewer.Status); err != nil { - return nil, false, fmt.Errorf("scan council reviewer status: %w", err) - } - if reviewer.Status != "done" && reviewer.Status != "failed" && reviewer.Status != "cancelled" { - allComplete = false - } - reviewers = append(reviewers, reviewer) - } - if err := rows.Err(); err != nil { - return nil, false, fmt.Errorf("iterate council reviewer statuses: %w", err) - } - if len(reviewers) == 0 { - return nil, false, fmt.Errorf("%w: council reviewers for run %s not found", ErrRunNotFound, runID) - } - - return reviewers, allComplete, nil -} - -func (s *OrchStore) TallyCouncil(ctx context.Context, input CouncilTallyInput) (CouncilTallyResult, error) { - runID := strings.TrimSpace(input.RunID) - if runID == "" { - return CouncilTallyResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - similarity := defaultString(strings.TrimSpace(input.Similarity), "normal") - if similarity != "normal" && similarity != "strict" { - return CouncilTallyResult{}, fmt.Errorf("%w: similarity must be strict or normal", ErrInvalidInput) - } - - if _, err := s.GetCouncilRun(ctx, runID); err != nil { - return CouncilTallyResult{}, err - } - - if _, err := s.ReconcileRun(ctx, runID); err != nil && !isSQLiteBusyError(err) { - return CouncilTallyResult{}, err - } - - reviewers, allComplete, err := s.GetCouncilReviewerStatuses(ctx, runID) - if err != nil { - return CouncilTallyResult{}, err - } - if !allComplete { - return CouncilTallyResult{}, fmt.Errorf("%w: council reviewers are not complete yet", ErrInvalidState) - } - - findings, err := s.collectCouncilFindings(ctx, runID, reviewers) - if err != nil { - return CouncilTallyResult{}, err - } - groups := groupCouncilFindings(runID, findings, reviewers, similarity) - counts := make(map[string]int) - for _, group := range groups { - counts[group.Bucket]++ - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return CouncilTallyResult{}, fmt.Errorf("begin council tally transaction: %w", err) - } - defer tx.Rollback() - - if _, err := tx.ExecContext(ctx, `DELETE FROM council_findings WHERE run_id = ?`, runID); err != nil { - return CouncilTallyResult{}, fmt.Errorf("clear council findings: %w", err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM council_groups WHERE run_id = ?`, runID); err != nil { - return CouncilTallyResult{}, fmt.Errorf("clear council groups: %w", err) - } - - for _, finding := range findings { - if _, err := tx.ExecContext( - ctx, - `INSERT INTO council_findings ( - run_id, reviewer_role, finding_id, title, summary, proposal, rationale, - confidence, tags_json, target_refs_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - finding.RunID, - finding.ReviewerRole, - finding.FindingID, - finding.Title, - finding.Summary, - finding.Proposal, - finding.Rationale, - finding.Confidence, - string(finding.TagsJSON), - string(finding.TargetRefsJSON), - ); err != nil { - return CouncilTallyResult{}, fmt.Errorf("insert council finding: %w", err) - } - } - - for _, group := range groups { - if _, err := tx.ExecContext( - ctx, - `INSERT INTO council_groups ( - run_id, group_id, proposal, bucket, support_count, supporters_json, - dissenters_json, rationale_summary, tags_json, source_finding_ids_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - group.RunID, - group.GroupID, - group.Proposal, - group.Bucket, - group.SupportCount, - string(group.SupportersJSON), - string(group.DissentersJSON), - group.RationaleSummary, - string(group.TagsJSON), - string(group.SourceFindingIDsJSON), - ); err != nil { - return CouncilTallyResult{}, fmt.Errorf("insert council group: %w", err) - } - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - Source: "orch", - EventType: "council_tallied", - Summary: "council recommendations grouped", - PayloadJSON: marshalJSON(map[string]any{ - "similarity": similarity, - "counts": counts, - }), - CreatedAt: nowUTC(), - }); err != nil { - return CouncilTallyResult{}, err - } - - if err := tx.Commit(); err != nil { - return CouncilTallyResult{}, fmt.Errorf("commit council tally transaction: %w", err) - } - - return CouncilTallyResult{ - RunID: runID, - Similarity: similarity, - Counts: counts, - GroupedRecommendations: groups, - }, nil -} - -func (s *OrchStore) BuildCouncilReport(ctx context.Context, input CouncilReportInput) (CouncilReportResult, error) { - runID := strings.TrimSpace(input.RunID) - if runID == "" { - return CouncilReportResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - run, err := s.GetCouncilRun(ctx, runID) - if err != nil { - return CouncilReportResult{}, err - } - councilInput, err := s.GetCouncilInput(ctx, runID) - if err != nil { - return CouncilReportResult{}, err - } - - show, err := normalizeCouncilReportShow(input.Show, run.OnlyUnanimous) - if err != nil { - return CouncilReportResult{}, err - } - - groups, tallied, err := s.ListCouncilGroups(ctx, runID) - if err != nil { - return CouncilReportResult{}, err - } - if !tallied { - return CouncilReportResult{}, fmt.Errorf("%w: council groups are not available; run council tally first", ErrInvalidState) - } - - summary := councilGroupSummary(groups) - selectedGroups := selectCouncilGroupsForReport(groups, show) - markdown := renderCouncilReportMarkdown(run, councilInput, show, summary, selectedGroups) - - return CouncilReportResult{ - RunID: runID, - Show: show, - Summary: summary, - GroupedRecommendations: selectedGroups, - Markdown: markdown, - }, nil -} - -func (s *OrchStore) PersistCouncilReport(ctx context.Context, input CouncilPersistReportInput) error { - runID := strings.TrimSpace(input.RunID) - if runID == "" { - return fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if _, err := s.GetCouncilRun(ctx, runID); err != nil { - return err - } - - showJSON := marshalJSON(input.Show) - summaryJSON := marshalJSON(normalizeCouncilSummary(input.Summary)) - markdownPath := strings.TrimSpace(input.MarkdownPath) - now := nowUTC() - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin council report transaction: %w", err) - } - defer tx.Rollback() - - if _, err := tx.ExecContext( - ctx, - `INSERT INTO council_reports ( - run_id, show_json, summary_json, markdown_path, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id) DO UPDATE SET - show_json = excluded.show_json, - summary_json = excluded.summary_json, - markdown_path = excluded.markdown_path, - updated_at = excluded.updated_at`, - runID, - showJSON, - summaryJSON, - markdownPath, - formatTime(now), - formatTime(now), - ); err != nil { - return fmt.Errorf("persist council report metadata: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - Source: "orch", - EventType: "council_reported", - Summary: "council report generated", - PayloadJSON: marshalJSON(map[string]any{ - "show": input.Show, - "markdown_path": markdownPath, - }), - CreatedAt: now, - }); err != nil { - return err - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit council report transaction: %w", err) - } - return nil -} - -func (s *OrchStore) collectCouncilFindings(ctx context.Context, runID string, reviewers []CouncilReviewer) ([]CouncilFinding, error) { - findings := make([]CouncilFinding, 0) - for _, reviewer := range reviewers { - if reviewer.Status != "done" { - return nil, fmt.Errorf("%w: reviewer %s did not finish successfully", ErrInvalidState, reviewer.ReviewerRole) - } - - message, err := s.loadCouncilReviewerResultMessage(ctx, runID, reviewer.TaskID) - if err != nil { - return nil, err - } - output, err := parseCouncilReviewerOutput(reviewer.ReviewerRole, message) - if err != nil { - return nil, err - } - for i, finding := range output.Findings { - tagsJSON, err := normalizeOptionalJSONArray(finding.Tags) - if err != nil { - return nil, fmt.Errorf("%w: reviewer %s finding %d tags must be a JSON array", ErrInvalidInput, reviewer.ReviewerRole, i+1) - } - targetRefsJSON, err := normalizeOptionalJSONObject(finding.TargetRefs) - if err != nil { - return nil, fmt.Errorf("%w: reviewer %s finding %d target_refs must be a JSON object", ErrInvalidInput, reviewer.ReviewerRole, i+1) - } - confidence := strings.TrimSpace(finding.Confidence) - switch confidence { - case "low", "medium", "high": - default: - return nil, fmt.Errorf("%w: reviewer %s finding %d confidence must be low, medium, or high", ErrInvalidInput, reviewer.ReviewerRole, i+1) - } - if strings.TrimSpace(finding.Proposal) == "" { - return nil, fmt.Errorf("%w: reviewer %s finding %d proposal is required", ErrInvalidInput, reviewer.ReviewerRole, i+1) - } - findings = append(findings, CouncilFinding{ - RunID: runID, - ReviewerRole: reviewer.ReviewerRole, - FindingID: fmt.Sprintf("f%02d", i+1), - Title: strings.TrimSpace(finding.Title), - Summary: strings.TrimSpace(finding.Summary), - Proposal: strings.TrimSpace(finding.Proposal), - Rationale: strings.TrimSpace(finding.Rationale), - Confidence: confidence, - TagsJSON: json.RawMessage(tagsJSON), - TargetRefsJSON: json.RawMessage(targetRefsJSON), - }) - } - } - return findings, nil -} - -func (s *OrchStore) GetCouncilInput(ctx context.Context, runID string) (CouncilInput, error) { - row := s.db.QueryRowContext( - ctx, - `SELECT run_id, prompt, target_file, repo_path, target_task_id - FROM council_inputs - WHERE run_id = ?`, - runID, - ) - - var input CouncilInput - if err := row.Scan( - &input.RunID, - &input.Prompt, - &input.TargetFile, - &input.RepoPath, - &input.TargetTaskID, - ); errors.Is(err, sql.ErrNoRows) { - return CouncilInput{RunID: runID}, nil - } else if err != nil { - return CouncilInput{}, fmt.Errorf("scan council input: %w", err) - } - return input, nil -} - -func (s *OrchStore) ListCouncilGroups(ctx context.Context, runID string) ([]CouncilGroup, bool, error) { - rows, err := s.db.QueryContext( - ctx, - `SELECT - run_id, group_id, proposal, bucket, support_count, supporters_json, - dissenters_json, rationale_summary, tags_json, source_finding_ids_json - FROM council_groups - WHERE run_id = ? - ORDER BY - CASE bucket - WHEN 'consensus' THEN 1 - WHEN 'majority' THEN 2 - WHEN 'minority' THEN 3 - ELSE 4 - END, - support_count DESC, - group_id ASC`, - runID, - ) - if err != nil { - return nil, false, fmt.Errorf("query council groups: %w", err) - } - defer rows.Close() - - groups := make([]CouncilGroup, 0) - for rows.Next() { - var group CouncilGroup - var supportersJSON string - var dissentersJSON string - var tagsJSON string - var sourceFindingIDsJSON string - if err := rows.Scan( - &group.RunID, - &group.GroupID, - &group.Proposal, - &group.Bucket, - &group.SupportCount, - &supportersJSON, - &dissentersJSON, - &group.RationaleSummary, - &tagsJSON, - &sourceFindingIDsJSON, - ); err != nil { - return nil, false, fmt.Errorf("scan council group: %w", err) - } - group.SupportersJSON = json.RawMessage(supportersJSON) - group.DissentersJSON = json.RawMessage(dissentersJSON) - group.TagsJSON = json.RawMessage(tagsJSON) - group.SourceFindingIDsJSON = json.RawMessage(sourceFindingIDsJSON) - groups = append(groups, group) - } - if err := rows.Err(); err != nil { - return nil, false, fmt.Errorf("iterate council groups: %w", err) - } - - if len(groups) > 0 { - return groups, true, nil - } - - tallied, err := s.hasCouncilTallyEvent(ctx, runID) - if err != nil { - return nil, false, err - } - return groups, tallied, nil -} - -func (s *OrchStore) hasCouncilTallyEvent(ctx context.Context, runID string) (bool, error) { - var count int - if err := s.db.QueryRowContext( - ctx, - `SELECT COUNT(*) - FROM events - WHERE run_id = ? AND event_type = 'council_tallied'`, - runID, - ).Scan(&count); err != nil { - return false, fmt.Errorf("query council tally events: %w", err) - } - return count > 0, nil -} - -func (s *OrchStore) loadCouncilReviewerResultMessage(ctx context.Context, runID, taskID string) (Message, error) { - task, err := selectTask(ctx, s.db, runID, taskID) - if err != nil { - return Message{}, err - } - if task.LatestAttemptNo == 0 { - return Message{}, fmt.Errorf("%w: reviewer task %s has no attempt", ErrInvalidState, taskID) - } - - attempt, err := selectAttempt(ctx, s.db, runID, taskID, task.LatestAttemptNo) - if err != nil { - return Message{}, err - } - - row := s.db.QueryRowContext( - ctx, - `SELECT - message_id, thread_id, from_agent, to_agent, kind, summary, body, - payload_json, created_at - FROM messages - WHERE thread_id = ? AND kind = 'result' - ORDER BY created_at DESC - LIMIT 1`, - attempt.ThreadID, - ) - message, err := scanMessage(row) - if errors.Is(err, sql.ErrNoRows) { - return Message{}, fmt.Errorf("%w: reviewer task %s has no result message", ErrInvalidState, taskID) - } - if err != nil { - return Message{}, err - } - return message, nil -} - -func parseCouncilReviewerOutput(expectedRole string, message Message) (councilReviewerOutput, error) { - candidates := []string{strings.TrimSpace(message.Body), strings.TrimSpace(string(message.PayloadJSON))} - var lastErr error - for _, candidate := range candidates { - if candidate == "" || candidate == "{}" { - continue - } - var output councilReviewerOutput - if err := json.Unmarshal([]byte(candidate), &output); err != nil { - lastErr = err - continue - } - if strings.TrimSpace(output.ReviewerRole) == "" { - return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output must include reviewer_role", ErrInvalidInput) - } - if output.ReviewerRole != expectedRole { - return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output role %s does not match expected %s", ErrInvalidInput, output.ReviewerRole, expectedRole) - } - return output, nil - } - if lastErr != nil { - return councilReviewerOutput{}, fmt.Errorf("%w: reviewer output must be valid JSON", ErrInvalidInput) - } - return councilReviewerOutput{}, fmt.Errorf("%w: reviewer result message did not contain council output JSON", ErrInvalidInput) -} - -func normalizeOptionalJSONArray(raw json.RawMessage) (string, error) { - if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { - return "[]", nil - } - var value []any - if err := json.Unmarshal(raw, &value); err != nil { - return "", err - } - return marshalJSON(value), nil -} - -func normalizeOptionalJSONObject(raw json.RawMessage) (string, error) { - if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { - return "{}", nil - } - var value map[string]any - if err := json.Unmarshal(raw, &value); err != nil { - return "", err - } - return marshalJSON(value), nil -} - -func groupCouncilFindings(runID string, findings []CouncilFinding, reviewers []CouncilReviewer, similarity string) []CouncilGroup { - type groupedFinding struct { - key string - proposal string - findings []CouncilFinding - } - - order := make([]string, 0) - groupsByKey := make(map[string]*groupedFinding) - for _, finding := range findings { - key := councilProposalGroupKey(finding.Proposal, similarity) - group, ok := groupsByKey[key] - if !ok { - group = &groupedFinding{ - key: key, - proposal: finding.Proposal, - } - groupsByKey[key] = group - order = append(order, key) - } - group.findings = append(group.findings, finding) - } - - sortedReviewers := make([]string, 0, len(reviewers)) - for _, reviewer := range reviewers { - sortedReviewers = append(sortedReviewers, reviewer.ReviewerRole) - } - sort.Strings(sortedReviewers) - - result := make([]CouncilGroup, 0, len(order)) - for idx, key := range order { - group := groupsByKey[key] - supporterSet := make(map[string]struct{}) - tagSet := make(map[string]struct{}) - sourceFindingIDs := make([]string, 0, len(group.findings)) - rationaleSummary := "" - - for _, finding := range group.findings { - supporterSet[finding.ReviewerRole] = struct{}{} - sourceFindingIDs = append(sourceFindingIDs, finding.ReviewerRole+":"+finding.FindingID) - if rationaleSummary == "" && finding.Rationale != "" { - rationaleSummary = finding.Rationale - } - - var tags []string - if len(finding.TagsJSON) > 0 { - _ = json.Unmarshal(finding.TagsJSON, &tags) - } - for _, tag := range tags { - tag = strings.TrimSpace(tag) - if tag != "" { - tagSet[tag] = struct{}{} - } - } - } - - supporters := make([]string, 0, len(supporterSet)) - for _, reviewer := range sortedReviewers { - if _, ok := supporterSet[reviewer]; ok { - supporters = append(supporters, reviewer) - } - } - dissenters := make([]string, 0, len(sortedReviewers)-len(supporters)) - for _, reviewer := range sortedReviewers { - if _, ok := supporterSet[reviewer]; !ok { - dissenters = append(dissenters, reviewer) - } - } - - tags := make([]string, 0, len(tagSet)) - for tag := range tagSet { - tags = append(tags, tag) - } - sort.Strings(tags) - sort.Strings(sourceFindingIDs) - - supportCount := len(supporters) - bucket := "minority" - if supportCount == 3 { - bucket = "consensus" - } else if supportCount == 2 { - bucket = "majority" - } - - result = append(result, CouncilGroup{ - RunID: runID, - GroupID: fmt.Sprintf("grp_%02d", idx+1), - Proposal: group.proposal, - Bucket: bucket, - SupportCount: supportCount, - SupportersJSON: json.RawMessage(marshalJSON(supporters)), - DissentersJSON: json.RawMessage(marshalJSON(dissenters)), - RationaleSummary: rationaleSummary, - TagsJSON: json.RawMessage(marshalJSON(tags)), - SourceFindingIDsJSON: json.RawMessage(marshalJSON(sourceFindingIDs)), - }) - } - - sort.SliceStable(result, func(i, j int) bool { - if result[i].SupportCount != result[j].SupportCount { - return result[i].SupportCount > result[j].SupportCount - } - return result[i].Proposal < result[j].Proposal - }) - for i := range result { - result[i].GroupID = fmt.Sprintf("grp_%02d", i+1) - } - return result -} - -func normalizeCouncilReportShow(raw string, onlyUnanimous bool) ([]string, error) { - if strings.TrimSpace(raw) == "" { - if onlyUnanimous { - return []string{"consensus"}, nil - } - return []string{"consensus", "majority"}, nil - } - - parts := strings.Split(raw, ",") - show := make([]string, 0, len(parts)) - seen := make(map[string]struct{}, len(parts)) - for _, part := range parts { - value := strings.ToLower(strings.TrimSpace(part)) - if value == "" { - continue - } - if value == "all" { - return []string{"consensus", "majority", "minority"}, nil - } - switch value { - case "consensus", "majority", "minority": - default: - return nil, fmt.Errorf("%w: show must contain consensus, majority, minority, or all", ErrInvalidInput) - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - show = append(show, value) - } - if len(show) == 0 { - return nil, fmt.Errorf("%w: show must contain at least one bucket", ErrInvalidInput) - } - return show, nil -} - -func councilGroupSummary(groups []CouncilGroup) map[string]int { - summary := normalizeCouncilSummary(nil) - for _, group := range groups { - summary[group.Bucket]++ - } - return summary -} - -func normalizeCouncilSummary(summary map[string]int) map[string]int { - result := map[string]int{ - "consensus": 0, - "majority": 0, - "minority": 0, - } - for key, value := range summary { - result[key] = value - } - return result -} - -func selectCouncilGroupsForReport(groups []CouncilGroup, show []string) []CouncilGroup { - groupedByBucket := make(map[string][]CouncilGroup, len(show)) - for _, group := range groups { - groupedByBucket[group.Bucket] = append(groupedByBucket[group.Bucket], group) - } - - selected := make([]CouncilGroup, 0, len(groups)) - for _, bucket := range show { - selected = append(selected, groupedByBucket[bucket]...) - } - return selected -} - -func renderCouncilReportMarkdown(run CouncilRun, input CouncilInput, show []string, summary map[string]int, groups []CouncilGroup) string { - var builder strings.Builder - - builder.WriteString("# Council Review Report\n\n") - builder.WriteString(fmt.Sprintf("- Run ID: `%s`\n", run.RunID)) - builder.WriteString(fmt.Sprintf("- Mode: `%s`\n", run.Mode)) - builder.WriteString(fmt.Sprintf("- Target Type: `%s`\n", run.TargetType)) - builder.WriteString(fmt.Sprintf("- Report Buckets: `%s`\n\n", strings.Join(show, "`, `"))) - - builder.WriteString("## Target\n\n") - if strings.TrimSpace(input.Prompt) != "" { - builder.WriteString(fmt.Sprintf("- Prompt: %s\n", input.Prompt)) - } - if strings.TrimSpace(input.TargetFile) != "" { - builder.WriteString(fmt.Sprintf("- Target File: `%s`\n", input.TargetFile)) - } - if strings.TrimSpace(input.RepoPath) != "" { - builder.WriteString(fmt.Sprintf("- Repo Path: `%s`\n", input.RepoPath)) - } - if strings.TrimSpace(input.TargetTaskID) != "" { - builder.WriteString(fmt.Sprintf("- Task ID: `%s`\n", input.TargetTaskID)) - } - if strings.TrimSpace(input.Prompt) == "" && - strings.TrimSpace(input.TargetFile) == "" && - strings.TrimSpace(input.RepoPath) == "" && - strings.TrimSpace(input.TargetTaskID) == "" { - builder.WriteString("- No explicit target metadata was recorded.\n") - } - builder.WriteString("\n") - - builder.WriteString("## Summary\n\n") - builder.WriteString(fmt.Sprintf("- Consensus: %d\n", summary["consensus"])) - builder.WriteString(fmt.Sprintf("- Majority: %d\n", summary["majority"])) - builder.WriteString(fmt.Sprintf("- Minority: %d\n\n", summary["minority"])) - - groupedByBucket := make(map[string][]CouncilGroup, len(show)) - for _, group := range groups { - groupedByBucket[group.Bucket] = append(groupedByBucket[group.Bucket], group) - } - - for _, bucket := range show { - builder.WriteString(fmt.Sprintf("## %s\n\n", councilBucketHeading(bucket))) - bucketGroups := groupedByBucket[bucket] - if len(bucketGroups) == 0 { - builder.WriteString(fmt.Sprintf("No %s recommendations.\n\n", bucket)) - continue - } - - for _, group := range bucketGroups { - supporters := decodeCouncilStringSlice(group.SupportersJSON) - dissenters := decodeCouncilStringSlice(group.DissentersJSON) - tags := decodeCouncilStringSlice(group.TagsJSON) - sourceFindingIDs := decodeCouncilStringSlice(group.SourceFindingIDsJSON) - - builder.WriteString(fmt.Sprintf("### %s\n\n", group.GroupID)) - builder.WriteString(group.Proposal) - builder.WriteString("\n\n") - builder.WriteString(fmt.Sprintf("- Support: %d of 3 reviewers", group.SupportCount)) - if len(supporters) > 0 { - builder.WriteString(fmt.Sprintf(" (`%s`)", strings.Join(supporters, "`, `"))) - } - builder.WriteString("\n") - if len(dissenters) > 0 { - builder.WriteString(fmt.Sprintf("- Dissenters: `%s`\n", strings.Join(dissenters, "`, `"))) - } - if strings.TrimSpace(group.RationaleSummary) != "" { - builder.WriteString(fmt.Sprintf("- Rationale: %s\n", group.RationaleSummary)) - } - if len(tags) > 0 { - builder.WriteString(fmt.Sprintf("- Tags: `%s`\n", strings.Join(tags, "`, `"))) - } - if len(sourceFindingIDs) > 0 { - builder.WriteString(fmt.Sprintf("- Source Findings: `%s`\n", strings.Join(sourceFindingIDs, "`, `"))) - } - builder.WriteString("\n") - } - } - - return builder.String() -} - -func councilBucketHeading(bucket string) string { - switch bucket { - case "consensus": - return "Consensus" - case "majority": - return "Majority" - case "minority": - return "Minority" - default: - if bucket == "" { - return "Recommendations" - } - return strings.ToUpper(bucket[:1]) + bucket[1:] - } -} - -func decodeCouncilStringSlice(raw json.RawMessage) []string { - if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || strings.TrimSpace(string(raw)) == "null" { - return nil - } - - var values []string - if err := json.Unmarshal(raw, &values); err != nil { - return nil - } - - result := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value != "" { - result = append(result, value) - } - } - return result -} - -func councilProposalGroupKey(proposal, similarity string) string { - tokens := proposalTokens(proposal) - if similarity == "strict" { - return strings.Join(tokens, " ") - } - - stopWords := map[string]struct{}{ - "a": {}, "an": {}, "the": {}, "to": {}, "into": {}, "and": {}, "or": {}, "of": {}, "for": {}, "in": {}, "on": {}, "with": {}, "from": {}, "that": {}, "this": {}, "it": {}, "is": {}, "are": {}, "be": {}, "by": {}, "as": {}, "keep": {}, "use": {}, "add": {}, - } - set := make(map[string]struct{}) - filtered := make([]string, 0, len(tokens)) - for _, token := range tokens { - if _, stop := stopWords[token]; stop { - continue - } - if len(token) <= 2 { - continue - } - if _, seen := set[token]; seen { - continue - } - set[token] = struct{}{} - filtered = append(filtered, token) - } - sort.Strings(filtered) - if len(filtered) == 0 { - return strings.Join(tokens, " ") - } - return strings.Join(filtered, " ") -} - -func proposalTokens(value string) []string { - lower := strings.ToLower(strings.TrimSpace(value)) - fields := strings.FieldsFunc(lower, func(r rune) bool { - return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) - }) - result := make([]string, 0, len(fields)) - for _, field := range fields { - if field != "" { - result = append(result, field) - } - } - return result -} diff --git a/internal/store/doc.go b/internal/store/doc.go deleted file mode 100644 index bbd69c5..0000000 --- a/internal/store/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -package store - -// Package store contains higher-level database access helpers. diff --git a/internal/store/inbox.go b/internal/store/inbox.go deleted file mode 100644 index 727cae8..0000000 --- a/internal/store/inbox.go +++ /dev/null @@ -1,1932 +0,0 @@ -package store - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" -) - -var ErrLeaseConflict = errors.New("thread already claimed by another worker") -var ErrThreadNotFound = errors.New("thread not found") -var ErrMessageNotFound = errors.New("message not found") -var ErrNoActiveLease = errors.New("no active lease") -var ErrInvalidInput = errors.New("invalid input") -var ErrInvalidState = errors.New("invalid state") - -type InboxStore struct { - db *sql.DB -} - -type Thread struct { - ThreadID string `json:"thread_id"` - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - Subject string `json:"subject"` - CreatedBy string `json:"created_by"` - AssignedTo string `json:"assigned_to"` - Status string `json:"status"` - Priority string `json:"priority"` - LatestMessageID string `json:"latest_message_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Message struct { - MessageID string `json:"message_id"` - ThreadID string `json:"thread_id"` - FromAgent string `json:"from_agent"` - ToAgent string `json:"to_agent"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Body string `json:"body"` - PayloadJSON json.RawMessage `json:"payload_json"` - CreatedAt time.Time `json:"created_at"` - Artifacts []Artifact `json:"artifacts,omitempty"` -} - -type Artifact struct { - ArtifactID string `json:"artifact_id"` - MessageID string `json:"message_id"` - Path string `json:"path"` - Kind string `json:"kind"` - MetadataJSON json.RawMessage `json:"metadata_json"` - CreatedAt time.Time `json:"created_at"` -} - -type ArtifactInput struct { - Path string - Kind string - MetadataJSON string -} - -type ThreadDetail struct { - Thread Thread `json:"thread"` - Messages []Message `json:"messages"` -} - -type Event struct { - EventID int64 `json:"event_id"` - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - ThreadID string `json:"thread_id,omitempty"` - Source string `json:"source"` - EventType string `json:"event_type"` - MessageID string `json:"message_id,omitempty"` - Summary string `json:"summary"` - PayloadJSON json.RawMessage `json:"payload_json"` - CreatedAt time.Time `json:"created_at"` -} - -type SendInput struct { - ThreadID string - RunID string - TaskID string - Subject string - FromAgent string - ToAgent string - Kind string - Summary string - Body string - PayloadJSON string - Priority string - Artifacts []ArtifactInput -} - -type FetchInput struct { - Agent string - Statuses []string - Limit int - Unread bool -} - -type ClaimInput struct { - ThreadID string - Agent string - LeaseSeconds int -} - -type RenewInput struct { - ThreadID string - Agent string - LeaseSeconds int -} - -type ClaimResult struct { - Thread Thread `json:"thread"` - Message Message `json:"message"` -} - -type UpdateInput struct { - ThreadID string - Agent string - Status string - Summary string - Body string - PayloadJSON string - Artifacts []ArtifactInput -} - -type ReplyInput struct { - ThreadID string - FromAgent string - ToAgent string - Kind string - Summary string - Body string - PayloadJSON string - Artifacts []ArtifactInput -} - -type CompleteInput struct { - ThreadID string - Agent string - Summary string - Body string - PayloadJSON string - Failed bool - Artifacts []ArtifactInput -} - -type CancelInput struct { - ThreadID string - Agent string - Reason string - Artifacts []ArtifactInput -} - -type ListInput struct { - Agent string - Statuses []string - CreatedBy string - AssignedTo string - Limit int - Unread bool -} - -type WatchInput struct { - Agent string - Statuses []string - AfterEventID int64 - StartFromNow bool - Timeout time.Duration -} - -type WatchResult struct { - Woke bool `json:"woke"` - NextEventID int64 `json:"next_event_id"` - Thread *Thread `json:"thread,omitempty"` - Message *Message `json:"message,omitempty"` - Event *Event `json:"event,omitempty"` -} - -type WaitReplyInput struct { - ThreadID string - AfterMessageID string - AfterEventID int64 - Kinds []string - Agent string - Timeout time.Duration -} - -type WaitReplyResult struct { - Woke bool `json:"woke"` - NextEventID int64 `json:"next_event_id"` - Message *Message `json:"message,omitempty"` -} - -func NewInboxStore(db *sql.DB) *InboxStore { - return &InboxStore{db: db} -} - -func (s *InboxStore) Send(ctx context.Context, input SendInput) (Thread, Message, error) { - if input.ThreadID != "" { - thread, err := selectThread(ctx, s.db, input.ThreadID) - if err == nil { - return s.appendThreadMessage(ctx, thread, input) - } - if !errors.Is(err, ErrThreadNotFound) { - return Thread{}, Message{}, err - } - } - - return s.createThread(ctx, input) -} - -func (s *InboxStore) createThread(ctx context.Context, input SendInput) (Thread, Message, error) { - now := nowUTC() - - threadID := defaultID(input.ThreadID, "thr") - runID := defaultID(input.RunID, "run") - taskID := defaultID(input.TaskID, "task") - kind := defaultString(input.Kind, "task") - priority := defaultString(input.Priority, "normal") - summary := defaultString(input.Summary, input.Subject) - payload, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return Thread{}, Message{}, err - } - messageID := newID("msg") - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin send transaction: %w", err) - } - defer tx.Rollback() - - thread := Thread{ - ThreadID: threadID, - RunID: runID, - TaskID: taskID, - Subject: input.Subject, - CreatedBy: input.FromAgent, - AssignedTo: input.ToAgent, - Status: "pending", - Priority: priority, - LatestMessageID: messageID, - CreatedAt: now, - UpdatedAt: now, - } - - if _, err := tx.ExecContext( - ctx, - `INSERT INTO threads ( - thread_id, run_id, task_id, subject, created_by, assigned_to, status, - priority, latest_message_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - thread.ThreadID, - thread.RunID, - thread.TaskID, - thread.Subject, - thread.CreatedBy, - thread.AssignedTo, - thread.Status, - thread.Priority, - thread.LatestMessageID, - formatTime(thread.CreatedAt), - formatTime(thread.UpdatedAt), - ); err != nil { - return Thread{}, Message{}, fmt.Errorf("insert thread: %w", err) - } - - message := Message{ - MessageID: messageID, - ThreadID: threadID, - FromAgent: input.FromAgent, - ToAgent: input.ToAgent, - Kind: kind, - Summary: summary, - Body: input.Body, - PayloadJSON: json.RawMessage(payload), - CreatedAt: now, - } - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_created", - MessageID: message.MessageID, - Summary: summary, - PayloadJSON: payload, - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit send transaction: %w", err) - } - - return thread, message, nil -} - -func (s *InboxStore) appendThreadMessage(ctx context.Context, existing Thread, input SendInput) (Thread, Message, error) { - now := nowUTC() - messageID := newID("msg") - payload, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return Thread{}, Message{}, err - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin append transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, existing.ThreadID) - if err != nil { - return Thread{}, Message{}, err - } - if isTerminalStatus(thread.Status) { - return Thread{}, Message{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, thread.ThreadID) - } - - assignedTo := thread.AssignedTo - if input.ToAgent != "" { - assignedTo = input.ToAgent - } - - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: input.FromAgent, - ToAgent: defaultString(input.ToAgent, thread.AssignedTo), - Kind: defaultString(input.Kind, "task"), - Summary: defaultString(input.Summary, thread.Subject), - Body: input.Body, - PayloadJSON: json.RawMessage(payload), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := updateThreadState(ctx, tx, thread.ThreadID, thread.Status, assignedTo, message.MessageID, now); err != nil { - return Thread{}, Message{}, err - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_message_sent", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: payload, - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit append transaction: %w", err) - } - - thread.AssignedTo = assignedTo - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return thread, message, nil -} - -func (s *InboxStore) FetchThreads(ctx context.Context, input FetchInput) ([]Thread, error) { - statuses := input.Statuses - if len(statuses) == 0 { - statuses = []string{"pending"} - } - - return s.ListThreads(ctx, ListInput{ - Agent: input.Agent, - Statuses: statuses, - Limit: input.Limit, - Unread: input.Unread, - }) -} - -func (s *InboxStore) ListThreads(ctx context.Context, input ListInput) ([]Thread, error) { - limit := input.Limit - if limit <= 0 { - limit = 20 - } - - var ( - joinArgs []any - whereArgs []any - conditions []string - joins []string - ) - - assignedTo := input.AssignedTo - if assignedTo == "" { - assignedTo = input.Agent - } - - if assignedTo != "" { - conditions = append(conditions, "t.assigned_to = ?") - whereArgs = append(whereArgs, assignedTo) - } - if input.CreatedBy != "" { - conditions = append(conditions, "t.created_by = ?") - whereArgs = append(whereArgs, input.CreatedBy) - } - if len(input.Statuses) > 0 { - conditions = append(conditions, "t.status IN ("+placeholders(len(input.Statuses))+")") - for _, status := range input.Statuses { - whereArgs = append(whereArgs, status) - } - } - if input.Unread { - if input.Agent == "" { - return nil, fmt.Errorf("%w: agent is required when filtering unread threads", ErrInvalidInput) - } - joins = append(joins, "JOIN messages lm ON lm.message_id = t.latest_message_id") - joins = append(joins, "LEFT JOIN thread_reads tr ON tr.thread_id = t.thread_id AND tr.agent_id = ?") - joinArgs = append(joinArgs, input.Agent) - conditions = append(conditions, "lm.to_agent = ?") - whereArgs = append(whereArgs, input.Agent) - conditions = append(conditions, "lm.from_agent <> ?") - whereArgs = append(whereArgs, input.Agent) - conditions = append(conditions, "(tr.last_read_message_id IS NULL OR tr.last_read_message_id <> t.latest_message_id)") - } - - query := `SELECT - t.thread_id, t.run_id, t.task_id, t.subject, t.created_by, t.assigned_to, t.status, - t.priority, t.latest_message_id, t.created_at, t.updated_at - FROM threads t` - if len(joins) > 0 { - query += " " + strings.Join(joins, " ") - } - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - query += " ORDER BY t.updated_at DESC LIMIT ?" - args := append(joinArgs, whereArgs...) - args = append(args, limit) - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("list threads: %w", err) - } - defer rows.Close() - - var threads []Thread - for rows.Next() { - thread, err := scanThread(rows) - if err != nil { - return nil, err - } - threads = append(threads, thread) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate threads: %w", err) - } - - return threads, nil -} - -func (s *InboxStore) ClaimThread(ctx context.Context, input ClaimInput) (ClaimResult, error) { - if input.LeaseSeconds <= 0 { - input.LeaseSeconds = 900 - } - - var lastBusyErr error - for attempt := 0; attempt < 20; attempt++ { - result, err := s.claimThreadOnce(ctx, input) - if err == nil { - return result, nil - } - if !isSQLiteBusyError(err) { - return ClaimResult{}, err - } - lastBusyErr = err - - ok, waitErr := waitForNextPoll(ctx, 25*time.Millisecond) - if waitErr != nil { - return ClaimResult{}, waitErr - } - if !ok { - break - } - } - - if resolvedErr := s.classifyClaimConflict(ctx, input.ThreadID); resolvedErr != nil { - return ClaimResult{}, resolvedErr - } - - return ClaimResult{}, fmt.Errorf("claim thread: %w", lastBusyErr) -} - -func (s *InboxStore) claimThreadOnce(ctx context.Context, input ClaimInput) (ClaimResult, error) { - if input.LeaseSeconds <= 0 { - input.LeaseSeconds = 900 - } - - now := nowUTC() - expiresAt := now.Add(time.Duration(input.LeaseSeconds) * time.Second) - leaseToken := newID("lease") - messageID := newID("msg") - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ClaimResult{}, fmt.Errorf("begin claim transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return ClaimResult{}, err - } - if isTerminalStatus(thread.Status) { - return ClaimResult{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - - var activeLease string - err = tx.QueryRowContext( - ctx, - `SELECT agent_id FROM leases - WHERE thread_id = ? - AND released_at IS NULL - AND expires_at > ?`, - input.ThreadID, - formatTime(now), - ).Scan(&activeLease) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return ClaimResult{}, fmt.Errorf("check active lease: %w", err) - } - if activeLease != "" { - return ClaimResult{}, ErrLeaseConflict - } - if thread.Status != "pending" { - return ClaimResult{}, fmt.Errorf("%w: thread %s is not pending", ErrInvalidState, input.ThreadID) - } - - result, err := tx.ExecContext( - ctx, - `INSERT INTO leases ( - thread_id, agent_id, lease_token, claimed_at, expires_at, released_at - ) VALUES (?, ?, ?, ?, ?, NULL) - ON CONFLICT(thread_id) DO UPDATE SET - agent_id = excluded.agent_id, - lease_token = excluded.lease_token, - claimed_at = excluded.claimed_at, - expires_at = excluded.expires_at, - released_at = NULL - WHERE leases.released_at IS NOT NULL - OR leases.expires_at <= excluded.claimed_at`, - input.ThreadID, - input.Agent, - leaseToken, - formatTime(now), - formatTime(expiresAt), - ) - if err != nil { - return ClaimResult{}, fmt.Errorf("upsert lease: %w", err) - } - if affected, err := result.RowsAffected(); err == nil && affected == 0 { - return ClaimResult{}, ErrLeaseConflict - } - - result, err = tx.ExecContext( - ctx, - `UPDATE threads - SET status = ?, assigned_to = ?, latest_message_id = ?, updated_at = ? - WHERE thread_id = ? - AND status = ?`, - "claimed", - input.Agent, - messageID, - formatTime(now), - input.ThreadID, - "pending", - ) - if err != nil { - return ClaimResult{}, fmt.Errorf("update thread claim status: %w", err) - } - if affected, err := result.RowsAffected(); err == nil && affected == 0 { - return ClaimResult{}, fmt.Errorf("%w: thread %s is not pending", ErrInvalidState, input.ThreadID) - } - - message := Message{ - MessageID: messageID, - ThreadID: input.ThreadID, - FromAgent: input.Agent, - ToAgent: input.Agent, - Kind: "event", - Summary: "thread claimed", - Body: "", - PayloadJSON: json.RawMessage(fmt.Sprintf(`{"lease_seconds":%d,"lease_token":"%s"}`, input.LeaseSeconds, leaseToken)), - CreatedAt: now, - } - - if _, err := tx.ExecContext( - ctx, - `INSERT INTO messages ( - message_id, thread_id, from_agent, to_agent, kind, summary, body, - payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - message.MessageID, - message.ThreadID, - message.FromAgent, - message.ToAgent, - message.Kind, - message.Summary, - message.Body, - string(message.PayloadJSON), - formatTime(message.CreatedAt), - ); err != nil { - return ClaimResult{}, fmt.Errorf("insert claim event message: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_claimed", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return ClaimResult{}, err - } - - if err := tx.Commit(); err != nil { - return ClaimResult{}, fmt.Errorf("commit claim transaction: %w", err) - } - - thread.Status = "claimed" - thread.AssignedTo = input.Agent - thread.LatestMessageID = messageID - thread.UpdatedAt = now - - return ClaimResult{ - Thread: thread, - Message: message, - }, nil -} - -func (s *InboxStore) RenewLease(ctx context.Context, input RenewInput) (ClaimResult, error) { - if input.LeaseSeconds <= 0 { - input.LeaseSeconds = 900 - } - - now := nowUTC() - expiresAt := now.Add(time.Duration(input.LeaseSeconds) * time.Second) - leaseToken := newID("lease") - messageID := newID("msg") - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ClaimResult{}, fmt.Errorf("begin renew transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return ClaimResult{}, err - } - if isTerminalStatus(thread.Status) { - return ClaimResult{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - - if _, err := requireActiveLease(ctx, tx, input.ThreadID, input.Agent, now); err != nil { - return ClaimResult{}, err - } - - if _, err := tx.ExecContext( - ctx, - `UPDATE leases - SET lease_token = ?, expires_at = ?, released_at = NULL - WHERE thread_id = ?`, - leaseToken, - formatTime(expiresAt), - input.ThreadID, - ); err != nil { - return ClaimResult{}, fmt.Errorf("renew lease: %w", err) - } - - message := Message{ - MessageID: messageID, - ThreadID: input.ThreadID, - FromAgent: input.Agent, - ToAgent: input.Agent, - Kind: "event", - Summary: "lease renewed", - Body: "", - PayloadJSON: json.RawMessage(fmt.Sprintf(`{"lease_seconds":%d,"lease_token":"%s"}`, input.LeaseSeconds, leaseToken)), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return ClaimResult{}, err - } - - if err := updateThreadState(ctx, tx, thread.ThreadID, thread.Status, thread.AssignedTo, message.MessageID, now); err != nil { - return ClaimResult{}, err - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_renewed", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return ClaimResult{}, err - } - - if err := tx.Commit(); err != nil { - return ClaimResult{}, fmt.Errorf("commit renew transaction: %w", err) - } - - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return ClaimResult{ - Thread: thread, - Message: message, - }, nil -} - -func (s *InboxStore) UpdateThreadStatus(ctx context.Context, input UpdateInput) (Thread, Message, error) { - now := nowUTC() - messageID := newID("msg") - payload, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return Thread{}, Message{}, err - } - - if input.Status != "in_progress" && input.Status != "blocked" { - return Thread{}, Message{}, fmt.Errorf("%w: unsupported update status %q", ErrInvalidInput, input.Status) - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin update transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return Thread{}, Message{}, err - } - if isTerminalStatus(thread.Status) { - return Thread{}, Message{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - if _, err := requireActiveLease(ctx, tx, input.ThreadID, input.Agent, now); err != nil { - return Thread{}, Message{}, err - } - - kind := "progress" - if input.Status == "blocked" { - kind = "question" - } - - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: input.Agent, - ToAgent: thread.CreatedBy, - Kind: kind, - Summary: input.Summary, - Body: input.Body, - PayloadJSON: json.RawMessage(payload), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := updateThreadState(ctx, tx, thread.ThreadID, input.Status, thread.AssignedTo, message.MessageID, now); err != nil { - return Thread{}, Message{}, err - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_" + input.Status, - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit update transaction: %w", err) - } - - thread.Status = input.Status - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return thread, message, nil -} - -func (s *InboxStore) ReplyToThread(ctx context.Context, input ReplyInput) (Thread, Message, error) { - now := nowUTC() - messageID := newID("msg") - payload, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return Thread{}, Message{}, err - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin reply transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return Thread{}, Message{}, err - } - if isTerminalStatus(thread.Status) { - return Thread{}, Message{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: input.FromAgent, - ToAgent: input.ToAgent, - Kind: defaultString(input.Kind, "answer"), - Summary: input.Summary, - Body: input.Body, - PayloadJSON: json.RawMessage(payload), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := updateThreadState(ctx, tx, thread.ThreadID, thread.Status, thread.AssignedTo, message.MessageID, now); err != nil { - return Thread{}, Message{}, err - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_replied", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit reply transaction: %w", err) - } - - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return thread, message, nil -} - -func (s *InboxStore) CompleteThread(ctx context.Context, input CompleteInput) (Thread, Message, error) { - now := nowUTC() - messageID := newID("msg") - payload, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return Thread{}, Message{}, err - } - - nextStatus := "done" - eventType := "thread_done" - summary := input.Summary - if input.Failed { - nextStatus = "failed" - eventType = "thread_failed" - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin complete transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return Thread{}, Message{}, err - } - if isTerminalStatus(thread.Status) { - return Thread{}, Message{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - if _, err := requireActiveLease(ctx, tx, input.ThreadID, input.Agent, now); err != nil { - return Thread{}, Message{}, err - } - - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: input.Agent, - ToAgent: thread.CreatedBy, - Kind: "result", - Summary: summary, - Body: input.Body, - PayloadJSON: json.RawMessage(payload), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := updateThreadState(ctx, tx, thread.ThreadID, nextStatus, thread.AssignedTo, message.MessageID, now); err != nil { - return Thread{}, Message{}, err - } - - if _, err := tx.ExecContext( - ctx, - `UPDATE leases - SET released_at = ? - WHERE thread_id = ? - AND released_at IS NULL`, - formatTime(now), - thread.ThreadID, - ); err != nil { - return Thread{}, Message{}, fmt.Errorf("release lease: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: eventType, - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit complete transaction: %w", err) - } - - thread.Status = nextStatus - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return thread, message, nil -} - -func (s *InboxStore) CancelThread(ctx context.Context, input CancelInput) (Thread, Message, error) { - now := nowUTC() - messageID := newID("msg") - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Thread{}, Message{}, fmt.Errorf("begin cancel transaction: %w", err) - } - defer tx.Rollback() - - thread, err := selectThreadForUpdate(ctx, tx, input.ThreadID) - if err != nil { - return Thread{}, Message{}, err - } - if isTerminalStatus(thread.Status) { - return Thread{}, Message{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, input.ThreadID) - } - - summary := defaultString(input.Reason, "thread cancelled") - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: input.Agent, - ToAgent: thread.AssignedTo, - Kind: "control", - Summary: summary, - Body: input.Reason, - PayloadJSON: json.RawMessage(`{}`), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return Thread{}, Message{}, err - } - artifacts, err := insertArtifacts(ctx, tx, message.MessageID, input.Artifacts, now) - if err != nil { - return Thread{}, Message{}, err - } - message.Artifacts = artifacts - - if err := updateThreadState(ctx, tx, thread.ThreadID, "cancelled", thread.AssignedTo, message.MessageID, now); err != nil { - return Thread{}, Message{}, err - } - - if _, err := tx.ExecContext( - ctx, - `UPDATE leases - SET released_at = ? - WHERE thread_id = ? - AND released_at IS NULL`, - formatTime(now), - thread.ThreadID, - ); err != nil { - return Thread{}, Message{}, fmt.Errorf("release lease on cancel: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_cancelled", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return Thread{}, Message{}, err - } - - if err := tx.Commit(); err != nil { - return Thread{}, Message{}, fmt.Errorf("commit cancel transaction: %w", err) - } - - thread.Status = "cancelled" - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - return thread, message, nil -} - -func (s *InboxStore) GetThread(ctx context.Context, threadID string) (ThreadDetail, error) { - return s.GetThreadForAgent(ctx, threadID, "", false) -} - -func (s *InboxStore) GetThreadForAgent(ctx context.Context, threadID, agent string, markRead bool) (ThreadDetail, error) { - thread, err := selectThread(ctx, s.db, threadID) - if err != nil { - return ThreadDetail{}, err - } - - rows, err := s.db.QueryContext( - ctx, - `SELECT - message_id, thread_id, from_agent, to_agent, kind, summary, body, - payload_json, created_at - FROM messages - WHERE thread_id = ? - ORDER BY created_at ASC`, - threadID, - ) - if err != nil { - return ThreadDetail{}, fmt.Errorf("query thread messages: %w", err) - } - defer rows.Close() - - var messages []Message - for rows.Next() { - message, err := scanMessage(rows) - if err != nil { - return ThreadDetail{}, err - } - messages = append(messages, message) - } - - if err := rows.Err(); err != nil { - return ThreadDetail{}, fmt.Errorf("iterate thread messages: %w", err) - } - - artifactsByMessageID, err := loadArtifactsForMessageIDs(ctx, s.db, messageIDs(messages)) - if err != nil { - return ThreadDetail{}, err - } - attachArtifacts(messages, artifactsByMessageID) - - if markRead { - if err := markThreadRead(ctx, s.db, thread.ThreadID, agent, thread.LatestMessageID, nowUTC()); err != nil { - return ThreadDetail{}, err - } - } - - return ThreadDetail{ - Thread: thread, - Messages: messages, - }, nil -} - -func (s *InboxStore) WatchThreads(ctx context.Context, input WatchInput) (WatchResult, error) { - cursor := input.AfterEventID - if input.StartFromNow && cursor == 0 { - current, err := s.currentMaxEventID(ctx) - if err != nil { - return WatchResult{}, err - } - cursor = current - } - - waitCtx := ctx - cancel := func() {} - if input.Timeout > 0 { - waitCtx, cancel = context.WithTimeout(ctx, input.Timeout) - } - defer cancel() - - for { - thread, message, event, found, err := s.findWatchEventAfter(waitCtx, input, cursor) - if err != nil { - if isDeadlineExceeded(waitCtx) { - return WatchResult{Woke: false, NextEventID: cursor}, nil - } - return WatchResult{}, err - } - if found { - return WatchResult{ - Woke: true, - NextEventID: event.EventID, - Thread: &thread, - Message: &message, - Event: &event, - }, nil - } - - ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return WatchResult{Woke: false, NextEventID: cursor}, nil - } - return WatchResult{}, err - } - if !ok { - return WatchResult{Woke: false, NextEventID: cursor}, nil - } - } -} - -func (s *InboxStore) WaitReply(ctx context.Context, input WaitReplyInput) (WaitReplyResult, error) { - cursor := input.AfterEventID - if input.AfterMessageID != "" { - eventID, err := s.lookupEventIDForMessage(ctx, input.ThreadID, input.AfterMessageID) - if err != nil { - return WaitReplyResult{}, err - } - if eventID > cursor { - cursor = eventID - } - } - - kinds := input.Kinds - if len(kinds) == 0 { - kinds = []string{"answer", "control", "result"} - } - - waitCtx := ctx - cancel := func() {} - if input.Timeout > 0 { - waitCtx, cancel = context.WithTimeout(ctx, input.Timeout) - } - defer cancel() - - for { - message, eventID, found, err := s.findReplyAfter(waitCtx, input.ThreadID, cursor, kinds) - if err != nil { - if isDeadlineExceeded(waitCtx) { - return WaitReplyResult{Woke: false, NextEventID: cursor}, nil - } - return WaitReplyResult{}, err - } - if found { - if shouldMarkMessageRead(message, input.Agent) { - if err := markThreadRead(waitCtx, s.db, input.ThreadID, input.Agent, message.MessageID, nowUTC()); err != nil { - return WaitReplyResult{}, err - } - } - return WaitReplyResult{ - Woke: true, - NextEventID: eventID, - Message: &message, - }, nil - } - - ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return WaitReplyResult{Woke: false, NextEventID: cursor}, nil - } - return WaitReplyResult{}, err - } - if !ok { - return WaitReplyResult{Woke: false, NextEventID: cursor}, nil - } - } -} - -type threadScanner interface { - Scan(dest ...any) error -} - -func scanThread(scanner threadScanner) (Thread, error) { - var ( - thread Thread - createdAt, updatedAt string - latestMessageID sql.NullString - ) - - if err := scanner.Scan( - &thread.ThreadID, - &thread.RunID, - &thread.TaskID, - &thread.Subject, - &thread.CreatedBy, - &thread.AssignedTo, - &thread.Status, - &thread.Priority, - &latestMessageID, - &createdAt, - &updatedAt, - ); err != nil { - return Thread{}, fmt.Errorf("scan thread: %w", err) - } - - thread.CreatedAt = parseTime(createdAt) - thread.UpdatedAt = parseTime(updatedAt) - if latestMessageID.Valid { - thread.LatestMessageID = latestMessageID.String - } - - return thread, nil -} - -func scanMessage(scanner threadScanner) (Message, error) { - var ( - message Message - payload, createdAt string - ) - - if err := scanner.Scan( - &message.MessageID, - &message.ThreadID, - &message.FromAgent, - &message.ToAgent, - &message.Kind, - &message.Summary, - &message.Body, - &payload, - &createdAt, - ); err != nil { - return Message{}, fmt.Errorf("scan message: %w", err) - } - - message.PayloadJSON = json.RawMessage(payload) - message.CreatedAt = parseTime(createdAt) - return message, nil -} - -func scanArtifact(scanner threadScanner) (Artifact, error) { - var ( - artifact Artifact - metadata, created string - ) - - if err := scanner.Scan( - &artifact.ArtifactID, - &artifact.MessageID, - &artifact.Path, - &artifact.Kind, - &metadata, - &created, - ); err != nil { - return Artifact{}, fmt.Errorf("scan artifact: %w", err) - } - - artifact.MetadataJSON = json.RawMessage(metadata) - artifact.CreatedAt = parseTime(created) - return artifact, nil -} - -func scanEvent(scanner threadScanner) (Event, error) { - var ( - event Event - messageID sql.NullString - payload, createdAt string - ) - - if err := scanner.Scan( - &event.EventID, - &event.RunID, - &event.TaskID, - &event.ThreadID, - &event.Source, - &event.EventType, - &messageID, - &event.Summary, - &payload, - &createdAt, - ); err != nil { - return Event{}, fmt.Errorf("scan event: %w", err) - } - - if messageID.Valid { - event.MessageID = messageID.String - } - event.PayloadJSON = json.RawMessage(payload) - event.CreatedAt = parseTime(createdAt) - return event, nil -} - -func selectThread(ctx context.Context, db queryRower, threadID string) (Thread, error) { - row := db.QueryRowContext( - ctx, - `SELECT - thread_id, run_id, task_id, subject, created_by, assigned_to, status, - priority, latest_message_id, created_at, updated_at - FROM threads - WHERE thread_id = ?`, - threadID, - ) - - thread, err := scanThread(row) - if errors.Is(err, sql.ErrNoRows) { - return Thread{}, fmt.Errorf("%w: %s", ErrThreadNotFound, threadID) - } - return thread, err -} - -func selectThreadForUpdate(ctx context.Context, tx *sql.Tx, threadID string) (Thread, error) { - return selectThread(ctx, tx, threadID) -} - -type queryRower interface { - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row -} - -type execContexter interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) -} - -type eventInput struct { - RunID string - TaskID string - ThreadID string - Source string - EventType string - MessageID string - Summary string - PayloadJSON string - CreatedAt time.Time -} - -func insertEvent(ctx context.Context, tx *sql.Tx, input eventInput) error { - _, err := tx.ExecContext( - ctx, - `INSERT INTO events ( - run_id, task_id, thread_id, source, event_type, message_id, summary, - payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - input.RunID, - input.TaskID, - input.ThreadID, - input.Source, - input.EventType, - input.MessageID, - input.Summary, - normalizeJSON(input.PayloadJSON), - formatTime(input.CreatedAt), - ) - if err != nil { - return fmt.Errorf("insert event: %w", err) - } - return nil -} - -func insertMessage(ctx context.Context, tx *sql.Tx, message Message) error { - _, err := tx.ExecContext( - ctx, - `INSERT INTO messages ( - message_id, thread_id, from_agent, to_agent, kind, summary, body, - payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - message.MessageID, - message.ThreadID, - message.FromAgent, - message.ToAgent, - message.Kind, - message.Summary, - message.Body, - string(message.PayloadJSON), - formatTime(message.CreatedAt), - ) - if err != nil { - return fmt.Errorf("insert message: %w", err) - } - return nil -} - -func insertArtifacts(ctx context.Context, tx *sql.Tx, messageID string, inputs []ArtifactInput, createdAt time.Time) ([]Artifact, error) { - if len(inputs) == 0 { - return nil, nil - } - - artifacts := make([]Artifact, 0, len(inputs)) - for _, input := range inputs { - metadataJSON, err := validateAndNormalizeJSON("artifact-metadata-json", input.MetadataJSON) - if err != nil { - return nil, err - } - - artifact := Artifact{ - ArtifactID: newID("art"), - MessageID: messageID, - Path: input.Path, - Kind: defaultString(input.Kind, "file"), - MetadataJSON: json.RawMessage(metadataJSON), - CreatedAt: createdAt, - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO artifacts ( - artifact_id, message_id, path, kind, metadata_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?)`, - artifact.ArtifactID, - artifact.MessageID, - artifact.Path, - artifact.Kind, - string(artifact.MetadataJSON), - formatTime(artifact.CreatedAt), - ) - if err != nil { - return nil, fmt.Errorf("insert artifact: %w", err) - } - - artifacts = append(artifacts, artifact) - } - - return artifacts, nil -} - -func updateThreadState(ctx context.Context, tx *sql.Tx, threadID, status, assignedTo, latestMessageID string, updatedAt time.Time) error { - _, err := tx.ExecContext( - ctx, - `UPDATE threads - SET status = ?, assigned_to = ?, latest_message_id = ?, updated_at = ? - WHERE thread_id = ?`, - status, - assignedTo, - latestMessageID, - formatTime(updatedAt), - threadID, - ) - if err != nil { - return fmt.Errorf("update thread state: %w", err) - } - return nil -} - -func markThreadRead(ctx context.Context, execer execContexter, threadID, agent, messageID string, readAt time.Time) error { - if agent == "" || messageID == "" { - return nil - } - - _, err := execer.ExecContext( - ctx, - `INSERT INTO thread_reads ( - thread_id, agent_id, last_read_message_id, last_read_at - ) VALUES (?, ?, ?, ?) - ON CONFLICT(thread_id, agent_id) DO UPDATE SET - last_read_message_id = excluded.last_read_message_id, - last_read_at = excluded.last_read_at`, - threadID, - agent, - messageID, - formatTime(readAt), - ) - if err != nil { - return fmt.Errorf("mark thread read: %w", err) - } - return nil -} - -func loadArtifactsForMessageIDs(ctx context.Context, db *sql.DB, messageIDs []string) (map[string][]Artifact, error) { - result := make(map[string][]Artifact) - if len(messageIDs) == 0 { - return result, nil - } - - args := make([]any, 0, len(messageIDs)) - for _, messageID := range messageIDs { - args = append(args, messageID) - } - - rows, err := db.QueryContext( - ctx, - `SELECT - artifact_id, message_id, path, kind, metadata_json, created_at - FROM artifacts - WHERE message_id IN (`+placeholders(len(messageIDs))+`) - ORDER BY created_at ASC`, - args..., - ) - if err != nil { - return nil, fmt.Errorf("query artifacts: %w", err) - } - defer rows.Close() - - for rows.Next() { - artifact, err := scanArtifact(rows) - if err != nil { - return nil, err - } - result[artifact.MessageID] = append(result[artifact.MessageID], artifact) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate artifacts: %w", err) - } - - return result, nil -} - -func attachArtifacts(messages []Message, artifactsByMessageID map[string][]Artifact) { - for i := range messages { - messages[i].Artifacts = artifactsByMessageID[messages[i].MessageID] - } -} - -func messageIDs(messages []Message) []string { - ids := make([]string, 0, len(messages)) - for _, message := range messages { - ids = append(ids, message.MessageID) - } - return ids -} - -func (s *InboxStore) classifyClaimConflict(ctx context.Context, threadID string) error { - thread, err := selectThread(ctx, s.db, threadID) - if err != nil { - return err - } - - now := nowUTC() - var activeLease string - err = s.db.QueryRowContext( - ctx, - `SELECT agent_id - FROM leases - WHERE thread_id = ? - AND released_at IS NULL - AND expires_at > ?`, - threadID, - formatTime(now), - ).Scan(&activeLease) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("check active lease after busy claim: %w", err) - } - if activeLease != "" { - return ErrLeaseConflict - } - if thread.Status != "pending" { - return fmt.Errorf("%w: thread %s is not pending", ErrInvalidState, threadID) - } - - return nil -} - -func requireActiveLease(ctx context.Context, tx *sql.Tx, threadID, agent string, now time.Time) (string, error) { - var ( - activeAgent string - leaseToken string - expiresAt string - releasedAt sql.NullString - ) - - err := tx.QueryRowContext( - ctx, - `SELECT agent_id, lease_token, expires_at, released_at - FROM leases - WHERE thread_id = ?`, - threadID, - ).Scan(&activeAgent, &leaseToken, &expiresAt, &releasedAt) - if errors.Is(err, sql.ErrNoRows) { - return "", ErrNoActiveLease - } - if err != nil { - return "", fmt.Errorf("read lease: %w", err) - } - - if releasedAt.Valid || !parseTime(expiresAt).After(now) { - return "", ErrNoActiveLease - } - if activeAgent != agent { - return "", ErrLeaseConflict - } - - return leaseToken, nil -} - -func (s *InboxStore) lookupEventIDForMessage(ctx context.Context, threadID, messageID string) (int64, error) { - var eventID int64 - err := s.db.QueryRowContext( - ctx, - `SELECT event_id - FROM events - WHERE thread_id = ? - AND message_id = ? - ORDER BY event_id DESC - LIMIT 1`, - threadID, - messageID, - ).Scan(&eventID) - if errors.Is(err, sql.ErrNoRows) { - return 0, fmt.Errorf("%w: message %s not found in thread %s", ErrMessageNotFound, messageID, threadID) - } - if err != nil { - return 0, fmt.Errorf("lookup message event: %w", err) - } - return eventID, nil -} - -func (s *InboxStore) currentMaxEventID(ctx context.Context) (int64, error) { - var maxEventID int64 - if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(event_id), 0) FROM events`).Scan(&maxEventID); err != nil { - return 0, fmt.Errorf("query max event id: %w", err) - } - return maxEventID, nil -} - -func (s *InboxStore) findReplyAfter(ctx context.Context, threadID string, afterEventID int64, kinds []string) (Message, int64, bool, error) { - args := []any{threadID, afterEventID} - query := `SELECT - e.event_id, - m.message_id, m.thread_id, m.from_agent, m.to_agent, m.kind, m.summary, m.body, m.payload_json, m.created_at - FROM events e - JOIN messages m ON m.message_id = e.message_id - WHERE e.thread_id = ? - AND e.event_id > ?` - if len(kinds) > 0 { - query += " AND m.kind IN (" + placeholders(len(kinds)) + ")" - for _, kind := range kinds { - args = append(args, kind) - } - } - query += " ORDER BY e.event_id ASC LIMIT 1" - - row := s.db.QueryRowContext(ctx, query, args...) - - var ( - eventID int64 - message Message - payload string - created string - ) - err := row.Scan( - &eventID, - &message.MessageID, - &message.ThreadID, - &message.FromAgent, - &message.ToAgent, - &message.Kind, - &message.Summary, - &message.Body, - &payload, - &created, - ) - if errors.Is(err, sql.ErrNoRows) { - return Message{}, 0, false, nil - } - if err != nil { - return Message{}, 0, false, fmt.Errorf("query reply after event %d: %w", afterEventID, err) - } - - message.PayloadJSON = json.RawMessage(payload) - message.CreatedAt = parseTime(created) - artifactsByMessageID, err := loadArtifactsForMessageIDs(ctx, s.db, []string{message.MessageID}) - if err != nil { - return Message{}, 0, false, err - } - message.Artifacts = artifactsByMessageID[message.MessageID] - return message, eventID, true, nil -} - -func (s *InboxStore) findWatchEventAfter(ctx context.Context, input WatchInput, afterEventID int64) (Thread, Message, Event, bool, error) { - args := []any{afterEventID} - query := `SELECT - t.thread_id, t.run_id, t.task_id, t.subject, t.created_by, t.assigned_to, t.status, - t.priority, t.latest_message_id, t.created_at, t.updated_at, - e.event_id, e.run_id, e.task_id, e.thread_id, e.source, e.event_type, e.message_id, e.summary, e.payload_json, e.created_at, - m.message_id, m.thread_id, m.from_agent, m.to_agent, m.kind, m.summary, m.body, m.payload_json, m.created_at - FROM events e - JOIN threads t ON t.thread_id = e.thread_id - JOIN messages m ON m.message_id = e.message_id - WHERE e.event_id > ?` - - if input.Agent != "" { - query += " AND t.assigned_to = ?" - args = append(args, input.Agent) - } - if len(input.Statuses) > 0 { - query += " AND t.status IN (" + placeholders(len(input.Statuses)) + ")" - for _, status := range input.Statuses { - args = append(args, status) - } - } - query += " ORDER BY e.event_id ASC LIMIT 1" - - row := s.db.QueryRowContext(ctx, query, args...) - - var ( - thread Thread - threadCreatedAt string - threadUpdatedAt string - threadLatestMessage sql.NullString - event Event - eventMessageID sql.NullString - eventPayload string - eventCreatedAt string - message Message - messagePayload string - messageCreatedAt string - ) - - err := row.Scan( - &thread.ThreadID, - &thread.RunID, - &thread.TaskID, - &thread.Subject, - &thread.CreatedBy, - &thread.AssignedTo, - &thread.Status, - &thread.Priority, - &threadLatestMessage, - &threadCreatedAt, - &threadUpdatedAt, - &event.EventID, - &event.RunID, - &event.TaskID, - &event.ThreadID, - &event.Source, - &event.EventType, - &eventMessageID, - &event.Summary, - &eventPayload, - &eventCreatedAt, - &message.MessageID, - &message.ThreadID, - &message.FromAgent, - &message.ToAgent, - &message.Kind, - &message.Summary, - &message.Body, - &messagePayload, - &messageCreatedAt, - ) - if errors.Is(err, sql.ErrNoRows) { - return Thread{}, Message{}, Event{}, false, nil - } - if err != nil { - return Thread{}, Message{}, Event{}, false, fmt.Errorf("query watch event after %d: %w", afterEventID, err) - } - - if threadLatestMessage.Valid { - thread.LatestMessageID = threadLatestMessage.String - } - thread.CreatedAt = parseTime(threadCreatedAt) - thread.UpdatedAt = parseTime(threadUpdatedAt) - if eventMessageID.Valid { - event.MessageID = eventMessageID.String - } - event.PayloadJSON = json.RawMessage(eventPayload) - event.CreatedAt = parseTime(eventCreatedAt) - message.PayloadJSON = json.RawMessage(messagePayload) - message.CreatedAt = parseTime(messageCreatedAt) - artifactsByMessageID, err := loadArtifactsForMessageIDs(ctx, s.db, []string{message.MessageID}) - if err != nil { - return Thread{}, Message{}, Event{}, false, err - } - message.Artifacts = artifactsByMessageID[message.MessageID] - return thread, message, event, true, nil -} - -func waitForNextPoll(ctx context.Context, interval time.Duration) (bool, error) { - timer := time.NewTimer(interval) - defer timer.Stop() - - select { - case <-ctx.Done(): - return false, ctx.Err() - case <-timer.C: - return true, nil - } -} - -func isTerminalStatus(status string) bool { - return status == "done" || status == "failed" || status == "cancelled" -} - -func isDeadlineExceeded(ctx context.Context) bool { - return ctx.Err() != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) -} - -func isSQLiteBusyError(err error) bool { - message := strings.ToLower(err.Error()) - return strings.Contains(message, "sqlite_busy") || - strings.Contains(message, "database is locked") || - strings.Contains(message, "database table is locked") -} - -func shouldMarkMessageRead(message Message, agent string) bool { - if agent == "" { - return false - } - return message.ToAgent == agent && message.FromAgent != agent -} - -func defaultID(value, prefix string) string { - if value != "" { - return value - } - return newID(prefix) -} - -func newID(prefix string) string { - return prefix + "_" + strings.ReplaceAll(uuid.NewString(), "-", "") -} - -func defaultString(value, fallback string) string { - if value != "" { - return value - } - return fallback -} - -func normalizeJSON(value string) string { - if strings.TrimSpace(value) == "" { - return "{}" - } - return value -} - -func validateAndNormalizeJSON(fieldName, value string) (string, error) { - normalized := normalizeJSON(value) - if !json.Valid([]byte(normalized)) { - return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName) - } - - var compact bytes.Buffer - if err := json.Compact(&compact, []byte(normalized)); err != nil { - return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName) - } - - return compact.String(), nil -} - -func placeholders(n int) string { - if n <= 0 { - return "" - } - parts := make([]string, n) - for i := range parts { - parts[i] = "?" - } - return strings.Join(parts, ",") -} - -func nowUTC() time.Time { - return time.Now().UTC() -} - -func formatTime(t time.Time) string { - return t.UTC().Format(time.RFC3339Nano) -} - -func parseTime(value string) time.Time { - parsed, err := time.Parse(time.RFC3339Nano, value) - if err != nil { - return time.Time{} - } - return parsed -} diff --git a/internal/store/inbox_test.go b/internal/store/inbox_test.go deleted file mode 100644 index 53ed9c9..0000000 --- a/internal/store/inbox_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package store - -import ( - "context" - "errors" - "path/filepath" - "testing" - "time" - - dbpkg "ai-workflow-skill/internal/db" -) - -func TestClaimThreadReturnsLeaseConflictAfterBusyWrite(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - dbPath := filepath.Join(t.TempDir(), "coord.db") - - sqlDB, err := dbpkg.Open(ctx, dbPath) - if err != nil { - t.Fatalf("open base db: %v", err) - } - defer sqlDB.Close() - - if err := dbpkg.ApplyMigrations(ctx, sqlDB); err != nil { - t.Fatalf("apply migrations: %v", err) - } - - baseStore := NewInboxStore(sqlDB) - thread, _, err := baseStore.Send(ctx, SendInput{ - FromAgent: "leader", - ToAgent: "worker-a", - Subject: "race claim", - Summary: "race claim", - }) - if err != nil { - t.Fatalf("seed thread: %v", err) - } - - lockerDB, err := dbpkg.Open(ctx, dbPath) - if err != nil { - t.Fatalf("open locker db: %v", err) - } - defer lockerDB.Close() - - lockTx, err := lockerDB.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin locker tx: %v", err) - } - - now := nowUTC() - if _, err := lockTx.ExecContext( - ctx, - `INSERT INTO leases ( - thread_id, agent_id, lease_token, claimed_at, expires_at, released_at - ) VALUES (?, ?, ?, ?, ?, NULL)`, - thread.ThreadID, - "worker-a", - "lease_locked", - formatTime(now), - formatTime(now.Add(5*time.Minute)), - ); err != nil { - t.Fatalf("seed active lease in tx: %v", err) - } - - if _, err := lockTx.ExecContext( - ctx, - `UPDATE threads - SET status = ?, assigned_to = ?, latest_message_id = ?, updated_at = ? - WHERE thread_id = ?`, - "claimed", - "worker-a", - "msg_locked", - formatTime(now), - thread.ThreadID, - ); err != nil { - t.Fatalf("seed claimed thread in tx: %v", err) - } - - commitDone := make(chan error, 1) - go func() { - time.Sleep(100 * time.Millisecond) - commitDone <- lockTx.Commit() - }() - - claimDB, err := dbpkg.Open(ctx, dbPath) - if err != nil { - t.Fatalf("open claim db: %v", err) - } - defer claimDB.Close() - - claimStore := NewInboxStore(claimDB) - _, err = claimStore.ClaimThread(ctx, ClaimInput{ - ThreadID: thread.ThreadID, - Agent: "worker-b", - LeaseSeconds: 300, - }) - if !errors.Is(err, ErrLeaseConflict) { - t.Fatalf("expected lease conflict after busy retry, got %v", err) - } - - if err := <-commitDone; err != nil { - t.Fatalf("commit locker tx: %v", err) - } -} diff --git a/internal/store/orch.go b/internal/store/orch.go deleted file mode 100644 index 130340e..0000000 --- a/internal/store/orch.go +++ /dev/null @@ -1,2579 +0,0 @@ -package store - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "ai-workflow-skill/internal/protocol" -) - -var ErrRunNotFound = errors.New("run not found") -var ErrTaskNotFound = errors.New("task not found") - -type OrchStore struct { - db *sql.DB -} - -type Run struct { - RunID string `json:"run_id"` - Goal string `json:"goal"` - Summary string `json:"summary"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type Task struct { - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - Title string `json:"title"` - Summary string `json:"summary"` - Status string `json:"status"` - DefaultTo string `json:"default_to,omitempty"` - Priority string `json:"priority"` - AcceptanceJSON json.RawMessage `json:"acceptance_json"` - LatestAttemptNo int `json:"latest_attempt_no,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type TaskDependency struct { - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - DependsOnTaskID string `json:"depends_on_task_id"` -} - -type TaskAttempt struct { - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - AttemptNo int `json:"attempt_no"` - AssignedTo string `json:"assigned_to"` - ThreadID string `json:"thread_id"` - BaseRef string `json:"base_ref,omitempty"` - BaseCommit string `json:"base_commit,omitempty"` - BranchName string `json:"branch_name,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorkspaceStatus string `json:"workspace_status,omitempty"` - ResultCommit string `json:"result_commit,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type RunOverview struct { - Run Run `json:"run"` - TaskCounts map[string]int `json:"task_counts"` - Tasks []Task `json:"tasks,omitempty"` -} - -type CreateRunInput struct { - RunID string - Goal string - Summary string -} - -type AddTaskInput struct { - RunID string - TaskID string - Title string - Summary string - DefaultTo string - AcceptanceJSON string - Priority string -} - -type AddDependencyInput struct { - RunID string - TaskID string - DependsOnTaskID string -} - -type ListReadyInput struct { - RunID string - Limit int -} - -type DispatchInput struct { - RunID string - TaskID string - ToAgent string - Body string - BaseRef string - PrepareWorkspace DispatchWorkspacePreparer -} - -type DispatchResult struct { - Task Task `json:"task"` - Attempt TaskAttempt `json:"attempt"` - Thread Thread `json:"thread"` - Message Message `json:"message"` -} - -type ReconcileResult struct { - Run Run `json:"run"` - TaskCounts map[string]int `json:"task_counts"` - UpdatedTasks []Task `json:"updated_tasks"` -} - -type RunEvent struct { - EventID int64 `json:"event_id"` - Type string `json:"type"` - RunID string `json:"run_id"` - TaskID string `json:"task_id"` - ThreadID string `json:"thread_id,omitempty"` - Summary string `json:"summary"` - Payload json.RawMessage `json:"payload"` - CreatedAt time.Time `json:"created_at"` -} - -type WaitInput struct { - RunID string - EventTypes []string - AfterEventID int64 - Timeout time.Duration -} - -type WaitResult struct { - Woke bool `json:"woke"` - NextEventID int64 `json:"next_event_id"` - Events []RunEvent `json:"events,omitempty"` -} - -type DispatchWorkspace struct { - BaseRef string `json:"base_ref,omitempty"` - BaseCommit string `json:"base_commit,omitempty"` - BranchName string `json:"branch_name,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorkspaceStatus string `json:"workspace_status,omitempty"` -} - -type DispatchWorkspacePreparer func(task Task, attemptNo int) (DispatchWorkspace, func(), error) - -type BlockedTask struct { - Task Task `json:"task"` - Attempt TaskAttempt `json:"attempt"` - Question Message `json:"question"` -} - -type AnswerInput struct { - RunID string - TaskID string - Body string - PayloadJSON string -} - -type AnswerResult struct { - Task Task `json:"task"` - Attempt TaskAttempt `json:"attempt"` - Thread Thread `json:"thread"` - Message Message `json:"message"` -} - -type RetryInput struct { - RunID string - TaskID string - ToAgent string - Body string - PrepareWorkspace DispatchWorkspacePreparer -} - -type RetryResult struct { - Task Task `json:"task"` - Attempt TaskAttempt `json:"attempt"` - Thread Thread `json:"thread"` - Message Message `json:"message"` - PreviousAttempt TaskAttempt `json:"previous_attempt"` -} - -type ReassignInput struct { - RunID string - TaskID string - ToAgent string - Reason string - PrepareWorkspace DispatchWorkspacePreparer -} - -type ReassignResult struct { - Task Task `json:"task"` - Attempt TaskAttempt `json:"attempt"` - Thread Thread `json:"thread"` - Message Message `json:"message"` - PreviousAttempt TaskAttempt `json:"previous_attempt"` -} - -type CancelControlInput struct { - RunID string - TaskID string - Reason string -} - -type CancelResult struct { - Run Run `json:"run"` - CancelledTasks []Task `json:"cancelled_tasks"` -} - -type CleanupInput struct { - RunID string - TaskID string - AttemptNo int - AllCompleted bool - Force bool -} - -type CleanupCandidate struct { - Attempt TaskAttempt `json:"attempt"` -} - -type CleanupRecord struct { - Attempt TaskAttempt `json:"attempt"` -} - -func NewOrchStore(db *sql.DB) *OrchStore { - return &OrchStore{db: db} -} - -func (s *OrchStore) CreateRun(ctx context.Context, input CreateRunInput) (Run, error) { - runID := strings.TrimSpace(input.RunID) - goal := strings.TrimSpace(input.Goal) - if runID == "" { - return Run{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if goal == "" { - return Run{}, fmt.Errorf("%w: goal is required", ErrInvalidInput) - } - - now := nowUTC() - run := Run{ - RunID: runID, - Goal: goal, - Summary: strings.TrimSpace(input.Summary), - Status: "active", - CreatedAt: now, - UpdatedAt: now, - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Run{}, fmt.Errorf("begin create run transaction: %w", err) - } - defer tx.Rollback() - - _, err = tx.ExecContext( - ctx, - `INSERT INTO runs (run_id, goal, summary, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?)`, - run.RunID, - run.Goal, - run.Summary, - run.Status, - formatTime(run.CreatedAt), - formatTime(run.UpdatedAt), - ) - if err != nil { - if isUniqueConstraintError(err) { - return Run{}, fmt.Errorf("%w: run %s already exists", ErrInvalidState, run.RunID) - } - return Run{}, fmt.Errorf("insert run: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: run.RunID, - TaskID: "", - Source: "orch", - EventType: "run_initialized", - Summary: defaultString(run.Summary, run.Goal), - PayloadJSON: marshalJSON(map[string]any{"goal": run.Goal, "summary": run.Summary}), - CreatedAt: now, - }); err != nil { - return Run{}, err - } - - if err := tx.Commit(); err != nil { - return Run{}, fmt.Errorf("commit create run transaction: %w", err) - } - - return run, nil -} - -func (s *OrchStore) GetRun(ctx context.Context, runID string) (Run, error) { - return selectRun(ctx, s.db, runID) -} - -func (s *OrchStore) AddTask(ctx context.Context, input AddTaskInput) (Task, error) { - if strings.TrimSpace(input.RunID) == "" { - return Task{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return Task{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.Title) == "" { - return Task{}, fmt.Errorf("%w: title is required", ErrInvalidInput) - } - - priority, err := normalizePriority(input.Priority) - if err != nil { - return Task{}, err - } - acceptanceJSON, err := validateAndNormalizeJSONDefault("acceptance-json", input.AcceptanceJSON, "[]") - if err != nil { - return Task{}, err - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Task{}, fmt.Errorf("begin add task transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return Task{}, err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO tasks ( - run_id, task_id, title, summary, status, default_to, priority, - acceptance_json, latest_attempt_no, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`, - input.RunID, - input.TaskID, - input.Title, - input.Summary, - "planned", - nullIfEmpty(input.DefaultTo), - priority, - acceptanceJSON, - formatTime(now), - formatTime(now), - ) - if err != nil { - if isUniqueConstraintError(err) { - return Task{}, fmt.Errorf("%w: task %s already exists in run %s", ErrInvalidState, input.TaskID, input.RunID) - } - return Task{}, fmt.Errorf("insert task: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: input.RunID, - TaskID: input.TaskID, - Source: "orch", - EventType: "task_added", - Summary: input.Title, - PayloadJSON: marshalJSON(map[string]any{"title": input.Title, "priority": priority}), - CreatedAt: now, - }); err != nil { - return Task{}, err - } - - if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil { - return Task{}, err - } - if err := updateRunAggregateStatus(ctx, tx, input.RunID, now); err != nil { - return Task{}, err - } - - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return Task{}, err - } - - if err := tx.Commit(); err != nil { - return Task{}, fmt.Errorf("commit add task transaction: %w", err) - } - - return task, nil -} - -func (s *OrchStore) AddDependency(ctx context.Context, input AddDependencyInput) (TaskDependency, error) { - if strings.TrimSpace(input.RunID) == "" { - return TaskDependency{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return TaskDependency{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.DependsOnTaskID) == "" { - return TaskDependency{}, fmt.Errorf("%w: depends-on task id is required", ErrInvalidInput) - } - if input.TaskID == input.DependsOnTaskID { - return TaskDependency{}, fmt.Errorf("%w: task cannot depend on itself", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return TaskDependency{}, fmt.Errorf("begin add dependency transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return TaskDependency{}, err - } - if _, err := selectTask(ctx, tx, input.RunID, input.TaskID); err != nil { - return TaskDependency{}, err - } - if _, err := selectTask(ctx, tx, input.RunID, input.DependsOnTaskID); err != nil { - return TaskDependency{}, err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO task_dependencies (run_id, task_id, depends_on_task_id) - VALUES (?, ?, ?)`, - input.RunID, - input.TaskID, - input.DependsOnTaskID, - ) - if err != nil { - if isUniqueConstraintError(err) { - return TaskDependency{}, fmt.Errorf("%w: dependency %s -> %s already exists", ErrInvalidState, input.TaskID, input.DependsOnTaskID) - } - return TaskDependency{}, fmt.Errorf("insert dependency: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: input.RunID, - TaskID: input.TaskID, - Source: "orch", - EventType: "task_dependency_added", - Summary: fmt.Sprintf("%s depends on %s", input.TaskID, input.DependsOnTaskID), - PayloadJSON: marshalJSON(map[string]any{"depends_on_task_id": input.DependsOnTaskID}), - CreatedAt: now, - }); err != nil { - return TaskDependency{}, err - } - - if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil { - return TaskDependency{}, err - } - if err := updateRunAggregateStatus(ctx, tx, input.RunID, now); err != nil { - return TaskDependency{}, err - } - - if err := tx.Commit(); err != nil { - return TaskDependency{}, fmt.Errorf("commit add dependency transaction: %w", err) - } - - return TaskDependency{ - RunID: input.RunID, - TaskID: input.TaskID, - DependsOnTaskID: input.DependsOnTaskID, - }, nil -} - -func (s *OrchStore) ListReadyTasks(ctx context.Context, input ListReadyInput) ([]Task, error) { - if strings.TrimSpace(input.RunID) == "" { - return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - limit := input.Limit - if limit <= 0 { - limit = 20 - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin list ready transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return nil, err - } - if err := refreshReadyStates(ctx, tx, input.RunID, nowUTC()); err != nil { - return nil, err - } - if err := updateRunAggregateStatus(ctx, tx, input.RunID, nowUTC()); err != nil { - return nil, err - } - - rows, err := tx.QueryContext( - ctx, - `SELECT - run_id, task_id, title, summary, status, default_to, priority, - acceptance_json, latest_attempt_no, created_at, updated_at - FROM tasks - WHERE run_id = ? AND status = 'ready' - ORDER BY CASE priority - WHEN 'high' THEN 0 - WHEN 'normal' THEN 1 - ELSE 2 - END, created_at ASC - LIMIT ?`, - input.RunID, - limit, - ) - if err != nil { - return nil, fmt.Errorf("query ready tasks: %w", err) - } - defer rows.Close() - - var tasks []Task - for rows.Next() { - task, err := scanTask(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, task) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate ready tasks: %w", err) - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit list ready transaction: %w", err) - } - - return tasks, nil -} - -func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (DispatchResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return DispatchResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return DispatchResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return DispatchResult{}, fmt.Errorf("begin dispatch transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return DispatchResult{}, err - } - if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil { - return DispatchResult{}, err - } - - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return DispatchResult{}, err - } - if task.Status != "ready" { - return DispatchResult{}, fmt.Errorf("%w: task %s is not ready for dispatch", ErrInvalidState, task.TaskID) - } - - result, finalizeWorkspace, err := s.dispatchTaskTx(ctx, tx, task, strings.TrimSpace(input.ToAgent), input.Body, strings.TrimSpace(input.BaseRef), input.PrepareWorkspace, now) - if err != nil { - return DispatchResult{}, err - } - workspaceCommitted := false - defer func() { - finalizeWorkspace(workspaceCommitted) - }() - - if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil { - return DispatchResult{}, err - } - - if err := tx.Commit(); err != nil { - return DispatchResult{}, fmt.Errorf("commit dispatch transaction: %w", err) - } - workspaceCommitted = true - return result, nil -} - -func (s *OrchStore) GetTaskWithLatestAttempt(ctx context.Context, runID, taskID string) (Task, *TaskAttempt, error) { - task, err := selectTask(ctx, s.db, runID, taskID) - if err != nil { - return Task{}, nil, err - } - if task.LatestAttemptNo == 0 { - return task, nil, nil - } - - attempt, err := selectAttempt(ctx, s.db, runID, taskID, task.LatestAttemptNo) - if err != nil { - return Task{}, nil, err - } - return task, &attempt, nil -} - -func (s *OrchStore) RetryTask(ctx context.Context, input RetryInput) (RetryResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return RetryResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return RetryResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return RetryResult{}, fmt.Errorf("begin retry transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return RetryResult{}, err - } - - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return RetryResult{}, err - } - if task.Status != "failed" { - return RetryResult{}, fmt.Errorf("%w: task %s is not failed", ErrInvalidState, task.TaskID) - } - if task.LatestAttemptNo == 0 { - return RetryResult{}, fmt.Errorf("%w: task %s has no attempt to retry", ErrInvalidState, task.TaskID) - } - - previousAttempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo) - if err != nil { - return RetryResult{}, err - } - - result, finalizeWorkspace, err := s.dispatchTaskTx( - ctx, - tx, - task, - strings.TrimSpace(input.ToAgent), - input.Body, - defaultString(previousAttempt.BaseRef, previousAttempt.BaseCommit), - input.PrepareWorkspace, - now, - ) - if err != nil { - return RetryResult{}, err - } - workspaceCommitted := false - defer func() { - finalizeWorkspace(workspaceCommitted) - }() - - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET workspace_status = CASE - WHEN workspace_status = 'cleaned' THEN workspace_status - ELSE ? - END, - updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - "abandoned", - formatTime(now), - previousAttempt.RunID, - previousAttempt.TaskID, - previousAttempt.AttemptNo, - ) - if err != nil { - return RetryResult{}, fmt.Errorf("mark previous retry attempt abandoned: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: task.RunID, - TaskID: task.TaskID, - ThreadID: result.Thread.ThreadID, - Source: "orch", - EventType: "task_retried", - MessageID: result.Message.MessageID, - Summary: result.Message.Summary, - PayloadJSON: marshalJSON(map[string]any{ - "previous_attempt_no": previousAttempt.AttemptNo, - "previous_thread_id": previousAttempt.ThreadID, - "attempt_no": result.Attempt.AttemptNo, - "thread_id": result.Attempt.ThreadID, - }), - CreatedAt: now, - }); err != nil { - return RetryResult{}, err - } - - if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil { - return RetryResult{}, err - } - - if err := tx.Commit(); err != nil { - return RetryResult{}, fmt.Errorf("commit retry transaction: %w", err) - } - workspaceCommitted = true - - return RetryResult{ - Task: result.Task, - Attempt: result.Attempt, - Thread: result.Thread, - Message: result.Message, - PreviousAttempt: previousAttempt, - }, nil -} - -func (s *OrchStore) ReassignTask(ctx context.Context, input ReassignInput) (ReassignResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return ReassignResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return ReassignResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.ToAgent) == "" { - return ReassignResult{}, fmt.Errorf("%w: destination agent is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ReassignResult{}, fmt.Errorf("begin reassign transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, input.RunID); err != nil { - return ReassignResult{}, err - } - - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return ReassignResult{}, err - } - if task.Status != "blocked" && task.Status != "failed" { - return ReassignResult{}, fmt.Errorf("%w: task %s is not blocked or failed", ErrInvalidState, task.TaskID) - } - if task.LatestAttemptNo == 0 { - return ReassignResult{}, fmt.Errorf("%w: task %s has no attempt to reassign", ErrInvalidState, task.TaskID) - } - - previousAttempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo) - if err != nil { - return ReassignResult{}, err - } - - if task.Status == "blocked" && previousAttempt.ThreadID != "" { - thread, err := selectThread(ctx, tx, previousAttempt.ThreadID) - if err != nil && !errors.Is(err, ErrThreadNotFound) { - return ReassignResult{}, err - } - if err == nil && !isTerminalStatus(thread.Status) { - if err := cancelThreadTx(ctx, tx, thread, defaultString(input.Reason, "task reassigned"), now); err != nil { - return ReassignResult{}, err - } - } - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET status = ?, workspace_status = CASE - WHEN workspace_status = 'cleaned' THEN workspace_status - ELSE ? - END, - updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - "cancelled", - "abandoned", - formatTime(now), - previousAttempt.RunID, - previousAttempt.TaskID, - previousAttempt.AttemptNo, - ) - if err != nil { - return ReassignResult{}, fmt.Errorf("mark previous blocked attempt abandoned: %w", err) - } - } else { - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET workspace_status = CASE - WHEN workspace_status = 'cleaned' THEN workspace_status - ELSE ? - END, - updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - "abandoned", - formatTime(now), - previousAttempt.RunID, - previousAttempt.TaskID, - previousAttempt.AttemptNo, - ) - if err != nil { - return ReassignResult{}, fmt.Errorf("mark previous attempt abandoned: %w", err) - } - } - - result, finalizeWorkspace, err := s.dispatchTaskTx( - ctx, - tx, - task, - strings.TrimSpace(input.ToAgent), - input.Reason, - defaultString(previousAttempt.BaseRef, previousAttempt.BaseCommit), - input.PrepareWorkspace, - now, - ) - if err != nil { - return ReassignResult{}, err - } - workspaceCommitted := false - defer func() { - finalizeWorkspace(workspaceCommitted) - }() - - if err := insertEvent(ctx, tx, eventInput{ - RunID: task.RunID, - TaskID: task.TaskID, - ThreadID: result.Thread.ThreadID, - Source: "orch", - EventType: "task_reassigned", - MessageID: result.Message.MessageID, - Summary: defaultString(input.Reason, result.Message.Summary), - PayloadJSON: marshalJSON(map[string]any{ - "previous_attempt_no": previousAttempt.AttemptNo, - "previous_thread_id": previousAttempt.ThreadID, - "from_agent": previousAttempt.AssignedTo, - "to_agent": result.Attempt.AssignedTo, - "attempt_no": result.Attempt.AttemptNo, - "thread_id": result.Attempt.ThreadID, - }), - CreatedAt: now, - }); err != nil { - return ReassignResult{}, err - } - - if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil { - return ReassignResult{}, err - } - - if err := tx.Commit(); err != nil { - return ReassignResult{}, fmt.Errorf("commit reassign transaction: %w", err) - } - workspaceCommitted = true - - return ReassignResult{ - Task: result.Task, - Attempt: result.Attempt, - Thread: result.Thread, - Message: result.Message, - PreviousAttempt: previousAttempt, - }, nil -} - -func (s *OrchStore) Cancel(ctx context.Context, input CancelControlInput) (CancelResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return CancelResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return CancelResult{}, fmt.Errorf("begin cancel transaction: %w", err) - } - defer tx.Rollback() - - run, err := selectRun(ctx, tx, input.RunID) - if err != nil { - return CancelResult{}, err - } - - var tasks []Task - if strings.TrimSpace(input.TaskID) != "" { - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return CancelResult{}, err - } - tasks = append(tasks, task) - } else { - tasks, err = listTasksForRun(ctx, tx, input.RunID) - if err != nil { - return CancelResult{}, err - } - } - - cancelledTasks := make([]Task, 0, len(tasks)) - for _, task := range tasks { - if task.Status == "cancelled" { - if strings.TrimSpace(input.TaskID) != "" { - return CancelResult{}, fmt.Errorf("%w: task %s is already cancelled", ErrInvalidState, task.TaskID) - } - continue - } - - cancelledTask, err := cancelTaskTx(ctx, tx, task, defaultString(input.Reason, "task cancelled"), now) - if err != nil { - return CancelResult{}, err - } - cancelledTasks = append(cancelledTasks, cancelledTask) - } - - if len(cancelledTasks) == 0 && len(tasks) == 0 { - _, err = tx.ExecContext( - ctx, - `UPDATE runs SET status = ?, updated_at = ? WHERE run_id = ?`, - "cancelled", - formatTime(now), - run.RunID, - ) - if err != nil { - return CancelResult{}, fmt.Errorf("cancel empty run: %w", err) - } - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: run.RunID, - Source: "orch", - EventType: "run_cancelled", - Summary: defaultString(input.Reason, "run cancelled"), - PayloadJSON: marshalJSON(map[string]any{ - "task_id": input.TaskID, - "reason": input.Reason, - }), - CreatedAt: now, - }); err != nil { - return CancelResult{}, err - } - - if err := updateRunAggregateStatus(ctx, tx, run.RunID, now); err != nil { - return CancelResult{}, err - } - - run, err = selectRun(ctx, tx, run.RunID) - if err != nil { - return CancelResult{}, err - } - - if err := tx.Commit(); err != nil { - return CancelResult{}, fmt.Errorf("commit cancel transaction: %w", err) - } - - return CancelResult{ - Run: run, - CancelledTasks: cancelledTasks, - }, nil -} - -func (s *OrchStore) ListCleanupCandidates(ctx context.Context, input CleanupInput) ([]CleanupCandidate, error) { - if strings.TrimSpace(input.RunID) == "" { - return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if input.AttemptNo > 0 && strings.TrimSpace(input.TaskID) == "" { - return nil, fmt.Errorf("%w: task id is required when attempt is specified", ErrInvalidInput) - } - if !input.AllCompleted && strings.TrimSpace(input.TaskID) == "" && input.AttemptNo == 0 { - return nil, fmt.Errorf("%w: specify --task, --attempt, or --all-completed", ErrInvalidInput) - } - - if _, err := s.GetRun(ctx, input.RunID); err != nil { - return nil, err - } - - conditions := []string{"run_id = ?", "worktree_path <> ''", "workspace_status <> 'cleaned'"} - args := []any{input.RunID} - if strings.TrimSpace(input.TaskID) != "" { - conditions = append(conditions, "task_id = ?") - args = append(args, strings.TrimSpace(input.TaskID)) - } - if input.AttemptNo > 0 { - conditions = append(conditions, "attempt_no = ?") - args = append(args, input.AttemptNo) - } - if !input.Force { - conditions = append(conditions, "workspace_status IN (?, ?)") - args = append(args, "completed", "abandoned") - } - - query := `SELECT - run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit, - branch_name, worktree_path, workspace_status, result_commit, status, - created_at, updated_at - FROM task_attempts - WHERE ` + strings.Join(conditions, " AND ") + ` - ORDER BY run_id, task_id, attempt_no ASC` - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("query cleanup candidates: %w", err) - } - defer rows.Close() - - var candidates []CleanupCandidate - for rows.Next() { - attempt, err := scanAttempt(rows) - if err != nil { - return nil, err - } - candidates = append(candidates, CleanupCandidate{Attempt: attempt}) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate cleanup candidates: %w", err) - } - - if len(candidates) == 0 { - return nil, protocol.NoMatchingWork("no cleanup candidates matched the requested filters") - } - return candidates, nil -} - -func (s *OrchStore) MarkAttemptsCleaned(ctx context.Context, records []CleanupRecord) ([]TaskAttempt, error) { - if len(records) == 0 { - return nil, nil - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin cleanup commit transaction: %w", err) - } - defer tx.Rollback() - - cleaned := make([]TaskAttempt, 0, len(records)) - for _, record := range records { - attempt := record.Attempt - _, err := tx.ExecContext( - ctx, - `UPDATE task_attempts - SET workspace_status = ?, updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - "cleaned", - formatTime(now), - attempt.RunID, - attempt.TaskID, - attempt.AttemptNo, - ) - if err != nil { - return nil, fmt.Errorf("mark attempt cleaned: %w", err) - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: attempt.RunID, - TaskID: attempt.TaskID, - ThreadID: attempt.ThreadID, - Source: "orch", - EventType: "workspace_cleaned", - Summary: fmt.Sprintf("cleaned workspace for %s/%s attempt %d", attempt.RunID, attempt.TaskID, attempt.AttemptNo), - PayloadJSON: marshalJSON(map[string]any{ - "attempt_no": attempt.AttemptNo, - "worktree_path": attempt.WorktreePath, - }), - CreatedAt: now, - }); err != nil { - return nil, err - } - attempt.WorkspaceStatus = "cleaned" - attempt.UpdatedAt = now - cleaned = append(cleaned, attempt) - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit cleanup transaction: %w", err) - } - - return cleaned, nil -} - -func (s *OrchStore) dispatchTaskTx( - ctx context.Context, - tx *sql.Tx, - task Task, - toAgent string, - body string, - baseRef string, - prepareWorkspace DispatchWorkspacePreparer, - now time.Time, -) (DispatchResult, func(bool), error) { - assignedTo := defaultString(strings.TrimSpace(toAgent), task.DefaultTo) - if assignedTo == "" { - return DispatchResult{}, nil, fmt.Errorf("%w: dispatch target agent is required", ErrInvalidInput) - } - - attemptNo := task.LatestAttemptNo + 1 - workspace := DispatchWorkspace{ - BaseRef: strings.TrimSpace(baseRef), - } - finalizeWorkspace := func(success bool) {} - if prepareWorkspace != nil { - cleanupWorkspace := func() {} - var err error - workspace, cleanupWorkspace, err = prepareWorkspace(task, attemptNo) - if err != nil { - return DispatchResult{}, nil, err - } - if cleanupWorkspace == nil { - cleanupWorkspace = func() {} - } - finalizeWorkspace = func(success bool) { - if !success { - cleanupWorkspace() - } - } - } - - threadID := newID("thr") - messageID := newID("msg") - payloadJSON := buildDispatchPayload(task, attemptNo, workspace) - thread := Thread{ - ThreadID: threadID, - RunID: task.RunID, - TaskID: task.TaskID, - Subject: task.Title, - CreatedBy: "orch", - AssignedTo: assignedTo, - Status: "pending", - Priority: task.Priority, - LatestMessageID: messageID, - CreatedAt: now, - UpdatedAt: now, - } - - _, err := tx.ExecContext( - ctx, - `INSERT INTO threads ( - thread_id, run_id, task_id, subject, created_by, assigned_to, status, - priority, latest_message_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - thread.ThreadID, - thread.RunID, - thread.TaskID, - thread.Subject, - thread.CreatedBy, - thread.AssignedTo, - thread.Status, - thread.Priority, - thread.LatestMessageID, - formatTime(thread.CreatedAt), - formatTime(thread.UpdatedAt), - ) - if err != nil { - return DispatchResult{}, finalizeWorkspace, fmt.Errorf("insert dispatch thread: %w", err) - } - - message := Message{ - MessageID: messageID, - ThreadID: threadID, - FromAgent: "orch", - ToAgent: assignedTo, - Kind: "task", - Summary: defaultString(task.Summary, task.Title), - Body: body, - PayloadJSON: json.RawMessage(payloadJSON), - CreatedAt: now, - } - if err := insertMessage(ctx, tx, message); err != nil { - return DispatchResult{}, finalizeWorkspace, err - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_created", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: payloadJSON, - CreatedAt: now, - }); err != nil { - return DispatchResult{}, finalizeWorkspace, err - } - - attempt := TaskAttempt{ - RunID: task.RunID, - TaskID: task.TaskID, - AttemptNo: attemptNo, - AssignedTo: assignedTo, - ThreadID: threadID, - BaseRef: workspace.BaseRef, - BaseCommit: workspace.BaseCommit, - BranchName: workspace.BranchName, - WorktreePath: workspace.WorktreePath, - WorkspaceStatus: workspace.WorkspaceStatus, - Status: "dispatched", - CreatedAt: now, - UpdatedAt: now, - } - _, err = tx.ExecContext( - ctx, - `INSERT INTO task_attempts ( - run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit, - branch_name, worktree_path, workspace_status, result_commit, status, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - attempt.RunID, - attempt.TaskID, - attempt.AttemptNo, - attempt.AssignedTo, - attempt.ThreadID, - nullIfEmpty(attempt.BaseRef), - nullIfEmpty(attempt.BaseCommit), - nullIfEmpty(attempt.BranchName), - nullIfEmpty(attempt.WorktreePath), - nullIfEmpty(attempt.WorkspaceStatus), - nil, - attempt.Status, - formatTime(attempt.CreatedAt), - formatTime(attempt.UpdatedAt), - ) - if err != nil { - return DispatchResult{}, finalizeWorkspace, fmt.Errorf("insert task attempt: %w", err) - } - - _, err = tx.ExecContext( - ctx, - `UPDATE tasks - SET status = ?, latest_attempt_no = ?, updated_at = ? - WHERE run_id = ? AND task_id = ?`, - "dispatched", - attempt.AttemptNo, - formatTime(now), - task.RunID, - task.TaskID, - ) - if err != nil { - return DispatchResult{}, finalizeWorkspace, fmt.Errorf("update task dispatch status: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: task.RunID, - TaskID: task.TaskID, - ThreadID: thread.ThreadID, - Source: "orch", - EventType: "task_dispatched", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: payloadJSON, - CreatedAt: now, - }); err != nil { - return DispatchResult{}, finalizeWorkspace, err - } - - task.Status = "dispatched" - task.LatestAttemptNo = attempt.AttemptNo - task.UpdatedAt = now - - return DispatchResult{ - Task: task, - Attempt: attempt, - Thread: thread, - Message: message, - }, finalizeWorkspace, nil -} - -func cancelTaskTx(ctx context.Context, tx *sql.Tx, task Task, reason string, now time.Time) (Task, error) { - if task.LatestAttemptNo > 0 { - attempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo) - if err != nil { - return Task{}, err - } - if attempt.ThreadID != "" { - thread, err := selectThread(ctx, tx, attempt.ThreadID) - if err != nil && !errors.Is(err, ErrThreadNotFound) { - return Task{}, err - } - if err == nil && !isTerminalStatus(thread.Status) { - if err := cancelThreadTx(ctx, tx, thread, reason, now); err != nil { - return Task{}, err - } - } - } - - attemptStatus := attempt.Status - if attemptStatus != "done" && attemptStatus != "failed" && attemptStatus != "cancelled" { - attemptStatus = "cancelled" - } - workspaceStatus := attempt.WorkspaceStatus - if workspaceStatus != "cleaned" { - workspaceStatus = "abandoned" - } - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET status = ?, workspace_status = ?, updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - attemptStatus, - nullIfEmpty(workspaceStatus), - formatTime(now), - attempt.RunID, - attempt.TaskID, - attempt.AttemptNo, - ) - if err != nil { - return Task{}, fmt.Errorf("update cancelled attempt: %w", err) - } - } - - _, err := tx.ExecContext( - ctx, - `UPDATE tasks - SET status = ?, updated_at = ? - WHERE run_id = ? AND task_id = ?`, - "cancelled", - formatTime(now), - task.RunID, - task.TaskID, - ) - if err != nil { - return Task{}, fmt.Errorf("update cancelled task: %w", err) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: task.RunID, - TaskID: task.TaskID, - Source: "orch", - EventType: "task_cancelled", - Summary: defaultString(reason, "task cancelled"), - PayloadJSON: marshalJSON(map[string]any{"reason": reason}), - CreatedAt: now, - }); err != nil { - return Task{}, err - } - - task.Status = "cancelled" - task.UpdatedAt = now - return task, nil -} - -func cancelThreadTx(ctx context.Context, tx *sql.Tx, thread Thread, reason string, now time.Time) error { - messageID := newID("msg") - summary := defaultString(reason, "thread cancelled") - message := Message{ - MessageID: messageID, - ThreadID: thread.ThreadID, - FromAgent: "orch", - ToAgent: thread.AssignedTo, - Kind: "control", - Summary: summary, - Body: reason, - PayloadJSON: json.RawMessage(`{}`), - CreatedAt: now, - } - - if err := insertMessage(ctx, tx, message); err != nil { - return err - } - if err := updateThreadState(ctx, tx, thread.ThreadID, "cancelled", thread.AssignedTo, message.MessageID, now); err != nil { - return err - } - if _, err := tx.ExecContext( - ctx, - `UPDATE leases - SET released_at = ? - WHERE thread_id = ? - AND released_at IS NULL`, - formatTime(now), - thread.ThreadID, - ); err != nil { - return fmt.Errorf("release lease on orch cancel: %w", err) - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_cancelled", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: string(message.PayloadJSON), - CreatedAt: now, - }); err != nil { - return err - } - return nil -} - -func (s *OrchStore) ReconcileRun(ctx context.Context, runID string) (ReconcileResult, error) { - if strings.TrimSpace(runID) == "" { - return ReconcileResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ReconcileResult{}, fmt.Errorf("begin reconcile transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, runID); err != nil { - return ReconcileResult{}, err - } - - rows, err := tx.QueryContext( - ctx, - `SELECT - t.task_id, - t.status, - a.attempt_no, - a.status, - a.thread_id, - th.status - FROM tasks t - JOIN task_attempts a - ON a.run_id = t.run_id - AND a.task_id = t.task_id - AND a.attempt_no = t.latest_attempt_no - JOIN threads th ON th.thread_id = a.thread_id - WHERE t.run_id = ? - AND t.latest_attempt_no IS NOT NULL`, - runID, - ) - if err != nil { - return ReconcileResult{}, fmt.Errorf("query reconcile candidates: %w", err) - } - defer rows.Close() - - var updatedIDs []string - for rows.Next() { - var ( - taskID string - taskStatus string - attemptNo int - attemptStatus string - threadID string - threadStatus string - ) - if err := rows.Scan(&taskID, &taskStatus, &attemptNo, &attemptStatus, &threadID, &threadStatus); err != nil { - return ReconcileResult{}, fmt.Errorf("scan reconcile candidate: %w", err) - } - - nextStatus := reconcileTaskStatus(threadStatus) - if nextStatus == "" { - continue - } - if nextStatus == taskStatus && nextStatus == attemptStatus { - continue - } - - _, err = tx.ExecContext( - ctx, - `UPDATE tasks - SET status = ?, updated_at = ? - WHERE run_id = ? AND task_id = ?`, - nextStatus, - formatTime(now), - runID, - taskID, - ) - if err != nil { - return ReconcileResult{}, fmt.Errorf("update reconciled task status: %w", err) - } - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET status = ?, workspace_status = COALESCE(?, workspace_status), updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - nextStatus, - nullIfEmpty(reconcileWorkspaceStatus(threadStatus)), - formatTime(now), - runID, - taskID, - attemptNo, - ) - if err != nil { - return ReconcileResult{}, fmt.Errorf("update reconciled attempt status: %w", err) - } - - summary := fmt.Sprintf("%s -> %s", taskID, nextStatus) - payloadJSON := marshalJSON(map[string]any{ - "thread_id": threadID, - "thread_status": threadStatus, - "previous_status": taskStatus, - "previous_attempt": attemptStatus, - }) - if nextStatus == "blocked" { - question, err := selectLatestQuestionMessage(ctx, tx, threadID) - if err != nil { - return ReconcileResult{}, err - } - summary = question.Summary - payloadJSON = string(question.PayloadJSON) - } - - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - TaskID: taskID, - ThreadID: threadID, - Source: "orch", - EventType: "task_" + nextStatus, - Summary: summary, - PayloadJSON: payloadJSON, - CreatedAt: now, - }); err != nil { - return ReconcileResult{}, err - } - - updatedIDs = append(updatedIDs, taskID) - } - if err := rows.Err(); err != nil { - return ReconcileResult{}, fmt.Errorf("iterate reconcile candidates: %w", err) - } - - if err := refreshReadyStates(ctx, tx, runID, now); err != nil { - return ReconcileResult{}, err - } - if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil { - return ReconcileResult{}, err - } - - run, err := selectRun(ctx, tx, runID) - if err != nil { - return ReconcileResult{}, err - } - taskCounts, err := collectTaskCounts(ctx, tx, runID) - if err != nil { - return ReconcileResult{}, err - } - - updatedTasks := make([]Task, 0, len(updatedIDs)) - for _, taskID := range updatedIDs { - task, err := selectTask(ctx, tx, runID, taskID) - if err != nil { - return ReconcileResult{}, err - } - updatedTasks = append(updatedTasks, task) - } - - if err := tx.Commit(); err != nil { - return ReconcileResult{}, fmt.Errorf("commit reconcile transaction: %w", err) - } - - return ReconcileResult{ - Run: run, - TaskCounts: taskCounts, - UpdatedTasks: updatedTasks, - }, nil -} - -func (s *OrchStore) ListBlockedTasks(ctx context.Context, runID string) ([]BlockedTask, error) { - if strings.TrimSpace(runID) == "" { - return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("begin list blocked transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, runID); err != nil { - return nil, err - } - - rows, err := tx.QueryContext( - ctx, - `SELECT - t.run_id, t.task_id, t.title, t.summary, t.status, t.default_to, t.priority, - t.acceptance_json, t.latest_attempt_no, t.created_at, t.updated_at, - a.run_id, a.task_id, a.attempt_no, a.assigned_to, a.thread_id, a.base_ref, - a.base_commit, a.branch_name, a.worktree_path, a.workspace_status, - a.result_commit, a.status, a.created_at, a.updated_at - FROM tasks t - JOIN task_attempts a - ON a.run_id = t.run_id - AND a.task_id = t.task_id - AND a.attempt_no = t.latest_attempt_no - WHERE t.run_id = ? - AND t.status = 'blocked' - ORDER BY t.updated_at ASC`, - runID, - ) - if err != nil { - return nil, fmt.Errorf("query blocked tasks: %w", err) - } - defer rows.Close() - - var blocked []BlockedTask - for rows.Next() { - task, attempt, err := scanTaskAndAttempt(rows) - if err != nil { - return nil, err - } - question, err := selectLatestQuestionMessage(ctx, tx, attempt.ThreadID) - if err != nil { - return nil, err - } - blocked = append(blocked, BlockedTask{ - Task: task, - Attempt: attempt, - Question: question, - }) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate blocked tasks: %w", err) - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("commit list blocked transaction: %w", err) - } - - return blocked, nil -} - -func (s *OrchStore) AnswerTask(ctx context.Context, input AnswerInput) (AnswerResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return AnswerResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - if strings.TrimSpace(input.TaskID) == "" { - return AnswerResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput) - } - - payloadJSON, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON) - if err != nil { - return AnswerResult{}, err - } - if strings.TrimSpace(input.Body) == "" && payloadJSON == "{}" { - return AnswerResult{}, fmt.Errorf("%w: body or payload-json is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return AnswerResult{}, fmt.Errorf("begin answer transaction: %w", err) - } - defer tx.Rollback() - - task, err := selectTask(ctx, tx, input.RunID, input.TaskID) - if err != nil { - return AnswerResult{}, err - } - if task.Status != "blocked" { - return AnswerResult{}, fmt.Errorf("%w: task %s is not blocked", ErrInvalidState, task.TaskID) - } - if task.LatestAttemptNo == 0 { - return AnswerResult{}, fmt.Errorf("%w: task %s has no active attempt", ErrInvalidState, task.TaskID) - } - - attempt, err := selectAttempt(ctx, tx, input.RunID, input.TaskID, task.LatestAttemptNo) - if err != nil { - return AnswerResult{}, err - } - thread, err := selectThread(ctx, tx, attempt.ThreadID) - if err != nil { - return AnswerResult{}, err - } - if isTerminalStatus(thread.Status) { - return AnswerResult{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, thread.ThreadID) - } - - message := Message{ - MessageID: newID("msg"), - ThreadID: thread.ThreadID, - FromAgent: "orch", - ToAgent: attempt.AssignedTo, - Kind: "answer", - Summary: summarizeAnswer(input.Body), - Body: input.Body, - PayloadJSON: json.RawMessage(payloadJSON), - CreatedAt: now, - } - if err := insertMessage(ctx, tx, message); err != nil { - return AnswerResult{}, err - } - if err := updateThreadState(ctx, tx, thread.ThreadID, thread.Status, thread.AssignedTo, message.MessageID, now); err != nil { - return AnswerResult{}, err - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: thread.RunID, - TaskID: thread.TaskID, - ThreadID: thread.ThreadID, - Source: "inbox", - EventType: "thread_reply", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: payloadJSON, - CreatedAt: now, - }); err != nil { - return AnswerResult{}, err - } - if err := insertEvent(ctx, tx, eventInput{ - RunID: task.RunID, - TaskID: task.TaskID, - ThreadID: thread.ThreadID, - Source: "orch", - EventType: "task_answered", - MessageID: message.MessageID, - Summary: message.Summary, - PayloadJSON: payloadJSON, - CreatedAt: now, - }); err != nil { - return AnswerResult{}, err - } - - _, err = tx.ExecContext( - ctx, - `UPDATE tasks - SET updated_at = ? - WHERE run_id = ? AND task_id = ?`, - formatTime(now), - task.RunID, - task.TaskID, - ) - if err != nil { - return AnswerResult{}, fmt.Errorf("touch answered task: %w", err) - } - _, err = tx.ExecContext( - ctx, - `UPDATE task_attempts - SET updated_at = ? - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - formatTime(now), - attempt.RunID, - attempt.TaskID, - attempt.AttemptNo, - ) - if err != nil { - return AnswerResult{}, fmt.Errorf("touch answered attempt: %w", err) - } - if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil { - return AnswerResult{}, err - } - - task.UpdatedAt = now - attempt.UpdatedAt = now - thread.LatestMessageID = message.MessageID - thread.UpdatedAt = now - - if err := tx.Commit(); err != nil { - return AnswerResult{}, fmt.Errorf("commit answer transaction: %w", err) - } - - return AnswerResult{ - Task: task, - Attempt: attempt, - Thread: thread, - Message: message, - }, nil -} - -func (s *OrchStore) GetRunOverview(ctx context.Context, runID string) (RunOverview, error) { - if strings.TrimSpace(runID) == "" { - return RunOverview{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - now := nowUTC() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return RunOverview{}, fmt.Errorf("begin run overview transaction: %w", err) - } - defer tx.Rollback() - - if _, err := selectRun(ctx, tx, runID); err != nil { - return RunOverview{}, err - } - if err := refreshReadyStates(ctx, tx, runID, now); err != nil { - return RunOverview{}, err - } - if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil { - return RunOverview{}, err - } - - run, err := selectRun(ctx, tx, runID) - if err != nil { - return RunOverview{}, err - } - taskCounts, err := collectTaskCounts(ctx, tx, runID) - if err != nil { - return RunOverview{}, err - } - tasks, err := listTasksForRun(ctx, tx, runID) - if err != nil { - return RunOverview{}, err - } - - if err := tx.Commit(); err != nil { - return RunOverview{}, fmt.Errorf("commit run overview transaction: %w", err) - } - - return RunOverview{ - Run: run, - TaskCounts: taskCounts, - Tasks: tasks, - }, nil -} - -func (s *OrchStore) WaitForEvents(ctx context.Context, input WaitInput) (WaitResult, error) { - if strings.TrimSpace(input.RunID) == "" { - return WaitResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) - } - - eventTypes := normalizeWaitEventTypes(input.EventTypes) - if _, err := s.GetRun(ctx, input.RunID); err != nil { - return WaitResult{}, err - } - - cursor := input.AfterEventID - waitCtx := ctx - cancel := func() {} - if input.Timeout > 0 { - waitCtx, cancel = context.WithTimeout(ctx, input.Timeout) - } - defer cancel() - - for { - events, nextEventID, found, err := s.findRunEventsAfter(waitCtx, input.RunID, cursor, eventTypes) - if err != nil { - if isDeadlineExceeded(waitCtx) { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - return WaitResult{}, err - } - if found { - return WaitResult{ - Woke: true, - NextEventID: nextEventID, - Events: events, - }, nil - } - - if _, err := s.ReconcileRun(waitCtx, input.RunID); err != nil { - if isSQLiteBusyError(err) { - ok, waitErr := waitForNextPoll(waitCtx, 25*time.Millisecond) - if waitErr != nil { - if errors.Is(waitErr, context.DeadlineExceeded) { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - return WaitResult{}, waitErr - } - if !ok { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - continue - } - if isDeadlineExceeded(waitCtx) { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - return WaitResult{}, err - } - - events, nextEventID, found, err = s.findRunEventsAfter(waitCtx, input.RunID, cursor, eventTypes) - if err != nil { - if isDeadlineExceeded(waitCtx) { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - return WaitResult{}, err - } - if found { - return WaitResult{ - Woke: true, - NextEventID: nextEventID, - Events: events, - }, nil - } - - ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - return WaitResult{}, err - } - if !ok { - return WaitResult{Woke: false, NextEventID: cursor}, nil - } - } -} - -func listTasksForRun(ctx context.Context, db queryRowsContexter, runID string) ([]Task, error) { - rows, err := db.QueryContext( - ctx, - `SELECT - run_id, task_id, title, summary, status, default_to, priority, - acceptance_json, latest_attempt_no, created_at, updated_at - FROM tasks - WHERE run_id = ? - ORDER BY created_at ASC`, - runID, - ) - if err != nil { - return nil, fmt.Errorf("query tasks for run: %w", err) - } - defer rows.Close() - - var tasks []Task - for rows.Next() { - task, err := scanTask(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, task) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate tasks for run: %w", err) - } - return tasks, nil -} - -func (s *OrchStore) findRunEventsAfter(ctx context.Context, runID string, afterEventID int64, eventTypes []string) ([]RunEvent, int64, bool, error) { - args := []any{runID, afterEventID} - query := `SELECT - event_id, event_type, run_id, task_id, thread_id, summary, payload_json, created_at - FROM events - WHERE run_id = ? - AND event_id > ?` - if len(eventTypes) > 0 { - query += " AND event_type IN (" + placeholders(len(eventTypes)) + ")" - for _, eventType := range eventTypes { - args = append(args, eventType) - } - } - query += " ORDER BY event_id ASC LIMIT 1" - - row := s.db.QueryRowContext(ctx, query, args...) - - var ( - event RunEvent - threadID sql.NullString - payload string - createdAt string - ) - err := row.Scan( - &event.EventID, - &event.Type, - &event.RunID, - &event.TaskID, - &threadID, - &event.Summary, - &payload, - &createdAt, - ) - if errors.Is(err, sql.ErrNoRows) { - return nil, 0, false, nil - } - if err != nil { - return nil, 0, false, fmt.Errorf("query run events after %d: %w", afterEventID, err) - } - - if threadID.Valid { - event.ThreadID = threadID.String - } - event.Payload = json.RawMessage(payload) - event.CreatedAt = parseTime(createdAt) - - return []RunEvent{event}, event.EventID, true, nil -} - -type queryRowsContexter interface { - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) -} - -func scanRun(scanner threadScanner) (Run, error) { - var ( - run Run - createdAt, updated string - ) - - if err := scanner.Scan( - &run.RunID, - &run.Goal, - &run.Summary, - &run.Status, - &createdAt, - &updated, - ); err != nil { - return Run{}, fmt.Errorf("scan run: %w", err) - } - - run.CreatedAt = parseTime(createdAt) - run.UpdatedAt = parseTime(updated) - return run, nil -} - -func scanTask(scanner threadScanner) (Task, error) { - var ( - task Task - defaultTo sql.NullString - latestAttempt sql.NullInt64 - acceptanceJSON string - createdAt, updatedAt string - ) - - if err := scanner.Scan( - &task.RunID, - &task.TaskID, - &task.Title, - &task.Summary, - &task.Status, - &defaultTo, - &task.Priority, - &acceptanceJSON, - &latestAttempt, - &createdAt, - &updatedAt, - ); err != nil { - return Task{}, fmt.Errorf("scan task: %w", err) - } - - task.DefaultTo = defaultTo.String - task.AcceptanceJSON = json.RawMessage(acceptanceJSON) - if latestAttempt.Valid { - task.LatestAttemptNo = int(latestAttempt.Int64) - } - task.CreatedAt = parseTime(createdAt) - task.UpdatedAt = parseTime(updatedAt) - return task, nil -} - -func scanAttempt(scanner threadScanner) (TaskAttempt, error) { - var ( - attempt TaskAttempt - baseRef sql.NullString - baseCommit sql.NullString - branchName sql.NullString - worktreePath sql.NullString - workspaceStatus sql.NullString - resultCommit sql.NullString - createdAt, updated string - ) - - if err := scanner.Scan( - &attempt.RunID, - &attempt.TaskID, - &attempt.AttemptNo, - &attempt.AssignedTo, - &attempt.ThreadID, - &baseRef, - &baseCommit, - &branchName, - &worktreePath, - &workspaceStatus, - &resultCommit, - &attempt.Status, - &createdAt, - &updated, - ); err != nil { - return TaskAttempt{}, fmt.Errorf("scan attempt: %w", err) - } - - attempt.BaseRef = baseRef.String - attempt.BaseCommit = baseCommit.String - attempt.BranchName = branchName.String - attempt.WorktreePath = worktreePath.String - attempt.WorkspaceStatus = workspaceStatus.String - attempt.ResultCommit = resultCommit.String - attempt.CreatedAt = parseTime(createdAt) - attempt.UpdatedAt = parseTime(updated) - return attempt, nil -} - -func scanTaskAndAttempt(scanner threadScanner) (Task, TaskAttempt, error) { - var ( - task Task - taskDefaultTo sql.NullString - taskLatestAttempt sql.NullInt64 - taskAcceptanceJSON string - taskCreatedAt string - taskUpdatedAt string - attempt TaskAttempt - attemptBaseRef sql.NullString - attemptBaseCommit sql.NullString - attemptBranchName sql.NullString - attemptWorktreePath sql.NullString - attemptWorkspaceState sql.NullString - attemptResultCommit sql.NullString - attemptCreatedAt string - attemptUpdatedAt string - ) - - if err := scanner.Scan( - &task.RunID, - &task.TaskID, - &task.Title, - &task.Summary, - &task.Status, - &taskDefaultTo, - &task.Priority, - &taskAcceptanceJSON, - &taskLatestAttempt, - &taskCreatedAt, - &taskUpdatedAt, - &attempt.RunID, - &attempt.TaskID, - &attempt.AttemptNo, - &attempt.AssignedTo, - &attempt.ThreadID, - &attemptBaseRef, - &attemptBaseCommit, - &attemptBranchName, - &attemptWorktreePath, - &attemptWorkspaceState, - &attemptResultCommit, - &attempt.Status, - &attemptCreatedAt, - &attemptUpdatedAt, - ); err != nil { - return Task{}, TaskAttempt{}, fmt.Errorf("scan task and attempt: %w", err) - } - - task.DefaultTo = taskDefaultTo.String - task.AcceptanceJSON = json.RawMessage(taskAcceptanceJSON) - if taskLatestAttempt.Valid { - task.LatestAttemptNo = int(taskLatestAttempt.Int64) - } - task.CreatedAt = parseTime(taskCreatedAt) - task.UpdatedAt = parseTime(taskUpdatedAt) - - attempt.BaseRef = attemptBaseRef.String - attempt.BaseCommit = attemptBaseCommit.String - attempt.BranchName = attemptBranchName.String - attempt.WorktreePath = attemptWorktreePath.String - attempt.WorkspaceStatus = attemptWorkspaceState.String - attempt.ResultCommit = attemptResultCommit.String - attempt.CreatedAt = parseTime(attemptCreatedAt) - attempt.UpdatedAt = parseTime(attemptUpdatedAt) - - return task, attempt, nil -} - -func selectRun(ctx context.Context, db queryRower, runID string) (Run, error) { - row := db.QueryRowContext( - ctx, - `SELECT run_id, goal, summary, status, created_at, updated_at - FROM runs - WHERE run_id = ?`, - runID, - ) - run, err := scanRun(row) - if errors.Is(err, sql.ErrNoRows) { - return Run{}, fmt.Errorf("%w: %s", ErrRunNotFound, runID) - } - return run, err -} - -func selectTask(ctx context.Context, db queryRower, runID, taskID string) (Task, error) { - row := db.QueryRowContext( - ctx, - `SELECT - run_id, task_id, title, summary, status, default_to, priority, - acceptance_json, latest_attempt_no, created_at, updated_at - FROM tasks - WHERE run_id = ? AND task_id = ?`, - runID, - taskID, - ) - task, err := scanTask(row) - if errors.Is(err, sql.ErrNoRows) { - return Task{}, fmt.Errorf("%w: %s/%s", ErrTaskNotFound, runID, taskID) - } - return task, err -} - -func selectAttempt(ctx context.Context, db queryRower, runID, taskID string, attemptNo int) (TaskAttempt, error) { - row := db.QueryRowContext( - ctx, - `SELECT - run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit, - branch_name, worktree_path, workspace_status, result_commit, status, - created_at, updated_at - FROM task_attempts - WHERE run_id = ? AND task_id = ? AND attempt_no = ?`, - runID, - taskID, - attemptNo, - ) - attempt, err := scanAttempt(row) - if errors.Is(err, sql.ErrNoRows) { - return TaskAttempt{}, fmt.Errorf("%w: attempt %s/%s/%d not found", ErrInvalidState, runID, taskID, attemptNo) - } - return attempt, err -} - -func selectLatestQuestionMessage(ctx context.Context, db queryRowsAndRower, threadID string) (Message, error) { - row := db.QueryRowContext( - ctx, - `SELECT - message_id, thread_id, from_agent, to_agent, kind, summary, body, - payload_json, created_at - FROM messages - WHERE thread_id = ? AND kind = 'question' - ORDER BY created_at DESC - LIMIT 1`, - threadID, - ) - message, err := scanMessage(row) - if errors.Is(err, sql.ErrNoRows) { - return Message{}, fmt.Errorf("%w: blocked thread %s has no question message", ErrInvalidState, threadID) - } - if err != nil { - return Message{}, err - } - artifactsByMessageID, err := loadArtifactsForMessageIDsFromQueryer(ctx, db, []string{message.MessageID}) - if err != nil { - return Message{}, err - } - message.Artifacts = artifactsByMessageID[message.MessageID] - return message, nil -} - -type queryRowsAndRower interface { - queryRower - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) -} - -func loadArtifactsForMessageIDsFromQueryer(ctx context.Context, db queryRowsContexter, messageIDs []string) (map[string][]Artifact, error) { - result := make(map[string][]Artifact) - if len(messageIDs) == 0 { - return result, nil - } - - args := make([]any, 0, len(messageIDs)) - for _, messageID := range messageIDs { - args = append(args, messageID) - } - - rows, err := db.QueryContext( - ctx, - `SELECT - artifact_id, message_id, path, kind, metadata_json, created_at - FROM artifacts - WHERE message_id IN (`+placeholders(len(messageIDs))+`) - ORDER BY created_at ASC`, - args..., - ) - if err != nil { - return nil, fmt.Errorf("query artifacts: %w", err) - } - defer rows.Close() - - for rows.Next() { - artifact, err := scanArtifact(rows) - if err != nil { - return nil, err - } - result[artifact.MessageID] = append(result[artifact.MessageID], artifact) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate artifacts: %w", err) - } - - return result, nil -} - -func refreshReadyStates(ctx context.Context, tx *sql.Tx, runID string, now time.Time) error { - rows, err := tx.QueryContext( - ctx, - `SELECT task_id, status, title - FROM tasks - WHERE run_id = ? - AND status IN ('planned', 'ready')`, - runID, - ) - if err != nil { - return fmt.Errorf("query tasks for readiness refresh: %w", err) - } - defer rows.Close() - - type readinessRow struct { - taskID string - status string - title string - } - - var tasks []readinessRow - for rows.Next() { - var row readinessRow - if err := rows.Scan(&row.taskID, &row.status, &row.title); err != nil { - return fmt.Errorf("scan readiness refresh row: %w", err) - } - tasks = append(tasks, row) - } - if err := rows.Err(); err != nil { - return fmt.Errorf("iterate readiness refresh rows: %w", err) - } - - for _, task := range tasks { - ready, err := dependenciesSatisfied(ctx, tx, runID, task.taskID) - if err != nil { - return err - } - - desired := "planned" - if ready { - desired = "ready" - } - if desired == task.status { - continue - } - - _, err = tx.ExecContext( - ctx, - `UPDATE tasks - SET status = ?, updated_at = ? - WHERE run_id = ? AND task_id = ?`, - desired, - formatTime(now), - runID, - task.taskID, - ) - if err != nil { - return fmt.Errorf("update task readiness: %w", err) - } - - if desired == "ready" { - if err := insertEvent(ctx, tx, eventInput{ - RunID: runID, - TaskID: task.taskID, - Source: "orch", - EventType: "task_ready", - Summary: defaultString(task.title, task.taskID), - PayloadJSON: marshalJSON(map[string]any{"task_id": task.taskID}), - CreatedAt: now, - }); err != nil { - return err - } - } - } - - return nil -} - -func dependenciesSatisfied(ctx context.Context, tx *sql.Tx, runID, taskID string) (bool, error) { - var pendingCount int - err := tx.QueryRowContext( - ctx, - `SELECT COUNT(*) - FROM task_dependencies d - JOIN tasks dep - ON dep.run_id = d.run_id - AND dep.task_id = d.depends_on_task_id - WHERE d.run_id = ? - AND d.task_id = ? - AND dep.status <> 'done'`, - runID, - taskID, - ).Scan(&pendingCount) - if err != nil { - return false, fmt.Errorf("query dependency readiness: %w", err) - } - return pendingCount == 0, nil -} - -func updateRunAggregateStatus(ctx context.Context, tx *sql.Tx, runID string, now time.Time) error { - counts, err := collectTaskCounts(ctx, tx, runID) - if err != nil { - return err - } - nextStatus := deriveRunStatus(counts) - - _, err = tx.ExecContext( - ctx, - `UPDATE runs - SET status = ?, updated_at = ? - WHERE run_id = ?`, - nextStatus, - formatTime(now), - runID, - ) - if err != nil { - return fmt.Errorf("update run aggregate status: %w", err) - } - return nil -} - -func collectTaskCounts(ctx context.Context, db queryRowsContexter, runID string) (map[string]int, error) { - rows, err := db.QueryContext( - ctx, - `SELECT status, COUNT(*) - FROM tasks - WHERE run_id = ? - GROUP BY status`, - runID, - ) - if err != nil { - return nil, fmt.Errorf("query task counts: %w", err) - } - defer rows.Close() - - counts := make(map[string]int) - for rows.Next() { - var ( - status string - count int - ) - if err := rows.Scan(&status, &count); err != nil { - return nil, fmt.Errorf("scan task count: %w", err) - } - counts[status] = count - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate task counts: %w", err) - } - - return counts, nil -} - -func deriveRunStatus(counts map[string]int) string { - total := 0 - for _, count := range counts { - total += count - } - if total == 0 { - return "active" - } - if counts["blocked"] > 0 { - return "blocked" - } - if counts["failed"] > 0 { - return "failed" - } - if counts["running"] > 0 || counts["dispatched"] > 0 { - return "running" - } - if counts["ready"] > 0 { - return "ready" - } - if counts["planned"] > 0 { - return "planned" - } - if counts["done"] > 0 { - return "done" - } - if counts["cancelled"] == total { - return "cancelled" - } - return "active" -} - -func reconcileTaskStatus(threadStatus string) string { - switch threadStatus { - case "pending": - return "dispatched" - case "claimed", "in_progress": - return "running" - case "blocked": - return "blocked" - case "done": - return "done" - case "failed": - return "failed" - case "cancelled": - return "cancelled" - default: - return "" - } -} - -func normalizePriority(priority string) (string, error) { - priority = defaultString(strings.TrimSpace(priority), "normal") - switch priority { - case "low", "normal", "high": - return priority, nil - default: - return "", fmt.Errorf("%w: priority must be one of low, normal, high", ErrInvalidInput) - } -} - -func normalizeWaitEventTypes(eventTypes []string) []string { - if len(eventTypes) == 0 { - return []string{"task_ready", "task_blocked", "task_done", "task_failed"} - } - - normalized := make([]string, 0, len(eventTypes)) - seen := make(map[string]struct{}, len(eventTypes)) - for _, eventType := range eventTypes { - eventType = strings.TrimSpace(eventType) - if eventType == "" { - continue - } - if _, ok := seen[eventType]; ok { - continue - } - seen[eventType] = struct{}{} - normalized = append(normalized, eventType) - } - if len(normalized) == 0 { - return []string{"task_ready", "task_blocked", "task_done", "task_failed"} - } - return normalized -} - -func validateAndNormalizeJSONDefault(fieldName, value, defaultValue string) (string, error) { - normalized := strings.TrimSpace(value) - if normalized == "" { - normalized = defaultValue - } - if !json.Valid([]byte(normalized)) { - return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName) - } - - var compact bytes.Buffer - if err := json.Compact(&compact, []byte(normalized)); err != nil { - return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName) - } - return compact.String(), nil -} - -func buildDispatchPayload(task Task, attemptNo int, workspace DispatchWorkspace) string { - payload := map[string]any{ - "run_id": task.RunID, - "task_id": task.TaskID, - "attempt_no": attemptNo, - "title": task.Title, - "summary": task.Summary, - "priority": task.Priority, - } - - if len(task.AcceptanceJSON) > 0 { - var acceptance any - if err := json.Unmarshal(task.AcceptanceJSON, &acceptance); err == nil { - payload["acceptance"] = acceptance - } - } - if strings.TrimSpace(workspace.BaseRef) != "" { - payload["base_ref"] = strings.TrimSpace(workspace.BaseRef) - } - if strings.TrimSpace(workspace.BaseCommit) != "" { - payload["base_commit"] = strings.TrimSpace(workspace.BaseCommit) - } - if strings.TrimSpace(workspace.BranchName) != "" { - payload["branch_name"] = strings.TrimSpace(workspace.BranchName) - } - if strings.TrimSpace(workspace.WorktreePath) != "" { - payload["worktree_path"] = strings.TrimSpace(workspace.WorktreePath) - } - if strings.TrimSpace(workspace.WorkspaceStatus) != "" { - payload["workspace_status"] = strings.TrimSpace(workspace.WorkspaceStatus) - } - - return marshalJSON(payload) -} - -func marshalJSON(v any) string { - data, err := json.Marshal(v) - if err != nil { - return "{}" - } - return string(data) -} - -func nullIfEmpty(value string) any { - if strings.TrimSpace(value) == "" { - return nil - } - return value -} - -func summarizeAnswer(body string) string { - body = strings.TrimSpace(body) - if body == "" { - return "task answer" - } - line := body - if idx := strings.IndexByte(line, '\n'); idx >= 0 { - line = line[:idx] - } - line = strings.TrimSpace(line) - if line == "" { - return "task answer" - } - return line -} - -func reconcileWorkspaceStatus(threadStatus string) string { - switch threadStatus { - case "pending": - return "created" - case "claimed", "in_progress", "blocked": - return "active" - case "done", "failed": - return "completed" - case "cancelled": - return "abandoned" - default: - return "" - } -} - -func isUniqueConstraintError(err error) bool { - return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") -} diff --git a/packages/inbox-runtime/cli/inbox/execute.go b/packages/inbox-runtime/cli/inbox/execute.go new file mode 100644 index 0000000..c8a1642 --- /dev/null +++ b/packages/inbox-runtime/cli/inbox/execute.go @@ -0,0 +1,11 @@ +package inbox + +import ( + "io" + + internalinbox "ai-workflow-skill/packages/inbox-runtime/internal/cli/inbox" +) + +func Execute(args []string, stdout, stderr io.Writer) int { + return internalinbox.Execute(args, stdout, stderr) +} diff --git a/packages/inbox-runtime/cmd/inbox/main.go b/packages/inbox-runtime/cmd/inbox/main.go index e4526eb..2f0a3e6 100644 --- a/packages/inbox-runtime/cmd/inbox/main.go +++ b/packages/inbox-runtime/cmd/inbox/main.go @@ -3,7 +3,7 @@ package main import ( "os" - inboxcli "ai-workflow-skill/packages/inbox-runtime/internal/cli/inbox" + inboxcli "ai-workflow-skill/packages/inbox-runtime/cli/inbox" ) func main() { diff --git a/packages/orch-runtime/cli/orch/execute.go b/packages/orch-runtime/cli/orch/execute.go new file mode 100644 index 0000000..0a69c7f --- /dev/null +++ b/packages/orch-runtime/cli/orch/execute.go @@ -0,0 +1,11 @@ +package orch + +import ( + "io" + + internalorch "ai-workflow-skill/packages/orch-runtime/internal/cli/orch" +) + +func Execute(args []string, stdout, stderr io.Writer) int { + return internalorch.Execute(args, stdout, stderr) +} diff --git a/packages/orch-runtime/cmd/orch/main.go b/packages/orch-runtime/cmd/orch/main.go index 34e5c83..a294cbd 100644 --- a/packages/orch-runtime/cmd/orch/main.go +++ b/packages/orch-runtime/cmd/orch/main.go @@ -3,7 +3,7 @@ package main import ( "os" - orchcli "ai-workflow-skill/packages/orch-runtime/internal/cli/orch" + orchcli "ai-workflow-skill/packages/orch-runtime/cli/orch" ) func main() { diff --git a/packages/orchd-runtime/cmd/orchd/main.go b/packages/orchd-runtime/cmd/orchd/main.go index d07af22..430e0c8 100644 --- a/packages/orchd-runtime/cmd/orchd/main.go +++ b/packages/orchd-runtime/cmd/orchd/main.go @@ -1,66 +1,11 @@ package main import ( - "context" - "errors" - "flag" - "log" - "net/http" "os" - "os/signal" - "syscall" - "time" - "ai-workflow-skill/packages/orchd-runtime/internal/app" - "ai-workflow-skill/packages/coord-core/db" - "ai-workflow-skill/packages/orchd-runtime/internal/httpapi" + "ai-workflow-skill/packages/orchd-runtime/server" ) func main() { - var ( - dbPath string - listen string - shutdown time.Duration - ) - - flag.StringVar(&dbPath, "db", ".agents/coord.db", "SQLite database path") - flag.StringVar(&listen, "listen", ":8080", "HTTP listen address") - flag.DurationVar(&shutdown, "shutdown-timeout", 5*time.Second, "Graceful shutdown timeout") - flag.Parse() - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - sqlDB, err := db.Open(ctx, dbPath) - if err != nil { - log.Fatalf("open database: %v", err) - } - defer sqlDB.Close() - - if err := db.ApplyMigrations(ctx, sqlDB); err != nil { - log.Fatalf("apply migrations: %v", err) - } - - webApp := app.NewWebService(sqlDB) - server := &http.Server{ - Addr: listen, - Handler: httpapi.NewRouter(webApp), - ReadHeaderTimeout: 5 * time.Second, - } - - go func() { - <-ctx.Done() - - shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdown) - defer cancel() - - if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Printf("http shutdown: %v", err) - } - }() - - log.Printf("orchd listening on %s", listen) - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("serve http api: %v", err) - } + os.Exit(server.Execute(os.Args[1:], os.Stderr)) } diff --git a/packages/orchd-runtime/server/execute.go b/packages/orchd-runtime/server/execute.go new file mode 100644 index 0000000..c791c51 --- /dev/null +++ b/packages/orchd-runtime/server/execute.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "ai-workflow-skill/packages/coord-core/db" + "ai-workflow-skill/packages/orchd-runtime/internal/app" + "ai-workflow-skill/packages/orchd-runtime/internal/httpapi" +) + +func Execute(args []string, stderr io.Writer) int { + fs := flag.NewFlagSet("orchd", flag.ContinueOnError) + fs.SetOutput(stderr) + + var ( + dbPath string + listen string + shutdown time.Duration + ) + + fs.StringVar(&dbPath, "db", ".agents/coord.db", "SQLite database path") + fs.StringVar(&listen, "listen", ":8080", "HTTP listen address") + fs.DurationVar(&shutdown, "shutdown-timeout", 5*time.Second, "Graceful shutdown timeout") + + if err := fs.Parse(args); err != nil { + return 2 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + sqlDB, err := db.Open(ctx, dbPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "open database: %v\n", err) + return 1 + } + defer sqlDB.Close() + + if err := db.ApplyMigrations(ctx, sqlDB); err != nil { + _, _ = fmt.Fprintf(stderr, "apply migrations: %v\n", err) + return 1 + } + + webApp := app.NewWebService(sqlDB) + server := &http.Server{ + Addr: listen, + Handler: httpapi.NewRouter(webApp), + ReadHeaderTimeout: 5 * time.Second, + } + + logger := log.New(stderr, "", log.LstdFlags) + + go func() { + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdown) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Printf("http shutdown: %v", err) + } + }() + + logger.Printf("orchd listening on %s", listen) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Printf("serve http api: %v", err) + return 1 + } + + return 0 +}