Implement orch core scheduling
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type dispatchOptions struct {
|
||||
runID string
|
||||
taskID string
|
||||
toAgent string
|
||||
body string
|
||||
bodyFile string
|
||||
baseRef 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 {
|
||||
if opts.workspaceRoot != "" || opts.strictWorktree {
|
||||
return protocol.InvalidInput("worktree dispatch is not implemented yet", nil)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
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.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
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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 ")
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOrchRunDispatchReconcileLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_001",
|
||||
"--goal", "Build blog MVP",
|
||||
"--summary", "Public blog plus admin CRUD",
|
||||
)
|
||||
|
||||
taskOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_001",
|
||||
"--task", "T1",
|
||||
"--title", "Implement retry policy",
|
||||
"--summary", "Add retry policy to HTTP client",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
|
||||
var taskResp map[string]any
|
||||
mustDecodeJSON(t, taskOut, &taskResp)
|
||||
if got := nestedString(t, taskResp, "data", "task", "status"); got != "ready" {
|
||||
t.Fatalf("expected new task to become ready, got %q", got)
|
||||
}
|
||||
|
||||
readyOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"ready",
|
||||
"--run", "run_blog_001",
|
||||
)
|
||||
|
||||
var readyResp map[string]any
|
||||
mustDecodeJSON(t, readyOut, &readyResp)
|
||||
readyTasks := nestedArray(t, readyResp, "data", "tasks")
|
||||
if len(readyTasks) != 1 {
|
||||
t.Fatalf("expected one ready task, got %#v", readyTasks)
|
||||
}
|
||||
readyTask, ok := readyTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
|
||||
}
|
||||
if taskID, _ := readyTask["task_id"].(string); taskID != "T1" {
|
||||
t.Fatalf("expected ready task T1, got %#v", readyTask["task_id"])
|
||||
}
|
||||
|
||||
dispatchOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_001",
|
||||
"--task", "T1",
|
||||
"--body", "Implement retry handling for the HTTP client.",
|
||||
)
|
||||
|
||||
var dispatchResp map[string]any
|
||||
mustDecodeJSON(t, dispatchOut, &dispatchResp)
|
||||
if got := nestedString(t, dispatchResp, "data", "task", "status"); got != "dispatched" {
|
||||
t.Fatalf("expected dispatched task, got %q", got)
|
||||
}
|
||||
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Implementation started",
|
||||
)
|
||||
|
||||
reconcileOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_001",
|
||||
)
|
||||
|
||||
var reconcileResp map[string]any
|
||||
mustDecodeJSON(t, reconcileOut, &reconcileResp)
|
||||
updatedTasks := nestedArray(t, reconcileResp, "data", "updated_tasks")
|
||||
if len(updatedTasks) != 1 {
|
||||
t.Fatalf("expected one updated task after running reconcile, got %#v", updatedTasks)
|
||||
}
|
||||
runningTask, ok := updatedTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected updated task object, got %#v", updatedTasks[0])
|
||||
}
|
||||
if status, _ := runningTask["status"].(string); status != "running" {
|
||||
t.Fatalf("expected running task after reconcile, got %#v", runningTask["status"])
|
||||
}
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadID,
|
||||
"--summary", "Retry policy implemented",
|
||||
"--body", "The HTTP client now retries transient failures.",
|
||||
)
|
||||
|
||||
reconcileDoneOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_001",
|
||||
)
|
||||
|
||||
var reconcileDoneResp map[string]any
|
||||
mustDecodeJSON(t, reconcileDoneOut, &reconcileDoneResp)
|
||||
updatedTasks = nestedArray(t, reconcileDoneResp, "data", "updated_tasks")
|
||||
if len(updatedTasks) != 1 {
|
||||
t.Fatalf("expected one updated task after done reconcile, got %#v", updatedTasks)
|
||||
}
|
||||
doneTask, ok := updatedTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected updated task object, got %#v", updatedTasks[0])
|
||||
}
|
||||
if status, _ := doneTask["status"].(string); status != "done" {
|
||||
t.Fatalf("expected done task after reconcile, got %#v", doneTask["status"])
|
||||
}
|
||||
|
||||
statusOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"status",
|
||||
"--run", "run_blog_001",
|
||||
)
|
||||
|
||||
var statusResp map[string]any
|
||||
mustDecodeJSON(t, statusOut, &statusResp)
|
||||
if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" {
|
||||
t.Fatalf("expected run status done, got %q", got)
|
||||
}
|
||||
tasks := nestedArray(t, statusResp, "data", "tasks")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("expected one task in status response, got %#v", tasks)
|
||||
}
|
||||
task, ok := tasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected task object, got %#v", tasks[0])
|
||||
}
|
||||
if got, _ := task["status"].(string); got != "done" {
|
||||
t.Fatalf("expected status task done, got %#v", task["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchDependencyBlockedAndAnswerFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_002",
|
||||
"--goal", "Build dependency-aware workflow",
|
||||
)
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T1",
|
||||
"--title", "Build backend",
|
||||
"--summary", "Implement backend APIs",
|
||||
"--default-to", "worker-a",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T2",
|
||||
"--title", "Build frontend",
|
||||
"--summary", "Implement frontend flows",
|
||||
"--default-to", "worker-b",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dep", "add",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T2",
|
||||
"--depends-on", "T1",
|
||||
)
|
||||
|
||||
readyOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"ready",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
var readyResp map[string]any
|
||||
mustDecodeJSON(t, readyOut, &readyResp)
|
||||
readyTasks := nestedArray(t, readyResp, "data", "tasks")
|
||||
if len(readyTasks) != 1 {
|
||||
t.Fatalf("expected only dependency-free task ready, got %#v", readyTasks)
|
||||
}
|
||||
readyTask, ok := readyTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
|
||||
}
|
||||
if taskID, _ := readyTask["task_id"].(string); taskID != "T1" {
|
||||
t.Fatalf("expected T1 ready before dependency clears, got %#v", readyTask["task_id"])
|
||||
}
|
||||
|
||||
dispatchBackendOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T1",
|
||||
)
|
||||
|
||||
var dispatchBackendResp map[string]any
|
||||
mustDecodeJSON(t, dispatchBackendOut, &dispatchBackendResp)
|
||||
threadBackend := nestedString(t, dispatchBackendResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadBackend,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-a",
|
||||
"--thread", threadBackend,
|
||||
"--summary", "Backend complete",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
readyAfterDoneOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"ready",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
var readyAfterDoneResp map[string]any
|
||||
mustDecodeJSON(t, readyAfterDoneOut, &readyAfterDoneResp)
|
||||
readyTasks = nestedArray(t, readyAfterDoneResp, "data", "tasks")
|
||||
if len(readyTasks) != 1 {
|
||||
t.Fatalf("expected dependent task to become ready, got %#v", readyTasks)
|
||||
}
|
||||
readyTask, ok = readyTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected ready task object, got %#v", readyTasks[0])
|
||||
}
|
||||
if taskID, _ := readyTask["task_id"].(string); taskID != "T2" {
|
||||
t.Fatalf("expected T2 ready after T1 completion, got %#v", readyTask["task_id"])
|
||||
}
|
||||
|
||||
dispatchFrontendOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T2",
|
||||
)
|
||||
|
||||
var dispatchFrontendResp map[string]any
|
||||
mustDecodeJSON(t, dispatchFrontendOut, &dispatchFrontendResp)
|
||||
threadFrontend := nestedString(t, dispatchFrontendResp, "data", "attempt", "thread_id")
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"claim",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
"--status", "blocked",
|
||||
"--summary", "Need logging decision",
|
||||
"--payload-json", `{"question":"stdout or stderr?"}`,
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
blockedOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"blocked",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
var blockedResp map[string]any
|
||||
mustDecodeJSON(t, blockedOut, &blockedResp)
|
||||
blockedTasks := nestedArray(t, blockedResp, "data", "blocked")
|
||||
if len(blockedTasks) != 1 {
|
||||
t.Fatalf("expected one blocked task, got %#v", blockedTasks)
|
||||
}
|
||||
blockedTask, ok := blockedTasks[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected blocked task object, got %#v", blockedTasks[0])
|
||||
}
|
||||
question, ok := blockedTask["question"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected blocked question object, got %#v", blockedTask["question"])
|
||||
}
|
||||
if kind, _ := question["kind"].(string); kind != "question" {
|
||||
t.Fatalf("expected blocked question kind, got %#v", question["kind"])
|
||||
}
|
||||
|
||||
answerOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"answer",
|
||||
"--run", "run_blog_002",
|
||||
"--task", "T2",
|
||||
"--body", "Use stdout for MVP.",
|
||||
)
|
||||
|
||||
var answerResp map[string]any
|
||||
mustDecodeJSON(t, answerOut, &answerResp)
|
||||
if got := nestedString(t, answerResp, "data", "message", "kind"); got != "answer" {
|
||||
t.Fatalf("expected answer message kind, got %q", got)
|
||||
}
|
||||
|
||||
showOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"show",
|
||||
"--thread", threadFrontend,
|
||||
)
|
||||
|
||||
var showResp map[string]any
|
||||
mustDecodeJSON(t, showOut, &showResp)
|
||||
messages := nestedArray(t, showResp, "data", "messages")
|
||||
if len(messages) < 4 {
|
||||
t.Fatalf("expected answer to append a message, got %#v", messages)
|
||||
}
|
||||
lastMessage, ok := messages[len(messages)-1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected last message object, got %#v", messages[len(messages)-1])
|
||||
}
|
||||
if kind, _ := lastMessage["kind"].(string); kind != "answer" {
|
||||
t.Fatalf("expected latest message to be answer, got %#v", lastMessage["kind"])
|
||||
}
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"update",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
"--status", "in_progress",
|
||||
"--summary", "Decision applied",
|
||||
)
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"done",
|
||||
"--agent", "worker-b",
|
||||
"--thread", threadFrontend,
|
||||
"--summary", "Frontend complete",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"reconcile",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
statusOut := runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"status",
|
||||
"--run", "run_blog_002",
|
||||
)
|
||||
|
||||
var statusResp map[string]any
|
||||
mustDecodeJSON(t, statusOut, &statusResp)
|
||||
if got := nestedString(t, statusResp, "data", "run", "status"); got != "done" {
|
||||
t.Fatalf("expected run status done after both tasks, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrchDispatchRejectsNonReadyTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"run", "init",
|
||||
"--run", "run_blog_003",
|
||||
"--goal", "Validate ready gating",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_003",
|
||||
"--task", "T1",
|
||||
"--title", "Backend",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"task", "add",
|
||||
"--run", "run_blog_003",
|
||||
"--task", "T2",
|
||||
"--title", "Frontend",
|
||||
)
|
||||
runOrchCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dep", "add",
|
||||
"--run", "run_blog_003",
|
||||
"--task", "T2",
|
||||
"--depends-on", "T1",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeOrchCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"dispatch",
|
||||
"--run", "run_blog_003",
|
||||
"--task", "T2",
|
||||
)
|
||||
if exitCode != 30 {
|
||||
t.Fatalf("expected invalid_state exit code 30, got %d\nstdout:\n%s", exitCode, stdout)
|
||||
}
|
||||
assertErrorJSON(t, stdout, "invalid_state")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -13,14 +13,24 @@ func NewRootCmd() *cobra.Command {
|
||||
opts := &rootOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "orch",
|
||||
Short: "Leader-facing scheduler and control plane",
|
||||
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())
|
||||
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(newBlockedCmd(opts))
|
||||
cmd.AddCommand(newAnswerCmd(opts))
|
||||
cmd.AddCommand(newStatusCmd(opts))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+113
-9
@@ -1,20 +1,124 @@
|
||||
package orch
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
func newRunCmd() *cobra.Command {
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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(&cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Stub for future run initialization",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package orch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user