refactor(monorepo): remove legacy root runtime ownership
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
inboxcli "ai-workflow-skill/internal/cli/inbox"
|
inboxcli "ai-workflow-skill/packages/inbox-runtime/cli/inbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
orchcli "ai-workflow-skill/internal/cli/orch"
|
orchcli "ai-workflow-skill/packages/orch-runtime/cli/orch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+2
-57
@@ -1,66 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ai-workflow-skill/internal/app"
|
"ai-workflow-skill/packages/orchd-runtime/server"
|
||||||
"ai-workflow-skill/packages/coord-core/db"
|
|
||||||
"ai-workflow-skill/internal/httpapi"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
os.Exit(server.Execute(os.Args[1:], os.Stderr))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ Current implementation status:
|
|||||||
- `Milestone 7: Council Review` is complete
|
- `Milestone 7: Council Review` is complete
|
||||||
- `Milestone 8: Web Product Phase 1 Skeleton` 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 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
|
### Milestone 1: Go Skeleton
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ Definition of done:
|
|||||||
|
|
||||||
Status:
|
Status:
|
||||||
|
|
||||||
- in progress
|
- completed
|
||||||
|
|
||||||
Completed so far:
|
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 ./...`
|
- `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
|
- `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
|
- `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:
|
Remaining:
|
||||||
|
|
||||||
- remove or reduce the remaining legacy root runtime ownership under `cmd/` and `internal/`
|
- none for the current migration slice
|
||||||
- decide whether the external `../tmp/briefdb` prototype should be archived, documented as superseded, or deleted after the package import is considered stable
|
- 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
|
## Immediate Next Task
|
||||||
|
|
||||||
If a new agent is taking over now, the next concrete step should be:
|
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
|
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. 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
|
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. treat the shared coordination kernel extraction into `packages/coord-core` as complete and move `inbox` plus `orch` into package-owned runtimes next
|
3. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as new skills or runtime-backed bundles are added
|
||||||
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
|
4. optionally archive or delete the external `../tmp/briefdb` prototype after confirming no remaining workflow depends on it
|
||||||
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
|
|
||||||
|
|
||||||
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
|
## Recommended Driver Choices
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- `in_progress`
|
- `completed`
|
||||||
|
|
||||||
## Owner
|
## Owner
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@
|
|||||||
- [x] Phase 3: extract `inbox-runtime` and `orch-runtime`
|
- [x] Phase 3: extract `inbox-runtime` and `orch-runtime`
|
||||||
- [x] Phase 4: extract `orchd-runtime`
|
- [x] Phase 4: extract `orchd-runtime`
|
||||||
- [x] Phase 5: import `repo-memory-runtime` and add `skills/repo-memory`
|
- [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
|
## Files
|
||||||
|
|
||||||
- `docs/roadmaps/active/skill-workspace-monorepo-migration.md`
|
- `docs/roadmaps/archive/skill-workspace-monorepo-migration.md`
|
||||||
- `docs/skill-workspace-monorepo.md`
|
- `docs/skill-workspace-monorepo.md`
|
||||||
- `docs/implementation-roadmap.md`
|
- `docs/implementation-roadmap.md`
|
||||||
- `go.work`
|
- `go.work`
|
||||||
@@ -54,4 +54,12 @@
|
|||||||
|
|
||||||
## Next Step
|
## 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
|
||||||
@@ -3,6 +3,9 @@ module ai-workflow-skill
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
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/go-chi/chi/v5 v5.2.5
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
modernc.org/sqlite v1.40.1
|
modernc.org/sqlite v1.40.1
|
||||||
@@ -22,3 +25,9 @@ require (
|
|||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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 ")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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":"."}}]}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 ")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
// Package store contains higher-level database access helpers.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
inboxcli "ai-workflow-skill/packages/inbox-runtime/internal/cli/inbox"
|
inboxcli "ai-workflow-skill/packages/inbox-runtime/cli/inbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
orchcli "ai-workflow-skill/packages/orch-runtime/internal/cli/orch"
|
orchcli "ai-workflow-skill/packages/orch-runtime/cli/orch"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,66 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ai-workflow-skill/packages/orchd-runtime/internal/app"
|
"ai-workflow-skill/packages/orchd-runtime/server"
|
||||||
"ai-workflow-skill/packages/coord-core/db"
|
|
||||||
"ai-workflow-skill/packages/orchd-runtime/internal/httpapi"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
os.Exit(server.Execute(os.Args[1:], os.Stderr))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user