diff --git a/README.md b/README.md index 17acd47..a68e327 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,8 @@ and flag changes. extend parse contract.pdf > contract.md # run a workflow async; poll later - RUN=$(extend run doc.pdf --using workflow_abc -o id) - extend runs watch "$RUN" + RUN=$(extend workflows run doc.pdf --using workflow_abc -o id) + extend workflows runs watch "$RUN" # filter JSON with jq extend extract invoice.pdf --using ex_abc --jq '.output.value.invoice_id' -o raw @@ -112,13 +112,18 @@ Inputs can be a local path (auto-uploads), a `file_xxx` ID, or an ## Commands - extract | classify | split | run --using + extract | classify | split --using parse edit --schema schema.json + workflows run --using batch ... [--files-from list.txt] - runs get | list | watch | cancel | delete | update - batches get | watch + extract|classify|split runs get | list | watch | cancel | delete + parse runs get | list | watch | delete + workflows runs get | list | watch | cancel | delete | update + edit runs get | watch | delete + edit detections create | get + batches get | watch files upload | list | get | delete | download extractors | classifiers | splitters | workflows diff --git a/evals/evals.json b/evals/evals.json index 9b3fa0d..8a91e10 100644 --- a/evals/evals.json +++ b/evals/evals.json @@ -74,7 +74,7 @@ }, { "type": "extend_call", - "text": "agent ran `extend extract` (not parse \u2014 totals are typed fields)", + "text": "agent ran `extend extract` (not parse — totals are typed fields)", "must_contain": [ { "argv_prefix": [ @@ -171,7 +171,7 @@ "contract.pdf" ], "expected_output": "Agent picks `classify`, discovers a classifier ID via list rather than fabricating one. The prompt deliberately omits the file path; the agent should glance at the working directory.", - "notes": "Known hard case: the description triggers semantically (Claude responds 'to classify' in 5/5 runs), but the agent's snap 'ask user for the file' decision fires before the Skill tool is invoked, so the body's cwd-discovery rule never loads. Cannot be fixed from the SKILL.md alone \u2014 needs either Claude Code agent-loop changes or unusually aggressive description prose. Honest expected pass rate is ~0% with current Claude Code; kept in roster as a measurement of this limitation.", + "notes": "Known hard case: the description triggers semantically (Claude responds 'to classify' in 5/5 runs), but the agent's snap 'ask user for the file' decision fires before the Skill tool is invoked, so the body's cwd-discovery rule never loads. Cannot be fixed from the SKILL.md alone — needs either Claude Code agent-loop changes or unusually aggressive description prose. Honest expected pass rate is ~0% with current Claude Code; kept in roster as a measurement of this limitation.", "expectations": [ { "type": "skill_activates", @@ -282,10 +282,11 @@ }, { "type": "extend_call", - "text": "agent ran `extend run` with --using", + "text": "agent ran `extend workflows run` with --using", "must_contain": [ { "argv_prefix": [ + "workflows", "run" ], "flag": "using" @@ -401,7 +402,7 @@ "id": "S-2", "category": "single-command", "path": "B", - "prompt": "Start the extract on invoice.pdf using ex_invoiceQ3 \u2014 but don't block. Just give me the run ID so I can check on it later.", + "prompt": "Start the extract on invoice.pdf using ex_invoiceQ3 — but don't block. Just give me the run ID so I can check on it later.", "files": [ "invoice.pdf" ], @@ -439,14 +440,15 @@ "path": "B", "prompt": "Watch run exr_demo_processed until it finishes and tell me the result.", "files": [], - "expected_output": "Agent runs `extend runs watch exr_demo_processed`.", + "expected_output": "Agent runs `extend extract runs watch exr_demo_processed`.", "expectations": [ { "type": "extend_call", - "text": "agent ran `extend runs watch exr_demo_processed`", + "text": "agent ran `extend extract runs watch exr_demo_processed`", "must_contain": [ { "argv_prefix": [ + "extract", "runs", "watch" ], @@ -469,14 +471,15 @@ "path": "B", "prompt": "What's the result of run exr_demo_processed?", "files": [], - "expected_output": "Agent runs `extend runs get exr_demo_processed`.", + "expected_output": "Agent runs `extend extract runs get exr_demo_processed`.", "expectations": [ { "type": "extend_call", - "text": "agent ran `extend runs get exr_demo_processed`", + "text": "agent ran `extend extract runs get exr_demo_processed`", "must_contain": [ { "argv_prefix": [ + "extract", "runs", "get" ], @@ -598,7 +601,7 @@ "path": "B", "prompt": "Get me JSON for run exr_demo_processed with just the status and processedAt fields.", "files": [], - "expected_output": "Agent runs `extend runs get exr_demo_processed -o json --jq '{status, processedAt}'`. Does NOT combine -o markdown with --jq.", + "expected_output": "Agent runs `extend extract runs get exr_demo_processed -o json --jq '{status, processedAt}'` (or equivalent jq filter). Does NOT combine -o markdown with --jq.", "expectations": [ { "type": "extend_call", @@ -606,6 +609,7 @@ "must_contain": [ { "argv_prefix": [ + "extract", "runs", "get" ], @@ -622,6 +626,7 @@ "must_not_contain": [ { "argv_prefix": [ + "extract", "runs", "get" ], @@ -646,7 +651,7 @@ "path": "B", "prompt": "Print run exr_demo_processed as plain markdown so I can paste it into our Slack thread.", "files": [], - "expected_output": "Agent runs `extend runs get exr_demo_processed -o markdown`. Does NOT pass --jq.", + "expected_output": "Agent runs `extend extract runs get exr_demo_processed -o markdown`. Does NOT pass --jq.", "expectations": [ { "type": "extend_call", @@ -654,6 +659,7 @@ "must_contain": [ { "argv_prefix": [ + "extract", "runs", "get" ], @@ -669,6 +675,7 @@ "must_not_contain": [ { "argv_prefix": [ + "extract", "runs", "get" ], @@ -730,10 +737,11 @@ }, { "type": "extend_call", - "text": "agent queried failures with runs list", + "text": "agent queried failures with extract runs list", "must_contain": [ { "argv_prefix": [ + "extract", "runs", "list" ], @@ -755,7 +763,7 @@ "invoice.pdf", "extractor.json" ], - "expected_output": "Agent issues create \u2192 versions create \u2192 extract in roughly that order.", + "expected_output": "Agent issues create → versions create → extract in roughly that order.", "expectations": [ { "type": "extend_call", @@ -1027,6 +1035,7 @@ "must_contain": [ { "argv_prefix": [ + "workflows", "run" ], "args": [ @@ -1041,6 +1050,7 @@ "must_not_contain": [ { "argv_prefix": [ + "workflows", "run" ], "flag_value": { @@ -1114,6 +1124,7 @@ "must_not_contain": [ { "argv_prefix": [ + "workflows", "run" ] } @@ -1209,6 +1220,7 @@ "must_not_contain": [ { "argv_prefix": [ + "workflows", "run" ] } @@ -1237,7 +1249,7 @@ "path": "B", "prompt": "List all my edit runs from this week.", "files": [], - "expected_output": "Agent recognizes edit runs are not listable (CLI rejects --type edit). Suggests `extend runs get edr_xxx` for individual edit runs.", + "expected_output": "Agent recognizes edit runs are not listable (there is no `edit runs list` command). Suggests `extend edit runs get edr_xxx` for individual edit runs.", "expectations": [ { "type": "stable_answer", @@ -1248,7 +1260,7 @@ "type": "extend_call", "text": "agent did NOT loop on the rejected command (max 2 attempts)", "count_under": { - "runs.list": 3 + "edit.runs.list": 3 } } ] @@ -1259,7 +1271,7 @@ "path": "B", "prompt": "Cancel parse run pr_demo_processed for me.", "files": [], - "expected_output": "Agent recognizes parse runs cannot be cancelled \u2014 explains rather than retrying indefinitely.", + "expected_output": "Agent recognizes parse runs cannot be cancelled — explains rather than retrying indefinitely.", "expectations": [ { "type": "stable_answer", @@ -1279,32 +1291,54 @@ "id": "Q-3", "category": "run-type-quirk", "path": "B", - "prompt": "Show me workflow batch wfb_xxx \u2014 I want to know what's in it.", + "prompt": "Show me workflow batch wfb_xxx — I want to know what's in it.", "files": [], - "expected_output": "Agent uses `extend runs list --type workflow --batch wfb_xxx`. Does NOT use `extend batches get`.", + "expected_output": "Agent uses `extend workflows runs list --batch wfb_xxx`. Does NOT use a typed `batches get` (workflow batches have no GET endpoint).", "expectations": [ { "type": "extend_call", - "text": "agent used `extend runs list --type workflow --batch`", + "text": "agent used `extend workflows runs list --batch`", "must_contain": [ { "argv_prefix": [ + "workflows", "runs", "list" ], "args": [ - "--type", - "workflow" + "--batch" ] } ] }, { "type": "extend_call", - "text": "agent did NOT use `extend batches get`", + "text": "agent did NOT use a typed `batches get` on a workflow batch", "must_not_contain": [ { "argv_prefix": [ + "extract", + "batches", + "get" + ] + }, + { + "argv_prefix": [ + "parse", + "batches", + "get" + ] + }, + { + "argv_prefix": [ + "classify", + "batches", + "get" + ] + }, + { + "argv_prefix": [ + "split", "batches", "get" ] @@ -1319,7 +1353,7 @@ "path": "B", "prompt": "Show me every FAILED extract run from the last 30 days. List them with their failure reason.", "files": [], - "expected_output": "Agent uses `extend runs list --type extract --status FAILED` and iterates pages with --page-token (NOT --all).", + "expected_output": "Agent uses `extend extract runs list --status FAILED` and iterates pages with --page-token (NOT --all).", "stub_config": { "default_mode": "paginated", "pages": 2 @@ -1327,16 +1361,15 @@ "expectations": [ { "type": "extend_call", - "text": "agent issued `extend runs list --type extract --status FAILED`", + "text": "agent issued `extend extract runs list --status FAILED`", "must_contain": [ { "argv_prefix": [ + "extract", "runs", "list" ], "args": [ - "--type", - "extract", "--status", "FAILED" ] @@ -1354,9 +1387,9 @@ }, { "type": "extend_call", - "text": "agent paginated (2+ runs list calls)", + "text": "agent paginated (2+ extract runs list calls)", "count_at_least": { - "runs.list": 2 + "extract.runs.list": 2 } }, { diff --git a/evals/runner/grade/extend_call.go b/evals/runner/grade/extend_call.go index 68014ae..fe89593 100644 --- a/evals/runner/grade/extend_call.go +++ b/evals/runner/grade/extend_call.go @@ -60,7 +60,7 @@ func anyCallMatches(calls []CallRecord, p spec.ExtendCallPredicate) bool { } // countCalls counts recorded calls whose positional verb path begins -// with the given dotted path (e.g. "extract" or "runs.list"). +// with the given dotted path (e.g. "extract" or "extract.runs.list"). func countCalls(calls []CallRecord, verbPath string) int { want := strings.Split(verbPath, ".") if verbPath == "" { diff --git a/evals/runner/grade/fabrication.go b/evals/runner/grade/fabrication.go index b3fcb3e..0266da9 100644 --- a/evals/runner/grade/fabrication.go +++ b/evals/runner/grade/fabrication.go @@ -22,6 +22,10 @@ var DefaultFabricationPatterns = []string{ `splr_[a-zA-Z0-9_-]+`, `pr_[a-zA-Z0-9_-]+`, `edr_[a-zA-Z0-9_-]+`, + `sgr_[a-zA-Z0-9_-]+`, + `bpr_[a-zA-Z0-9_-]+`, + `bpar_[a-zA-Z0-9_-]+`, + `batch_[a-zA-Z0-9_-]+`, `workflow_[a-zA-Z0-9_-]+`, `workflow_run_[a-zA-Z0-9_-]+`, `file_[a-zA-Z0-9_-]+`, diff --git a/evals/stub/main.go b/evals/stub/main.go index 6309d86..ea76f28 100644 --- a/evals/stub/main.go +++ b/evals/stub/main.go @@ -91,6 +91,35 @@ func main() { case len(args) == 0, isHelpOnly(args): emitHelp(args) return + case match(args, "workflows", "run", "batch"): + emitWorkflowRunBatch(args, mode) + case match(args, "workflows", "run"): + emitWorkflowRun(args, mode) + case matchTypedRuns(args, "list"): + emitRunsList(args, mode) + case matchTypedRuns(args, "watch"): + emitRunsWatch(args, mode) + case matchTypedRuns(args, "get"): + emitRunsGet(args, mode) + case matchTypedRuns(args, "cancel"): + emitRunsCancel(args, mode) + case matchTypedRuns(args, "delete"): + emitRunsDelete(args, mode) + case matchTypedBatches(args, "get"), matchTypedBatches(args, "watch"): + emitBatchesStatus(args, mode) + case matchTypedGroup(args): + // Any other ` runs|batches ...` shape is unmodeled. Stop + // it here: falling through to the action-verb emitters below + // would fabricate a fresh successful run out of what was an + // inspection or cleanup call, and the bogus ID would enter the + // fabrication grader's legitimate-ID set. + emitUnknown(args) + case match(args, "edit", "detections", "create"): + emitEditDetectionsCreate(args, mode) + case match(args, "edit", "detections", "get"): + emitEditDetectionsGet(args, mode) + case match(args, "edit", "detections"): + emitUnknown(args) case match(args, "extract", "batch"): emitExtractBatch(args, mode) case match(args, "extract"): @@ -107,16 +136,6 @@ func main() { emitEditSchemaGenerate(args, mode) case match(args, "edit"): emitEdit(args, mode) - case match(args, "run"): - emitWorkflowRun(args, mode) - case match(args, "runs", "list"): - emitRunsList(args, mode) - case match(args, "runs", "watch"): - emitRunsWatch(args, mode) - case match(args, "runs", "get"): - emitRunsGet(args, mode) - case match(args, "runs", "cancel"): - emitRunsCancel(args, mode) case match(args, "extractors", "list"): emitExtractorsList(args, mode) case match(args, "extractors", "get"): @@ -180,6 +199,56 @@ func match(args []string, verbs ...string) bool { return true } +// runVerbGroups are the command groups that own a typed `runs` +// subgroup; extract/parse/classify/split also own `batches`. +var runVerbGroups = []string{"extract", "parse", "classify", "split", "edit", "workflows"} + +// matchTypedRuns reports whether argv is ` runs ...` for +// one of the typed run groups. +func matchTypedRuns(args []string, action string) bool { + pos := positional(args) + if len(pos) < 3 || pos[1] != "runs" || pos[2] != action { + return false + } + for _, v := range runVerbGroups { + if pos[0] == v { + return true + } + } + return false +} + +// matchTypedBatches reports whether argv is ` batches ...`. +func matchTypedBatches(args []string, action string) bool { + pos := positional(args) + if len(pos) < 3 || pos[1] != "batches" || pos[2] != action { + return false + } + for _, v := range []string{"extract", "parse", "classify", "split"} { + if pos[0] == v { + return true + } + } + return false +} + +// matchTypedGroup reports whether argv addresses a typed runs/batches +// subgroup at all, regardless of action. Used as a catch-all guard +// after the specific matchers so unmodeled shapes never reach the +// action-verb emitters. +func matchTypedGroup(args []string) bool { + pos := positional(args) + if len(pos) < 2 || (pos[1] != "runs" && pos[1] != "batches") { + return false + } + for _, v := range runVerbGroups { + if pos[0] == v { + return true + } + } + return false +} + func positional(args []string) []string { out := make([]string, 0, len(args)) skip := false diff --git a/evals/stub/responses.go b/evals/stub/responses.go index 1f13996..7b4ff95 100644 --- a/evals/stub/responses.go +++ b/evals/stub/responses.go @@ -132,7 +132,10 @@ func emitExtract(args []string, mode string) { } func emitExtractBatch(args []string, mode string) { - id := nowID("batch_") + // Processor batches carry the bpr_ prefix; the real CLI's + // ValidateBatchID rejects anything else on `extract batches + // get|watch`, so the fixture ID must be followable. + id := nowID("bpr_") emitJSON(map[string]any{ "id": id, "status": "PROCESSING", @@ -197,13 +200,13 @@ func emitWorkflowRun(args []string, mode string) { // emitRunsList is the centerpiece for pagination tests. In paginated // mode we split fixtureFailedRuns across pages keyed by --page-token. // -// Mirrors a real-CLI behaviour: --type edit is rejected (the API has -// no list-edit-runs endpoint). Agents are expected to recognize this -// and use `runs get edr_xxx` per ID instead. +// Mirrors a real-CLI behaviour: `edit runs list` does not exist (the +// API has no list-edit-runs endpoint). Agents are expected to +// recognize this and use `edit runs get edr_xxx` per ID instead. func emitRunsList(args []string, mode string) { - if flagValue(args, "type") == "edit" { + if positional(args)[0] == "edit" { fmt.Fprintln(stderr, - "Error: --type edit is not supported (edit runs are not listable; use 'extend runs get edr_...' for individual edit runs)") + `Error: unknown command "list" for "extend edit runs" (edit runs are not listable; use 'extend edit runs get edr_...' for individual edit runs)`) exitCode = 1 return } @@ -247,14 +250,19 @@ func emitRunsList(args []string, mode string) { func filterRuns(args []string, in []Run) []Run { status := flagValue(args, "status") - if status == "" { - return in + kind := positional(args)[0] + if kind == "workflows" { + kind = "workflow" } out := make([]Run, 0, len(in)) for _, r := range in { - if r.Status == status { - out = append(out, r) + if r.Type != kind { + continue + } + if status != "" && r.Status != status { + continue } + out = append(out, r) } return out } @@ -274,12 +282,17 @@ func chunk(in []Run, size int) [][]Run { return out } -func emitRunsWatch(args []string, mode string) { +// typedRunID pulls the run ID from ` runs ` argv. +func typedRunID(args []string) string { pos := positional(args) - id := "" - if len(pos) >= 3 { - id = pos[2] + if len(pos) >= 4 { + return pos[3] } + return "" +} + +func emitRunsWatch(args []string, mode string) { + id := typedRunID(args) if r, ok := fixtureRuns[id]; ok { emitJSON(r) return @@ -289,11 +302,7 @@ func emitRunsWatch(args []string, mode string) { } func emitRunsGet(args []string, mode string) { - pos := positional(args) - id := "" - if len(pos) >= 3 { - id = pos[2] - } + id := typedRunID(args) if r, ok := fixtureRuns[id]; ok { emitJSON(r) return @@ -302,18 +311,101 @@ func emitRunsGet(args []string, mode string) { } func emitRunsCancel(args []string, mode string) { - pos := positional(args) - id := "" - if len(pos) >= 3 { - id = pos[2] + // Parse and edit runs have no cancel command in the real CLI; + // the dispatch reaches here for them too, so mirror cobra's + // unknown-command failure verbatim. + verb := positional(args)[0] + if verb == "parse" || verb == "edit" { + fmt.Fprintf(stderr, "Error: unknown command \"cancel\" for \"extend %s runs\"\n", verb) + exitCode = 1 + return } - // Parse runs cannot be cancelled in real life. Mirror that. - if strings.HasPrefix(id, "pr_") { - fmt.Fprintln(stderr, "Error: parse runs cannot be cancelled (use 'runs delete' to remove the record)") + emitJSON(synthesizeRun(typedRunID(args), "CANCELLED")) +} + +// emitRunsDelete mirrors ` runs delete `: the stub is never +// a TTY, so like the real CLI it refuses without --yes/-y and reports +// the deletion to stderr on success. +func emitRunsDelete(args []string, mode string) { + confirmed := hasFlag(args, "yes") + for _, a := range args { + if a == "-y" { + confirmed = true + } + } + if !confirmed { + fmt.Fprintln(stderr, "Error: refusing to delete run without confirmation; pass --yes") exitCode = 1 return } - emitJSON(synthesizeRun(id, "CANCELLED")) + // positional() treats `-y ` as a flag/value pair, so fall back + // to scanning for a run-prefixed token when the ID lands adjacent + // to the short flag. + id := typedRunID(args) + if id == "" { + for _, a := range args { + for _, p := range []string{"exr_", "pr_", "clr_", "splr_", "edr_", "workflow_run_"} { + if strings.HasPrefix(a, p) { + id = a + } + } + } + } + fmt.Fprintf(stderr, "✓ Deleted run %s\n", id) +} + +// emitEditDetectionsCreate mirrors `edit detections create`, which +// waits by default and prints the PROCESSED form detection run; +// output.schema carries the generated edit schema. +func emitEditDetectionsCreate(args []string, mode string) { + emitDetectionRun(nowID("sgr_")) +} + +func emitEditDetectionsGet(args []string, mode string) { + pos := positional(args) + id := "" + if len(pos) >= 4 { + id = pos[3] + } + emitDetectionRun(id) +} + +func emitDetectionRun(id string) { + emitJSON(map[string]any{ + "object": "form_detection_run", + "id": id, + "status": "PROCESSED", + "output": map[string]any{ + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string", "extend_edit:value": nil}, + "date": map[string]any{"type": "string", "extend_edit:value": nil}, + }, + }, + }, + }) +} + +// emitBatchesStatus serves ` batches get|watch` with a terminal +// processor batch. +func emitBatchesStatus(args []string, mode string) { + pos := positional(args) + id := "" + if len(pos) >= 4 { + id = pos[3] + } + emitJSON(map[string]any{ + "id": id, + "status": "PROCESSED", + "counts": map[string]any{"submitted": 1, "processed": 1, "failed": 0}, + }) +} + +// emitWorkflowRunBatch mirrors the real workflow batch response, which +// is just {batchId} — there is no status endpoint for workflow batches. +func emitWorkflowRunBatch(args []string, mode string) { + emitJSON(map[string]any{"batchId": nowID("batch_")}) } func synthesizeRun(id, status string) Run { diff --git a/internal/cli/AGENTS.md b/internal/cli/AGENTS.md index 2158a99..7d5e818 100644 --- a/internal/cli/AGENTS.md +++ b/internal/cli/AGENTS.md @@ -69,8 +69,8 @@ must contain "extend " somewhere — this allows pipeline forms like a complete sentence ending in a period. The corrections an agent needs to hear *once* to avoid a class of error. Examples: "Page tokens are bound to the originating query; repeat the same filter flags on every paginated -call." or "Workflow batches do not have a get endpoint; use 'extend runs -list --type workflow --batch ' instead." +call." or "Workflow batches do not have a get endpoint; use 'extend +workflows runs list --batch ' instead." **`SeeAlso`** — related command paths in space-separated form ("runs watch", "extract"), validated cross-doc. Optional. diff --git a/internal/cli/batch.go b/internal/cli/batch.go index ab0976c..6ccc6c9 100644 --- a/internal/cli/batch.go +++ b/internal/cli/batch.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "sync" - "time" "github.com/spf13/cobra" @@ -229,8 +228,8 @@ every input identically); the server schema does not accept top-level metadata for processor batches. After submission, the command prints the batch ID and a hint for following progress. -Track progress with ` + "`extend batches watch `" + ` or list contained -runs with ` + "`extend runs list --type extract --batch `" + `.`, +Track progress with ` + "`extend extract batches watch `" + ` or list +contained runs with ` + "`extend extract runs list --batch `" + `.`, Examples: []Example{ {Label: "From positional args", Cmd: "extend extract batch invoice1.pdf invoice2.pdf --using ex_abc"}, {Label: "From a list file", Cmd: "extend extract batch --files-from list.txt --using ex_abc"}, @@ -239,9 +238,9 @@ runs with ` + "`extend runs list --type extract --batch `" + `.`, Gotchas: []string{ "Maximum 1,000 inputs per batch; submit multiple batches for larger sets.", "--metadata and --tag apply to every input identically; per-item metadata is not supported.", - "Batch submission returns immediately; use 'extend batches watch ' to follow progress.", + "Batch submission returns immediately; use 'extend extract batches watch ' to follow progress.", }, - SeeAlso: []string{"extract", "batches watch", "batches get", "runs list"}, + SeeAlso: []string{"extract", "extract batches watch", "extract batches get", "extract runs list"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { prep, err := prepBatchSubmit(cmd.Context(), app, args, f) @@ -278,7 +277,7 @@ runs with ` + "`extend runs list --type extract --batch `" + `.`, if err != nil { return fmt.Errorf("submit batch: %w", err) } - return renderBatchSubmitted(app, br) + return renderBatchSubmitted(app, br, "extract") }, Configure: func(cmd *cobra.Command) { f.attach(cmd, "using", true) @@ -307,8 +306,8 @@ identically; the server schema does not accept top-level metadata for processor batches. After submission, the command prints the batch ID and a hint for following progress. -Track progress with ` + "`extend batches watch `" + ` or list contained -runs with ` + "`extend runs list --type classify --batch `" + `.`, +Track progress with ` + "`extend classify batches watch `" + ` or list +contained runs with ` + "`extend classify runs list --batch `" + `.`, Examples: []Example{ {Label: "From positional args", Cmd: "extend classify batch invoice1.pdf invoice2.pdf --using cl_abc"}, {Label: "From a list file", Cmd: "extend classify batch --files-from list.txt --using cl_abc"}, @@ -317,9 +316,9 @@ runs with ` + "`extend runs list --type classify --batch `" + `.`, Gotchas: []string{ "Maximum 1,000 inputs per batch.", "--metadata and --tag apply to every input identically; per-item metadata is not supported.", - "Batch submission returns immediately; use 'extend batches watch ' to follow progress.", + "Batch submission returns immediately; use 'extend classify batches watch ' to follow progress.", }, - SeeAlso: []string{"classify", "batches watch", "batches get", "runs list"}, + SeeAlso: []string{"classify", "classify batches watch", "classify batches get", "classify runs list"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { prep, err := prepBatchSubmit(cmd.Context(), app, args, f) @@ -356,7 +355,7 @@ runs with ` + "`extend runs list --type classify --batch `" + `.`, if err != nil { return fmt.Errorf("submit batch: %w", err) } - return renderBatchSubmitted(app, br) + return renderBatchSubmitted(app, br, "classify") }, Configure: func(cmd *cobra.Command) { f.attach(cmd, "using", true) @@ -384,8 +383,8 @@ one-off requests.`, identically; the server schema does not accept top-level metadata for processor batches. -Track progress with ` + "`extend batches watch `" + ` or list contained -runs with ` + "`extend runs list --type split --batch `" + `.`, +Track progress with ` + "`extend split batches watch `" + ` or list +contained runs with ` + "`extend split runs list --batch `" + `.`, Examples: []Example{ {Label: "From positional args", Cmd: "extend split batch bundle1.pdf bundle2.pdf --using spl_abc"}, {Label: "From a list file", Cmd: "extend split batch --files-from list.txt --using spl_abc"}, @@ -393,9 +392,9 @@ runs with ` + "`extend runs list --type split --batch `" + `.`, Gotchas: []string{ "Maximum 1,000 inputs per batch.", "--metadata and --tag apply to every input identically; per-item metadata is not supported.", - "Batch submission returns immediately; use 'extend batches watch ' to follow progress.", + "Batch submission returns immediately; use 'extend split batches watch ' to follow progress.", }, - SeeAlso: []string{"split", "batches watch", "batches get", "runs list"}, + SeeAlso: []string{"split", "split batches watch", "split batches get", "split runs list"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { prep, err := prepBatchSubmit(cmd.Context(), app, args, f) @@ -432,7 +431,7 @@ runs with ` + "`extend runs list --type split --batch `" + `.`, if err != nil { return fmt.Errorf("submit batch: %w", err) } - return renderBatchSubmitted(app, br) + return renderBatchSubmitted(app, br, "split") }, Configure: func(cmd *cobra.Command) { f.attach(cmd, "using", true) @@ -478,8 +477,8 @@ The same parse tuning as single 'parse' applies to every input: and --advanced-options (see 'extend parse --help' for the JSON field catalogs). --password applies to URL inputs only. -Track progress with ` + "`extend batches watch `" + ` or list contained -runs with ` + "`extend runs list --type parse --batch `" + `.`, +Track progress with ` + "`extend parse batches watch `" + ` or list +contained runs with ` + "`extend parse runs list --batch `" + `.`, Examples: []Example{ {Label: "From positional args", Cmd: "extend parse batch file_a file_b"}, {Label: "Specific engine", Cmd: "extend parse batch --engine parse_performance --engine-version 1.0.1 file_a file_b"}, @@ -490,7 +489,7 @@ runs with ` + "`extend runs list --type parse --batch `" + `.`, "No processor reference is required; engine is selected via --engine/--engine-version.", "Parse runs cannot be cancelled once submitted.", }, - SeeAlso: []string{"parse", "batches watch", "batches get", "runs list"}, + SeeAlso: []string{"parse", "parse batches watch", "parse batches get", "parse runs list"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { // Build (and validate) the config before uploading, so a bad @@ -532,7 +531,7 @@ runs with ` + "`extend runs list --type parse --batch `" + `.`, if err != nil { return fmt.Errorf("submit batch: %w", err) } - return renderBatchSubmitted(app, br) + return renderBatchSubmitted(app, br, "parse") }, Configure: func(cmd *cobra.Command) { cmd.Flags().StringVar(&filesFrom, "files-from", "", "Path to a file with one input per line (- for stdin)") @@ -552,8 +551,9 @@ runs with ` + "`extend runs list --type parse --batch `" + `.`, } } -// newWorkflowBatchDoc returns the typed documentation for `extend run -// batch`. Composed under newRunDoc via CommandDoc.Subcommands. +// newWorkflowBatchDoc returns the typed documentation for `extend +// workflows run batch`. Composed under newWorkflowsRunDoc via +// CommandDoc.Subcommands. func newWorkflowBatchDoc(app *App) *CommandDoc { var ( f batchFlags @@ -569,23 +569,23 @@ func newWorkflowBatchDoc(app *App) *CommandDoc { "trigger up to 1000 workflow runs at once", }, WhenToUse: `Use when you have many inputs to feed through the same workflow and want -to fan them out as a single batch. Prefer single-input 'run' for one-off -runs.`, +to fan them out as a single batch. Prefer single-input 'workflows run' +for one-off runs.`, Details: `Workflow batches return only a batch_id; unlike processor batches there -is no GET /batch_runs/{id} endpoint for workflow batches and -'extend batches watch' will not work on them. Track progress with: +is no GET /batch_runs/{id} endpoint for workflow batches, so there are +no 'workflows batches' commands. Track progress with: - extend runs list --type workflow --batch `, + extend workflows runs list --batch `, Examples: []Example{ - {Label: "From positional args", Cmd: "extend run batch doc1.pdf doc2.pdf --using workflow_abc"}, - {Label: "From a list file", Cmd: "extend run batch --files-from inputs.txt --using workflow_abc"}, + {Label: "From positional args", Cmd: "extend workflows run batch doc1.pdf doc2.pdf --using workflow_abc"}, + {Label: "From a list file", Cmd: "extend workflows run batch --files-from inputs.txt --using workflow_abc"}, }, Gotchas: []string{ "Workflow batch does not accept --priority (server schema omits it).", "Workflow batch does not accept top-level --metadata/--tag (only per-input metadata is allowed; the CLI rejects top-level use).", - "'extend batches watch' does not work on workflow batches; use 'extend runs list --type workflow --batch ' to follow progress.", + "Workflow batches have no get/watch endpoint; use 'extend workflows runs list --batch ' to follow progress.", }, - SeeAlso: []string{"run", "runs list"}, + SeeAlso: []string{"workflows run", "workflows runs list"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { // Workflow batch has two CLI-level rejections that must @@ -659,177 +659,21 @@ func renderWorkflowBatchSubmitted(app *App, resp *extend.WorkflowRunsCreateBatch pal := paletteFor(app.IO) fmt.Fprintf(app.IO.Out, "%s %s (%d run%s submitted)\n", pal.Cyan("⋯"), resp.BatchID, runCount, pluralize(runCount)) - fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Track: extend runs list --type workflow --batch %s --all", resp.BatchID)) - fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Note: workflow batches do not support 'extend batches watch'; use the list command above")) + fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Track: extend workflows runs list --batch %s --all", resp.BatchID)) + fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Note: workflow batches have no get/watch endpoint; use the list command above")) return nil } -func renderBatchSubmitted(app *App, br *extend.BatchRun) error { +// renderBatchSubmitted formats a processor or parse batch, pointing at +// the typed follow-up commands under verb ("extract", "parse", ...). +func renderBatchSubmitted(app *App, br *extend.BatchRun, verb string) error { if app.Format != "" { return renderWithDefault(app, br, output.FormatJSON) } pal := paletteFor(app.IO) fmt.Fprintf(app.IO.Out, "%s %s (%s, %d run%s)\n", statusIcon(pal, extendx.RunStatus(br.Status)), br.ID, br.Status, br.RunCount, pluralize(br.RunCount)) - fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Watch: extend batches watch %s", br.ID)) - fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Results: extend runs list --type --batch %s", br.ID)) + fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Watch: extend %s batches watch %s", verb, br.ID)) + fmt.Fprintf(app.IO.Out, " %s\n", pal.Dimf("Results: extend %s runs list --batch %s", verb, br.ID)) return nil } - -// newBatchesDoc returns the typed documentation for `extend batches` (the -// inspect-and-follow group for batch runs) and its 2 leaves: get, watch. -func newBatchesDoc(app *App) *CommandDoc { - return &CommandDoc{ - Use: "batches", - Summary: "Inspect and follow batch runs", - Group: "Inspection", - WhenToUse: `Use these commands to inspect or watch a processor or parse batch run -by its batch ID. Workflow batches do not have a get/watch endpoint; use -'extend runs list --type workflow --batch ' for those.`, - Details: `Operations on batch runs identified by their bpr_/bpar_ ID. Workflow -batches (returned by 'extend run batch') do NOT have a get endpoint; -list their member runs with 'extend runs list --type workflow --batch -' instead.`, - Subcommands: []*CommandDoc{ - newBatchesGetDoc(app), - newBatchesWatchDoc(app), - }, - } -} - -func newBatchesGetDoc(app *App) *CommandDoc { - return &CommandDoc{ - Use: "get ", - Summary: "Show one batch run by ID", - Triggers: []string{ - "show one batch run", - "inspect a processor or parse batch", - "check batch status by id", - }, - WhenToUse: `Use to retrieve the current status, member-run count, and timestamps -for a single batch run. Does not poll; for live progress use -'extend batches watch'.`, - Details: `Show one processor or parse batch run, including its overall status, -member-run count, and timestamps. Workflow batches do NOT have a get -endpoint; for those, use 'extend runs list --type workflow --batch '.`, - Examples: []Example{ - {Label: "Processor batch", Cmd: "extend batches get bpr_abc123"}, - {Label: "Parse batch", Cmd: "extend batches get bpar_xyz"}, - }, - Gotchas: []string{ - "Workflow batches do not have a get endpoint; use 'extend runs list --type workflow --batch ' instead.", - }, - SeeAlso: []string{"batches watch", "runs list"}, - Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cli, err := app.NewClient() - if err != nil { - return err - } - br, err := getBatchRun(cmd.Context(), cli, args[0]) - if err != nil { - return err - } - return renderWithDefault(app, br, output.FormatJSON) - }, - } -} - -// getBatchRun is the CLI-side wrapper around BatchRuns.Get that -// surfaces the friendly "workflow batches have no get endpoint" error -// instead of letting a 404 leak through. The SDK has no equivalent -// because the API endpoint really does exist for processor and parse -// batches; the workflow-batch carve-out lives at the schema level on -// the server side, not the SDK's. -func getBatchRun(ctx context.Context, cli *sdkclient.Client, id string) (*extend.BatchRun, error) { - if kind, ok := extendx.BatchKindFromID(id); ok && kind == extendx.BatchKindWorkflow { - return nil, extendx.ErrWorkflowBatchNotRetrievable - } - return cli.BatchRuns.Get(ctx, id) -} - -func newBatchesWatchDoc(app *App) *CommandDoc { - var ( - timeout time.Duration - exitStatus bool - ) - return &CommandDoc{ - Use: "watch ", - Summary: "Poll a batch run until it reaches a terminal state", - Triggers: []string{ - "watch a batch run until it finishes", - "poll a processor or parse batch", - "block until extract batch completes", - "follow batch progress live", - }, - WhenToUse: `Use to block until a processor or parse batch reaches a terminal -state. Combine with --exit-status to gate downstream scripts on success.`, - Details: `Poll a processor or parse batch and print the final status when it -reaches a terminal state. Workflow batches do not have a get endpoint and -cannot be watched here; use 'extend runs list --type workflow --batch ' -to monitor them instead. - -Pass --exit-status to make the command exit non-zero when the batch -finishes in FAILED or CANCELLED status, suitable for shell composition: - - extend batches watch bpr_abc --exit-status && downstream-script.sh - -Polls every 2s, backing off to 30s.`, - Examples: []Example{ - {Label: "Basic", Cmd: "extend batches watch bpr_abc123"}, - {Label: "Custom timeout", Cmd: "extend batches watch bpr_abc123 --timeout 2h"}, - {Label: "Gate downstream script", Cmd: "extend batches watch bpr_abc123 --exit-status"}, - }, - Gotchas: []string{ - "Workflow batches cannot be watched here; use 'extend runs list --type workflow --batch '.", - "Without --exit-status, the command exits 0 on terminal regardless of FAILED/CANCELLED.", - }, - SeeAlso: []string{"batches get", "runs list", "runs watch"}, - Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, - Wait: &WaitSpec{Profile: extendx.ProfileLong, DefaultsToWait: true}, - Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cli, err := app.NewClient() - if err != nil { - return err - } - id := args[0] - sp := app.IO.StartSpinner(fmt.Sprintf("Batch %s: ?", id)) - final, err := waitForBatchRun(cmd.Context(), cli, id, extendx.WaitProfileOptions(extendx.ProfileLong, timeout), func(r *extend.BatchRun) { - sp.Update(fmt.Sprintf("Batch %s: %s (%d run%s)", r.ID, r.Status, r.RunCount, pluralize(r.RunCount))) - }) - sp.Stop("") - if err != nil { - return formatWatchWaitError(err, id) - } - if err := renderBatchSubmitted(app, final); err != nil { - return err - } - if exitStatus { - switch extendx.RunStatus(final.Status) { - case extendx.StatusFailed: - return fmt.Errorf("batch %s failed", id) - case extendx.StatusCancelled: - return fmt.Errorf("batch %s was cancelled", id) - } - } - return nil - }, - Configure: func(cmd *cobra.Command) { - cmd.Flags().DurationVar(&timeout, "timeout", 1*time.Hour, "Maximum total time to wait for the batch to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") - cmd.Flags().BoolVar(&exitStatus, "exit-status", false, "Exit non-zero on FAILED or CANCELLED") - }, - } -} - -func waitForBatchRun(ctx context.Context, c *sdkclient.Client, id string, opts extendx.WaitOptions, onPoll func(*extend.BatchRun)) (*extend.BatchRun, error) { - return extendx.PollForRun(ctx, - func(ctx context.Context) (*extend.BatchRun, error) { - return getBatchRun(ctx, c, id) - }, - func(r *extend.BatchRun) extendx.RunStatus { return extendx.RunStatus(r.Status) }, - opts, onPoll, - ) -} diff --git a/internal/cli/batch_test.go b/internal/cli/batch_test.go index b67813d..1947ca0 100644 --- a/internal/cli/batch_test.go +++ b/internal/cli/batch_test.go @@ -9,6 +9,8 @@ import ( "strings" "sync/atomic" "testing" + + "github.com/extend-hq/extend-cli/internal/extendx" ) func TestCollectBatchInputs_PositionalOnly(t *testing.T) { @@ -142,7 +144,7 @@ func TestWorkflowBatch_ReturnsBatchIDOnly(t *testing.T) { writeJSON(w, 200, map[string]any{"batchId": "batch_abc"}) }) ta := newTestApp(t, srv) - cmd := findCmd(t, ta.app, "run", "batch") + cmd := findCmd(t, ta.app, "workflows", "run", "batch") cmd.SetArgs([]string{"file_a", "file_b", "--using", "workflow_xK9"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v", err) @@ -164,7 +166,7 @@ func TestWorkflowBatch_RejectsTopLevelPriority(t *testing.T) { t.Fatal("server should not be hit when client validates") }) ta := newTestApp(t, srv) - cmd := findCmd(t, ta.app, "run", "batch") + cmd := findCmd(t, ta.app, "workflows", "run", "batch") cmd.SetArgs([]string{"file_a", "--using", "workflow_xK9", "--priority", "5"}) err := cmd.Execute() if err == nil || !strings.Contains(err.Error(), "priority") { @@ -177,8 +179,11 @@ func TestRunsList_BatchFilterReachesQuery(t *testing.T) { writeJSON(w, 200, map[string]any{"object": "list", "data": []any{}}) }) ta := newTestApp(t, srv) - if err := runRunsList(stubCmdWithCtx(context.Background(), "list"), ta.app, runsListParams{ - runType: "extract", + cli, err := ta.app.NewClient() + if err != nil { + t.Fatalf("client: %v", err) + } + if _, _, err := collectListRows(context.Background(), cli, extendx.KindExtract, runsListParams{ batchID: "bpr_xyz", limit: 5, sortDir: "desc", @@ -345,7 +350,7 @@ func TestWorkflowBatch_ForwardsSecrets(t *testing.T) { writeJSON(w, 200, map[string]any{"batchId": "wfb_x"}) }) ta := newTestApp(t, srv) - cmd := findCmd(t, ta.app, "run", "batch") + cmd := findCmd(t, ta.app, "workflows", "run", "batch") cmd.SetArgs([]string{"file_a", "--using", "workflow_abc", "--secret", "API_KEY=xyz"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v", err) diff --git a/internal/cli/batches.go b/internal/cli/batches.go new file mode 100644 index 0000000..b92a880 --- /dev/null +++ b/internal/cli/batches.go @@ -0,0 +1,215 @@ +package cli + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + extend "github.com/extend-hq/extend-go-sdk" + sdkclient "github.com/extend-hq/extend-go-sdk/client" + + "github.com/extend-hq/extend-cli/internal/extendx" + "github.com/extend-hq/extend-cli/internal/output" +) + +// This file holds the typed `extend batches` inspection +// subgroups (get/watch on already-submitted batches). The batch +// *submit* builders (`extend batch`) live in batch.go with +// their upload plumbing. + +// batchesGroupSpec parameterizes the generated `extend batches` +// subgroup. Only verbs whose batch endpoint supports GET +// /batch_runs/{id} attach one: extract, classify, and split share the +// processor batch kind (bpr_), parse has its own (bpar_). Workflow +// batches have no retrieval endpoint, so `workflows` has no batches +// group. +type batchesGroupSpec struct { + verb string + batchKind extendx.BatchKind + exampleID string +} + +func extractBatchesSpec() batchesGroupSpec { + return batchesGroupSpec{verb: "extract", batchKind: extendx.BatchKindProcessor, exampleID: "bpr_xK9mLPq"} +} + +func classifyBatchesSpec() batchesGroupSpec { + return batchesGroupSpec{verb: "classify", batchKind: extendx.BatchKindProcessor, exampleID: "bpr_kMXkR"} +} + +func splitBatchesSpec() batchesGroupSpec { + return batchesGroupSpec{verb: "split", batchKind: extendx.BatchKindProcessor, exampleID: "bpr_s8Yqw"} +} + +func parseBatchesSpec() batchesGroupSpec { + return batchesGroupSpec{verb: "parse", batchKind: extendx.BatchKindParse, exampleID: "bpar_pJDa8"} +} + +func (s batchesGroupSpec) prefix() string { + if s.batchKind == extendx.BatchKindParse { + return "bpar_" + } + return "bpr_" +} + +// sharedPrefixNote documents the bpr_ ambiguity for the processor +// verbs: prefix validation alone cannot tell an extract batch from a +// classify or split batch, so the server resolves the exact type. +func (s batchesGroupSpec) sharedPrefixNote() string { + if s.batchKind != extendx.BatchKindProcessor { + return "" + } + return "The bpr_ prefix is shared by extract, classify, and split batches; the server resolves the exact type." +} + +// doc returns the typed documentation tree for `extend batches`. +func (s batchesGroupSpec) doc(app *App) *CommandDoc { + return &CommandDoc{ + Use: "batches", + Summary: fmt.Sprintf("Inspect and follow %s batch runs", s.verb), + WhenToUse: fmt.Sprintf(`Use these commands to inspect or watch a %s batch run (submitted with +'extend %s batch') by its %s ID.`, s.verb, s.verb, s.prefix()), + Details: fmt.Sprintf(`Operations on %s batch runs identified by their %s ID.`, s.verb, s.prefix()), + Subcommands: []*CommandDoc{ + s.getDoc(app), + s.watchDoc(app), + }, + } +} + +func (s batchesGroupSpec) getDoc(app *App) *CommandDoc { + gotchas := []string{ + fmt.Sprintf("This command never waits; use 'extend %s batches watch' for live polling.", s.verb), + } + if n := s.sharedPrefixNote(); n != "" { + gotchas = append(gotchas, n) + } + return &CommandDoc{ + Use: "get ", + Summary: fmt.Sprintf("Show one %s batch run by ID", s.verb), + Triggers: []string{ + fmt.Sprintf("show one %s batch run", s.verb), + fmt.Sprintf("inspect a %s batch by id", s.verb), + fmt.Sprintf("check %s batch status", s.verb), + }, + WhenToUse: fmt.Sprintf(`Use to retrieve the current status, member-run count, and timestamps +for a single %s batch run. Does not poll; for live progress use +'extend %s batches watch'.`, s.verb, s.verb), + Details: fmt.Sprintf(`Show one %s batch run, including its overall status, member-run count, +and timestamps. To list the individual runs inside the batch, use +'extend %s runs list --batch '.`, s.verb, s.verb), + Examples: []Example{ + {Label: "Basic", Cmd: fmt.Sprintf("extend %s batches get %s", s.verb, s.exampleID)}, + {Label: "Just the status", Cmd: fmt.Sprintf("extend %s batches get %s --jq '.status' -o raw", s.verb, s.exampleID)}, + }, + Gotchas: gotchas, + SeeAlso: []string{s.verb + " batches watch", s.verb + " runs list"}, + Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + if err := extendx.ValidateBatchID(s.batchKind, id, "get"); err != nil { + return err + } + cli, err := app.NewClient() + if err != nil { + return err + } + br, err := cli.BatchRuns.Get(cmd.Context(), id) + if err != nil { + return err + } + return renderWithDefault(app, br, output.FormatJSON) + }, + } +} + +func (s batchesGroupSpec) watchDoc(app *App) *CommandDoc { + var ( + timeout time.Duration + exitStatus bool + ) + gotchas := []string{ + "Without --exit-status, the command exits 0 on terminal regardless of FAILED/CANCELLED.", + } + if n := s.sharedPrefixNote(); n != "" { + gotchas = append(gotchas, n) + } + return &CommandDoc{ + Use: "watch ", + Summary: fmt.Sprintf("Poll %s %s batch until it reaches a terminal state", articleFor(s.verb), s.verb), + Triggers: []string{ + fmt.Sprintf("watch %s %s batch until it finishes", articleFor(s.verb), s.verb), + fmt.Sprintf("poll %s %s batch run", articleFor(s.verb), s.verb), + fmt.Sprintf("block until %s %s batch completes", articleFor(s.verb), s.verb), + }, + WhenToUse: fmt.Sprintf(`Use to block until %s %s batch reaches a terminal state. Combine with +--exit-status to gate downstream scripts on success.`, articleFor(s.verb), s.verb), + Details: fmt.Sprintf(`Poll %s %s batch and print the final status when it reaches a terminal +state. + +Pass --exit-status to make the command exit non-zero when the batch +finishes in FAILED or CANCELLED status, suitable for shell composition: + + extend %s batches watch %s --exit-status && downstream-script.sh + +Polls every 2s, backing off to 30s.`, articleFor(s.verb), s.verb, s.verb, s.exampleID), + Examples: []Example{ + {Label: "Basic", Cmd: fmt.Sprintf("extend %s batches watch %s", s.verb, s.exampleID)}, + {Label: "Custom timeout", Cmd: fmt.Sprintf("extend %s batches watch %s --timeout 2h", s.verb, s.exampleID)}, + {Label: "Gate downstream script", Cmd: fmt.Sprintf("extend %s batches watch %s --exit-status", s.verb, s.exampleID)}, + }, + Gotchas: gotchas, + SeeAlso: []string{s.verb + " batches get", s.verb + " runs list", s.verb + " runs watch"}, + Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, + Wait: &WaitSpec{Profile: extendx.ProfileLong, DefaultsToWait: true}, + Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + if err := extendx.ValidateBatchID(s.batchKind, id, "watch"); err != nil { + return err + } + cli, err := app.NewClient() + if err != nil { + return err + } + sp := app.IO.StartSpinner(fmt.Sprintf("Batch %s: ?", id)) + final, err := waitForBatchRun(cmd.Context(), cli, id, extendx.WaitProfileOptions(extendx.ProfileLong, timeout), func(r *extend.BatchRun) { + sp.Update(fmt.Sprintf("Batch %s: %s (%d run%s)", r.ID, r.Status, r.RunCount, pluralize(r.RunCount))) + }) + sp.Stop("") + if err != nil { + return formatWatchWaitError(err, id, fmt.Sprintf("extend %s batches watch", s.verb)) + } + if err := renderBatchSubmitted(app, final, s.verb); err != nil { + return err + } + if exitStatus { + switch extendx.RunStatus(final.Status) { + case extendx.StatusFailed: + return fmt.Errorf("batch %s failed", id) + case extendx.StatusCancelled: + return fmt.Errorf("batch %s was cancelled", id) + } + } + return nil + }, + Configure: func(cmd *cobra.Command) { + cmd.Flags().DurationVar(&timeout, "timeout", 1*time.Hour, "Maximum total time to wait for the batch to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") + cmd.Flags().BoolVar(&exitStatus, "exit-status", false, "Exit non-zero on FAILED or CANCELLED") + }, + } +} + +func waitForBatchRun(ctx context.Context, c *sdkclient.Client, id string, opts extendx.WaitOptions, onPoll func(*extend.BatchRun)) (*extend.BatchRun, error) { + return extendx.PollForRun(ctx, + func(ctx context.Context) (*extend.BatchRun, error) { + return c.BatchRuns.Get(ctx, id) + }, + func(r *extend.BatchRun) extendx.RunStatus { return extendx.RunStatus(r.Status) }, + opts, onPoll, + ) +} diff --git a/internal/cli/classify.go b/internal/cli/classify.go index 513e48a..d6c8a94 100644 --- a/internal/cli/classify.go +++ b/internal/cli/classify.go @@ -83,7 +83,7 @@ JSON, a path, a file:// URI, or '-' to read from stdin. "Exactly one of --using or --config is required (server schema rejects both or neither).", "--patch requires --using; for a standalone config use --config instead.", }, - SeeAlso: []string{"extract", "parse", "classify batch", "runs watch", "runs get"}, + SeeAlso: []string{"extract", "parse", "classify batch", "classify runs watch", "classify runs get"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, @@ -131,7 +131,7 @@ JSON, a path, a file:// URI, or '-' to read from stdin. cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") meta.attach(cmd) }, - Subcommands: []*CommandDoc{newClassifyBatchDoc(app)}, + Subcommands: []*CommandDoc{newClassifyBatchDoc(app), classifyRunsSpec().doc(app), classifyBatchesSpec().doc(app)}, } } @@ -223,7 +223,7 @@ func runClassify(ctx context.Context, app *App, p classifyParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend classify runs watch") } if err := renderClassifyResult(app, final); err != nil { diff --git a/internal/cli/commanddoc.go b/internal/cli/commanddoc.go index 701d8db..dcea484 100644 --- a/internal/cli/commanddoc.go +++ b/internal/cli/commanddoc.go @@ -474,6 +474,18 @@ func (d *CommandDoc) build(root *CommandDoc) *cobra.Command { }) case d.RunE != nil: cmd.RunE = d.RunE + case d.IsGroup(): + // Groups must be runnable: cobra returns flag.ErrHelp for a + // non-runnable command BEFORE Args validation, so an unknown + // subcommand ('extend parse runs cancel ...') would print help + // and exit 0 — silently succeeding for scripts that gate on + // exit status. With a RunE attached, the group's Args + // validator (cobra.NoArgs by default) rejects unknown + // subcommands with a non-zero exit, and a bare invocation + // still shows help. + cmd.RunE = func(cobraCmd *cobra.Command, _ []string) error { + return cobraCmd.Help() + } } applyAnnotations(cmd, d) diff --git a/internal/cli/commanddoc_test.go b/internal/cli/commanddoc_test.go index c9ead49..c679995 100644 --- a/internal/cli/commanddoc_test.go +++ b/internal/cli/commanddoc_test.go @@ -393,6 +393,62 @@ func TestValidateRejectsUnresolvedSeeAlso(t *testing.T) { // in the tree to run, so no client is ever needed. func testAppForDocs() *App { return &App{} } +// TestGroupsRejectUnknownSubcommands pins the exit contract for argv +// shapes that don't resolve to a runnable leaf: removed top-level +// groups (runs/run/batches), typed subcommands a kind doesn't have +// (parse runs cancel, edit runs list, workflows batches), and any +// other unknown subcommand of a group. Each must surface an error +// (non-zero exit) rather than printing help and exiting 0 — scripts +// gate on exit status, and a silent success here means a watch or +// cancel that never happened. +func TestGroupsRejectUnknownSubcommands(t *testing.T) { + cases := []struct { + args []string + want string // substring of the error message + }{ + {[]string{"runs", "watch", "exr_x"}, "extend runs"}, + {[]string{"run", "in.pdf"}, "extend workflows run"}, + {[]string{"batches", "get", "bpr_x"}, "extend batches"}, + {[]string{"parse", "runs", "cancel", "pr_x"}, `unknown command "cancel" for "extend parse runs"`}, + {[]string{"edit", "runs", "list"}, `unknown command "list" for "extend edit runs"`}, + {[]string{"extract", "runs", "update", "exr_x"}, `unknown command "update" for "extend extract runs"`}, + {[]string{"workflows", "batches", "get", "b_x"}, `unknown command "batches" for "extend workflows"`}, + {[]string{"bogus"}, `unknown command "bogus" for "extend"`}, + } + for _, tc := range cases { + ta := newTestApp(t, nil) + root := RootDoc(ta.app).Build() + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(ta.out) + root.SetErr(ta.errOut) + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Errorf("extend %s: err = nil; want unknown-command error", strings.Join(tc.args, " ")) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("extend %s: error %q does not contain %q", strings.Join(tc.args, " "), err, tc.want) + } + } + + // A bare group still shows help and exits 0. + ta := newTestApp(t, nil) + root := RootDoc(ta.app).Build() + root.SilenceUsage = true + root.SilenceErrors = true + root.SetOut(ta.out) + root.SetErr(ta.errOut) + root.SetArgs([]string{"extract", "runs"}) + if err := root.Execute(); err != nil { + t.Errorf("bare group 'extend extract runs': err = %v; want nil (help)", err) + } + if !strings.Contains(ta.out.String(), "Available Commands") { + t.Error("bare group 'extend extract runs' did not print help") + } +} + func noopRun(*cobra.Command, []string) error { return nil } var jsonJSON = OutputSpec{TTY: OutputJSON, Pipe: OutputJSON} diff --git a/internal/cli/configdocs.go b/internal/cli/configdocs.go index 17f42e6..81511cb 100644 --- a/internal/cli/configdocs.go +++ b/internal/cli/configdocs.go @@ -127,7 +127,7 @@ const workflowStepsFields = ` - steps (array) - the workflow's processing graph 1. create a workflow draft: extend workflows create --from-file body.json 2. update the draft graph: extend workflows update workflow_xxx --from-file steps.json 3. deploy an immutable version: extend workflows versions create workflow_xxx --name "initial" - 4. run it async: extend run invoice.pdf --using workflow_xxx [--wait] + 4. run it async: extend workflows run invoice.pdf --using workflow_xxx [--wait] Step envelope: { "name": "extract", "type": "EXTRACT", "config": { ... }, "next": [{ "step": "review" }] } diff --git a/internal/cli/configdocs_test.go b/internal/cli/configdocs_test.go index 5a32f34..05ea5cb 100644 --- a/internal/cli/configdocs_test.go +++ b/internal/cli/configdocs_test.go @@ -165,7 +165,7 @@ func TestWorkflowHelpDocumentsRoutingAndVersionRules(t *testing.T) { ta := newTestApp(t, newFakeServer(t, nil)) tokens := []string{ "extend workflows versions create", - "extend run invoice.pdf --using workflow_xxx", + "extend workflows run invoice.pdf --using workflow_xxx", "TRIGGER followed by", "WEBHOOK_RESPONSE is terminal", "classificationId", diff --git a/internal/cli/download.go b/internal/cli/download.go index f18980a..f1be47e 100644 --- a/internal/cli/download.go +++ b/internal/cli/download.go @@ -28,8 +28,9 @@ import ( // workflow_run_* every file output across the run's step runs // // Runs whose output is JSON (parse, extract, classify) deliberately -// error out with a pointer to `extend runs get` — there is nothing to -// "download" in those cases, only structured data to render. +// error out with a pointer to the typed `extend runs get` — +// there is nothing to "download" in those cases, only structured data +// to render. func newDownloadDoc(app *App) *CommandDoc { var ( outputDir string @@ -51,8 +52,8 @@ workflow) or referenced by file ID. The ID prefix selects the source; the command auto-walks the run record for downloadable files. For runs whose output is structured JSON (parse, extract, classify), -use 'extend runs get ' instead — there is no file to download in -those cases.`, +use the typed 'extend runs get ' instead — there is no file +to download in those cases.`, Details: `Resolves based on its prefix: file_* the file itself @@ -76,12 +77,12 @@ caller has no way to disambiguate concatenated outputs.`, {Label: "Workflow run, write all file outputs to ./out", Cmd: "extend download workflow_run_xK9 --output-dir ./out"}, }, Gotchas: []string{ - "Parse, extract, and classify runs have no file artifact; use 'extend runs get ' for their JSON output.", + "Parse, extract, and classify runs have no file artifact; use the typed 'extend runs get ' for their JSON output.", "--output-file is rejected for multi-file sources (split or workflow runs); use --output-dir instead.", "Presigned URLs expire after one hour; downloading long after the run completed may fail with a 'no presigned URL' error.", "Identically-named files in a multi-file download get '-2', '-3', etc. appended before the extension.", }, - SeeAlso: []string{"files download", "files get", "runs get"}, + SeeAlso: []string{"files download", "files get", "extract runs get", "parse runs get"}, Output: OutputSpec{TTY: OutputBinary, Pipe: OutputBinary}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -155,7 +156,7 @@ func resolveDownloadTargets(ctx context.Context, cli *sdkclient.Client, id strin case strings.HasPrefix(id, "exr_"), strings.HasPrefix(id, "pr_"), strings.HasPrefix(id, "clr_"): - return nil, fmt.Errorf("%s runs produce JSON, not files; use 'extend runs get %s'", runTypeNameFromID(id), id) + return nil, fmt.Errorf("%s runs produce JSON, not files; use 'extend %s runs get %s'", runTypeNameFromID(id), runTypeNameFromID(id), id) default: return nil, fmt.Errorf("unrecognized ID prefix for download: %s (expected file_, edr_, splr_, or workflow_run_)", id) } diff --git a/internal/cli/edit.go b/internal/cli/edit.go index 6a59826..4fe57a9 100644 --- a/internal/cli/edit.go +++ b/internal/cli/edit.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -118,7 +119,7 @@ the output must remain editable. "Detection toggles (flattenPdf/nativeFieldsOnly/tableParsingEnabled/radioEnumsEnabled) go in --advanced-options JSON; omitted fields use the server default.", "Edit runs cannot have a CANCELLED status; only FAILED or PROCESSED.", }, - SeeAlso: []string{"edit schema generate", "runs watch", "runs get", "files download"}, + SeeAlso: []string{"edit schema generate", "edit runs watch", "edit runs get", "files download"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, Failures: []extendx.RunStatus{extendx.StatusFailed}, @@ -146,7 +147,7 @@ the output must remain editable. cmd.Flags().BoolVar(&wait, "wait", true, "Wait for the run to reach a terminal state (--wait=false returns the run ID immediately)") cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") }, - Subcommands: []*CommandDoc{newEditSchemaDoc(app), newEditTemplatesDoc(app)}, + Subcommands: []*CommandDoc{newEditSchemaDoc(app), newEditTemplatesDoc(app), editRunsSpec().doc(app), newEditDetectionsDoc(app)}, } } @@ -228,7 +229,7 @@ func runEdit(ctx context.Context, app *App, p editParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend edit runs watch") } if extendx.RunStatus(final.Status) == extendx.StatusFailed { @@ -338,7 +339,213 @@ func maybeWarnEmptyEditOutput(app *App, run *extend.EditRun) { pal := paletteFor(app.IO) fmt.Fprintf(app.IO.ErrOut, "%s edit run %s reported PROCESSED but produced no filled PDF (output.editedFile is missing).\n", pal.Yellow("warning:"), run.ID) - fmt.Fprintln(app.IO.ErrOut, pal.Dimf(" This usually means the server detected no fields to fill — double-check your --schema or --instructions. Inspect the full run with: extend runs get %s -o json", run.ID)) + fmt.Fprintln(app.IO.ErrOut, pal.Dimf(" This usually means the server detected no fields to fill — double-check your --schema or --instructions. Inspect the full run with: extend edit runs get %s -o json", run.ID)) +} + +// newEditDetectionsDoc returns the typed documentation for the +// `extend edit detections` group: asynchronous form detection runs +// (sgr_..., POST /form_detection_runs). 'create' starts one and +// 'get' fetches it; the synchronous scaffolding path is 'extend edit +// schema generate'. +func newEditDetectionsDoc(app *App) *CommandDoc { + return &CommandDoc{ + Use: "detections", + Summary: "Start and inspect form detection runs", + WhenToUse: `Use this group to start an asynchronous form detection run (sgr_...) or +fetch one by ID. When a run is PROCESSED, output.schema contains an edit +schema you can pass directly to 'extend edit --schema'.`, + Details: `Form detection runs wrap POST /form_detection_runs (async). For +synchronous schema scaffolding from the CLI, use 'extend edit schema +generate' instead; the async variant suits large or slow documents.`, + Subcommands: []*CommandDoc{ + newEditDetectionsCreateDoc(app), + newEditDetectionsGetDoc(app), + }, + } +} + +// newEditDetectionsCreateDoc returns the typed documentation for +// `extend edit detections create`: the async counterpart of +// 'extend edit schema generate', sharing the same +// EditSchemaGenerationConfig knobs. +func newEditDetectionsCreateDoc(app *App) *CommandDoc { + var ( + advancedOptionsPath string + instructions string + inputSchemaPath string + password string + wait bool + timeout time.Duration + ) + return &CommandDoc{ + Use: "create ", + Summary: "Start an async form detection run", + Triggers: []string{ + "start an async form detection run", + "detect form fields without blocking", + "create a form detection run for a pdf", + "scaffold an edit schema asynchronously", + }, + WhenToUse: `Use to detect form fields in a PDF via the asynchronous endpoint. By +default the command waits and prints the finished run; when PROCESSED, +output.schema is an edit schema usable with 'extend edit --schema'. For +a one-call synchronous scaffold, prefer 'extend edit schema generate'.`, + Details: `Start a form detection run (sgr_...) and, by default, poll until it +reaches PROCESSED or FAILED, then print the full run as JSON. Pass +--wait=false to print the newly created run immediately and poll later +with 'extend edit detections get '. + +The config knobs match 'extend edit schema generate': --instructions +guides the generator, --input-schema seeds it with a starting-point +schema, and detection options ride in --advanced-options as a JSON +object (omitted fields use the server default): + + nativeFieldsOnly bool Only use embedded AcroForm fields; set false to also detect fields via vision. + tableParsingEnabled bool Parse table regions as arrays of objects. + radioEnumsEnabled bool Model a radio-button group as a single-choice enum.`, + Examples: []Example{ + {Label: "Basic", Cmd: "extend edit detections create form.pdf"}, + {Label: "Just the schema", Cmd: "extend edit detections create form.pdf --jq '.output.schema' -o json > schema.json"}, + {Label: "Async (return run ID)", Cmd: "extend edit detections create form.pdf --wait=false"}, + {Label: "With instructions", Cmd: `extend edit detections create form.pdf --instructions "skip the signature block"`}, + }, + Gotchas: []string{ + "For an interactive one-call scaffold, 'extend edit schema generate' returns the schema directly; this command returns the run object with the schema under output.schema.", + "With --wait=false, poll with 'extend edit detections get '; there is no watch or cancel for detection runs.", + }, + SeeAlso: []string{"edit detections get", "edit schema generate", "edit"}, + Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, + Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, + Failures: []extendx.RunStatus{extendx.StatusFailed}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cli, err := app.NewClient() + if err != nil { + return err + } + ref, err := uploadOrResolveWith(cmd.Context(), app, cli, args[0], password) + if err != nil { + return err + } + file, err := extendx.BuildFormDetectionFile(ref) + if err != nil { + return err + } + cfg := &extend.EditSchemaGenerationConfig{} + if advancedOptionsPath != "" { + raw, err := readJSONFile(advancedOptionsPath, "--advanced-options") + if err != nil { + return err + } + var ao extend.EditSchemaGenerationConfigAdvancedOptions + if err := json.Unmarshal(raw, &ao); err != nil { + return fmt.Errorf("--advanced-options: %w", err) + } + cfg.AdvancedOptions = &ao + } + if instructions != "" { + cfg.Instructions = extend.String(instructions) + } + if inputSchemaPath != "" { + raw, err := readJSONFile(inputSchemaPath, "--input-schema") + if err != nil { + return err + } + var schema extend.EditRootJSON + if err := json.Unmarshal(raw, &schema); err != nil { + return fmt.Errorf("--input-schema: %w", err) + } + cfg.InputSchema = &schema + } + run, err := cli.FormDetectionRuns.Create(cmd.Context(), &extend.FormDetectionRunsCreateRequest{ + File: file, + Config: cfg, + }) + if err != nil { + return fmt.Errorf("create run: %w", err) + } + if !wait { + return renderWithDefault(app, run, output.FormatJSON) + } + sp := app.IO.StartSpinner(fmt.Sprintf("Run %s: %s", run.ID, run.Status)) + final, err := waitForFormDetectionRun(cmd.Context(), cli, run.ID, extendx.WaitProfileOptions(extendx.ProfileShort, timeout), func(r *extend.FormDetectionRun) { + sp.Update(fmt.Sprintf("Run %s: %s", r.ID, r.Status)) + }) + sp.Stop("") + if err != nil { + return formatActionWaitError(err, run.ID, "extend edit detections get") + } + if extendx.RunStatus(final.Status) == extendx.StatusFailed { + // Best-effort render of the failed run before returning + // the failure error; a render error here is secondary. + _ = renderWithDefault(app, final, output.FormatJSON) + return runFailureError(final.ID, final.FailureReason, final.FailureMessage) + } + return renderWithDefault(app, final, output.FormatJSON) + }, + Configure: func(cmd *cobra.Command) { + cmd.Flags().StringVar(&advancedOptionsPath, "advanced-options", "", "Detection options as a JSON object: nativeFieldsOnly, tableParsingEnabled, radioEnumsEnabled. Source: inline JSON, path, file:// URI, or '-' for stdin. Omitted fields use the server default.") + cmd.Flags().StringVar(&instructions, "instructions", "", "Free-form instructions to guide schema generation") + cmd.Flags().StringVar(&inputSchemaPath, "input-schema", "", "Starting-point JSON Schema (overlaid by detection). Source: inline JSON, path, file:// URI, or '-' for stdin.") + cmd.Flags().StringVar(&password, "password", "", "Password for a password-protected PDF (URL inputs only)") + cmd.Flags().BoolVar(&wait, "wait", true, "Wait for the run to reach a terminal state (--wait=false returns the run immediately)") + cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") + }, + } +} + +func waitForFormDetectionRun(ctx context.Context, c *sdkclient.Client, id string, opts extendx.WaitOptions, onPoll func(*extend.FormDetectionRun)) (*extend.FormDetectionRun, error) { + return extendx.PollForRun(ctx, + func(ctx context.Context) (*extend.FormDetectionRun, error) { + return c.FormDetectionRuns.Retrieve(ctx, id, &extend.FormDetectionRunsRetrieveRequest{}) + }, + func(r *extend.FormDetectionRun) extendx.RunStatus { return extendx.RunStatus(r.Status) }, + opts, onPoll, + ) +} + +func newEditDetectionsGetDoc(app *App) *CommandDoc { + return &CommandDoc{ + Use: "get ", + Summary: "Fetch a form detection run by ID", + Triggers: []string{ + "get a form detection run by id", + "poll form detection run status", + "fetch a detected form schema by run id", + "inspect an sgr_ form detection run", + }, + WhenToUse: `Use to retrieve the status and result of an asynchronous form detection +run (sgr_...). Poll until status is PROCESSED or FAILED; when PROCESSED, +output.schema is an edit schema usable with 'extend edit --schema'.`, + Details: `Returns the full form detection run object as JSON, including status, +config, and (when PROCESSED) the generated schema under output.schema.`, + Examples: []Example{ + {Label: "Basic", Cmd: "extend edit detections get sgr_xK9mLPq"}, + {Label: "Just the schema", Cmd: "extend edit detections get sgr_xK9mLPq --jq '.output.schema' -o json"}, + }, + Gotchas: []string{ + "This command never waits; re-run it to poll until status is PROCESSED or FAILED.", + "For synchronous schema scaffolding, use 'extend edit schema generate' instead.", + }, + SeeAlso: []string{"edit detections create", "edit schema generate", "edit"}, + Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + if !strings.HasPrefix(id, "sgr_") { + return fmt.Errorf("%q is not a recognized form detection run ID (expected sgr_ prefix)", id) + } + cli, err := app.NewClient() + if err != nil { + return err + } + run, err := cli.FormDetectionRuns.Retrieve(cmd.Context(), id, &extend.FormDetectionRunsRetrieveRequest{}) + if err != nil { + return err + } + return renderWithDefault(app, run, output.FormatJSON) + }, + } } // newEditSchemaDoc returns the typed documentation for the diff --git a/internal/cli/edit_test.go b/internal/cli/edit_test.go index 9c64348..43359ea 100644 --- a/internal/cli/edit_test.go +++ b/internal/cli/edit_test.go @@ -361,7 +361,7 @@ func TestEdit_ProcessedButNoOutputFileEmitsWarning(t *testing.T) { t.Fatalf("runEdit: %v", err) } warn := ta.errOut.String() - for _, want := range []string{"warning", "edr_empty", "no filled PDF", "extend runs get edr_empty"} { + for _, want := range []string{"warning", "edr_empty", "no filled PDF", "extend edit runs get edr_empty"} { if !strings.Contains(warn, want) { t.Errorf("stderr warning missing %q:\n%s", want, warn) } diff --git a/internal/cli/extract.go b/internal/cli/extract.go index cd04395..ac41843 100644 --- a/internal/cli/extract.go +++ b/internal/cli/extract.go @@ -80,7 +80,7 @@ complete standalone config that replaces the need for one entirely. Examples: []Example{ {Label: "Basic", Cmd: "extend extract invoice.pdf --using ex_abc"}, {Label: "URL input", Cmd: "extend extract https://example.com/doc.pdf --using ex_abc"}, - {Label: "Async", Cmd: "extend extract file_xK9mLPq --using ex_abc --wait=false", Note: "Returns the run ID immediately; poll with `extend runs watch`."}, + {Label: "Async", Cmd: "extend extract file_xK9mLPq --using ex_abc --wait=false", Note: "Returns the run ID immediately; poll with `extend extract runs watch`."}, {Label: "Patch a saved extractor for this run", Cmd: "extend extract invoice.pdf --using ex_abc --patch tweaks.json"}, {Label: "Inline patch", Cmd: `extend extract invoice.pdf --using ex_abc --patch '{"foo":"bar"}'`}, {Label: "One-off run with no saved extractor", Cmd: "extend extract invoice.pdf --config inline-config.json"}, @@ -91,7 +91,7 @@ complete standalone config that replaces the need for one entirely. "Exactly one of --using or --config is required (server schema rejects both or neither).", "--patch requires --using; for a standalone config use --config instead.", }, - SeeAlso: []string{"parse", "classify", "extract batch", "runs watch", "runs get"}, + SeeAlso: []string{"parse", "classify", "extract batch", "extract runs watch", "extract runs get"}, Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, @@ -139,7 +139,7 @@ complete standalone config that replaces the need for one entirely. cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") meta.attach(cmd) }, - Subcommands: []*CommandDoc{newExtractBatchDoc(app)}, + Subcommands: []*CommandDoc{newExtractBatchDoc(app), extractRunsSpec().doc(app), extractBatchesSpec().doc(app)}, } } @@ -231,7 +231,7 @@ func runExtract(ctx context.Context, app *App, p extractParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend extract runs watch") } if err := renderWithDefault(app, final, output.FormatJSON); err != nil { diff --git a/internal/cli/extract_test.go b/internal/cli/extract_test.go index 1b26507..2499691 100644 --- a/internal/cli/extract_test.go +++ b/internal/cli/extract_test.go @@ -142,7 +142,7 @@ func TestExtract_WaitTimeoutSurfacesActionableMessage(t *testing.T) { t.Fatal("expected timeout error") } msg := err.Error() - for _, want := range []string{"exr_slow", "--timeout", "--wait=false", "extend runs watch exr_slow"} { + for _, want := range []string{"exr_slow", "--timeout", "--wait=false", "extend extract runs watch exr_slow"} { if !strings.Contains(msg, want) { t.Errorf("action wait timeout error missing %q: %s", want, msg) } diff --git a/internal/cli/parse.go b/internal/cli/parse.go index 1fadb33..053cafa 100644 --- a/internal/cli/parse.go +++ b/internal/cli/parse.go @@ -82,7 +82,7 @@ knobs in the same forms. "--jq cannot be combined with -o markdown; use -o json --jq and select the markdown chunk paths.", "Parse runs cannot be cancelled once submitted (unlike extract/classify/split).", }, - SeeAlso: []string{"extract", "classify", "parse batch", "runs watch", "runs get"}, + SeeAlso: []string{"extract", "classify", "parse batch", "parse runs watch", "parse runs get"}, Output: OutputSpec{TTY: OutputMarkdown, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, Failures: []extendx.RunStatus{extendx.StatusFailed}, @@ -122,7 +122,7 @@ knobs in the same forms. cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") meta.attach(cmd) }, - Subcommands: []*CommandDoc{newParseBatchDoc(app)}, + Subcommands: []*CommandDoc{newParseBatchDoc(app), parseRunsSpec().doc(app), parseBatchesSpec().doc(app)}, } } @@ -187,7 +187,7 @@ func runParse(ctx context.Context, app *App, p parseParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend parse runs watch") } if err := renderParseResult(app, final, p.target); err != nil { diff --git a/internal/cli/processors.go b/internal/cli/processors.go index ed0e728..008728c 100644 --- a/internal/cli/processors.go +++ b/internal/cli/processors.go @@ -439,7 +439,7 @@ processors: each deploy is a named snapshot rather than a major/minor bump. Pass --from-file with the API body (inline JSON, path, file:// URI, or - for stdin), or use --name to name the deployed version. -Once deployed, refer to the version by its name in extend run --version.` +Once deployed, refer to the version by its name in extend workflows run --version.` } return `Publish a new version of the draft. @@ -468,7 +468,7 @@ func versionCreateGotchas(noun string) []string { if noun == "workflow" { return []string{ "Workflows use named deploys; do not pass --release-type.", - "Once deployed, the name is how 'extend run --version' refers to the deploy.", + "Once deployed, the name is how 'extend workflows run --version' refers to the deploy.", } } return []string{ diff --git a/internal/cli/root.go b/internal/cli/root.go index c792247..f94f308 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -96,24 +96,39 @@ Environment variables: The --workspace, --region, and --http-timeout flags override their respective env vars.`, + // The named cases are top-level groups this CLI used to ship + // before run/batch inspection became typed per verb; callers + // with stale scripts or skills still invoke them, so the error + // names the replacement instead of a bare "unknown command". + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + switch args[0] { + case "run": + return errors.New(`unknown command "run" for "extend": workflow runs moved; use 'extend workflows run'`) + case "runs": + return errors.New(`unknown command "runs" for "extend": run inspection is typed per verb; use 'extend runs ...'`) + case "batches": + return errors.New(`unknown command "batches" for "extend": batch status is typed per verb; use 'extend batches ...'`) + } + return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath()) + }, Subcommands: []*CommandDoc{ // Actions newExtractDoc(app), newParseDoc(app), newClassifyDoc(app), newSplitDoc(app), - newRunDoc(app), newEditDoc(app), // Inspection - newRunsDoc(app), - newBatchesDoc(app), newFilesDoc(app), newDownloadDoc(app), // Resources extractorAccessor().doc(app), classifierAccessor().doc(app), splitterAccessor().doc(app), - workflowAccessor().doc(app), + newWorkflowsDoc(app), newWebhooksDoc(app), newEvaluationsDoc(app), // Agent surface @@ -532,7 +547,7 @@ func formatError(w io.Writer, pal palette, err error) { if errors.As(err, &urlErr) { if urlErr.Timeout() { fmt.Fprintf(w, "%s request timed out\n", pal.Red("Error:")) - fmt.Fprintf(w, " %s\n", pal.Dim("raise --http-timeout or EXTEND_HTTP_TIMEOUT; for long runs use --wait=false and poll with 'extend runs watch'")) + fmt.Fprintf(w, " %s\n", pal.Dim("raise --http-timeout or EXTEND_HTTP_TIMEOUT; for long runs use --wait=false and poll with 'extend runs watch'")) } else { fmt.Fprintf(w, "%s could not reach the Extend API\n", pal.Red("Error:")) fmt.Fprintf(w, " %s\n", pal.Dim("check connectivity, --region/--base-url, and that the API is reachable")) diff --git a/internal/cli/run.go b/internal/cli/run.go index dbb9003..118bc60 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -15,9 +15,10 @@ import ( "github.com/extend-hq/extend-cli/internal/output" ) -// newRunDoc returns the typed documentation for `extend run` (the -// workflow-run launcher) and its `extend run batch` subcommand. -func newRunDoc(app *App) *CommandDoc { +// newWorkflowsRunDoc returns the typed documentation for `extend +// workflows run` (the workflow-run launcher) and its `extend workflows +// run batch` subcommand. +func newWorkflowsRunDoc(app *App) *CommandDoc { var ( workflowID string version string @@ -35,7 +36,6 @@ func newRunDoc(app *App) *CommandDoc { return &CommandDoc{ Use: "run ", Summary: "Start a workflow run on a document", - Group: "Actions", Triggers: []string{ "start a workflow run on a document", "trigger an extend workflow on a pdf", @@ -71,12 +71,12 @@ output}; output is the same shape that processor would normally return --secret key=value provides per-run secrets that step actions can reference. Repeatable.`, Examples: []Example{ - {Label: "Basic", Cmd: "extend run invoice.pdf --using workflow_abc"}, - {Label: "Block until terminal", Cmd: "extend run invoice.pdf --using workflow_abc --wait"}, - {Label: "Pin version with priority", Cmd: "extend run invoice.pdf --using workflow_abc --version v2-with-review --priority 10"}, - {Label: "Seed processor outputs", Cmd: "extend run invoice.pdf --using workflow_abc --outputs seeded.json"}, - {Label: "Inline seeded outputs", Cmd: `extend run invoice.pdf --using workflow_abc --outputs '[{"processorId":"ex_abc","output":{"value":{}}}]`}, - {Label: "Pass a secret", Cmd: "extend run invoice.pdf --using workflow_abc --secret API_KEY=$KEY"}, + {Label: "Basic", Cmd: "extend workflows run invoice.pdf --using workflow_abc"}, + {Label: "Block until terminal", Cmd: "extend workflows run invoice.pdf --using workflow_abc --wait"}, + {Label: "Pin version with priority", Cmd: "extend workflows run invoice.pdf --using workflow_abc --version v2-with-review --priority 10"}, + {Label: "Seed processor outputs", Cmd: "extend workflows run invoice.pdf --using workflow_abc --outputs seeded.json"}, + {Label: "Inline seeded outputs", Cmd: `extend workflows run invoice.pdf --using workflow_abc --outputs '[{"processorId":"ex_abc","output":{"value":{}}}]`}, + {Label: "Pass a secret", Cmd: "extend workflows run invoice.pdf --using workflow_abc --secret API_KEY=$KEY"}, }, Gotchas: []string{ "Workflow runs are async by default (unlike extract/classify/split). Pass --wait to block.", @@ -84,7 +84,7 @@ Repeatable.`, "--outputs entries must match the shape of the processor they replace (extract/classify/split).", "--secret values are not echoed back; use them for per-run API keys, not as run metadata.", }, - SeeAlso: []string{"extract", "run batch", "runs watch", "runs get", "webhooks subscriptions create"}, + SeeAlso: []string{"extract", "workflows run batch", "workflows runs watch", "workflows runs get", "webhooks subscriptions create"}, Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileLong, DefaultsToWait: false}, Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled, extendx.StatusRejected}, @@ -218,7 +218,7 @@ func runWorkflow(ctx context.Context, app *App, p workflowParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend workflows runs watch") } if err := renderWorkflowResult(app, final); err != nil { diff --git a/internal/cli/runs.go b/internal/cli/runs.go index 7688494..e3c65b4 100644 --- a/internal/cli/runs.go +++ b/internal/cli/runs.go @@ -18,40 +18,378 @@ import ( "github.com/extend-hq/extend-cli/internal/output" ) -// newRunsDoc returns the typed documentation for `extend runs` (the -// inspect-and-follow group across all processor types) and its 6 leaves. -func newRunsDoc(app *App) *CommandDoc { +// runsGroupSpec parameterizes the generated `extend runs` +// subgroup for one run kind. Every action verb (extract, parse, +// classify, split, edit, workflows) attaches one of these; the +// capability flags control which leaves exist so each typed command +// carries exactly the operations and flags its kind supports. There +// is no cross-kind dispatch: the kind is fixed by the command path, +// and IDs are validated against it (extendx.ValidateRunID) so a +// pasted ID of the wrong type redirects to the right command. +type runsGroupSpec struct { + kind extendx.RunKind + exampleID string + // cancellable: extract/classify/split/workflow. Parse and edit + // runs have no cancel endpoint. + cancellable bool + // listable: everything except edit (the API has no LIST /edit_runs). + listable bool + // updatable: workflow runs only (rename + metadata patch). + updatable bool + // responseType: parse runs only (--response-type json|url on get). + responseType bool + // usingFlag names the processor-ID filter for list ("" = no + // --using flag; parse runs have no processor reference). + usingFlag string + // usingExample is a processor ID for list examples (ex_abc, ...). + usingExample string + // sourceFilters: --source/--source-id on list. Workflow runs have + // no source filters at the server. + sourceFilters bool + // sortable: --sort-by/--sort on list. Parse runs ignore both. + sortable bool + // watchFailures are the terminal statuses that gate non-zero exit + // for `runs watch --exit-status` (and the lifecycle annotation). + watchFailures []extendx.RunStatus + // watchGotchas are appended to the generated watch gotchas to + // document type-specific semantics (review pauses, non-cancellability). + watchGotchas []string +} + +func extractRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindExtract, + exampleID: "exr_xK9mLPq", + cancellable: true, + listable: true, + usingFlag: "extractor", + usingExample: "ex_abc", + sourceFilters: true, + sortable: true, + watchFailures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, + } +} + +func parseRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindParse, + exampleID: "pr_pJDa8iX", + listable: true, + responseType: true, + sourceFilters: true, + watchFailures: []extendx.RunStatus{extendx.StatusFailed}, + watchGotchas: []string{ + "Parse runs cannot be cancelled; a watched parse run only ends in PROCESSED or FAILED.", + }, + } +} + +func classifyRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindClassify, + exampleID: "clr_kMXkR", + cancellable: true, + listable: true, + usingFlag: "classifier", + usingExample: "cl_abc", + sourceFilters: true, + sortable: true, + watchFailures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, + } +} + +func splitRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindSplit, + exampleID: "splr_s8Yqw", + cancellable: true, + listable: true, + usingFlag: "splitter", + usingExample: "spl_abc", + sourceFilters: true, + sortable: true, + watchFailures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, + } +} + +func editRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindEdit, + exampleID: "edr_aB3xY", + watchFailures: []extendx.RunStatus{extendx.StatusFailed}, + watchGotchas: []string{ + "Edit runs cannot be cancelled; a watched edit run only ends in PROCESSED or FAILED.", + }, + } +} + +func workflowRunsSpec() runsGroupSpec { + return runsGroupSpec{ + kind: extendx.KindWorkflow, + exampleID: "workflow_run_abc", + cancellable: true, + listable: true, + updatable: true, + usingFlag: "workflow", + usingExample: "workflow_abc", + sortable: true, + watchFailures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled, extendx.StatusRejected}, + watchGotchas: []string{ + "NEEDS_REVIEW is terminal for watch: the run pauses for human review at the dashboard and the command returns.", + }, + } +} + +// name returns the kind as prose ("extract", "workflow", ...). +func (s runsGroupSpec) name() string { return string(s.kind) } + +// verb returns the owning command group ("extract", ..., "workflows"). +func (s runsGroupSpec) verb() string { return s.kind.Verb() } + +// path renders a command path under this spec's group, e.g. +// path("runs get") = "extract runs get". +func (s runsGroupSpec) path(rest string) string { return s.verb() + " " + rest } + +// doc returns the typed documentation tree for `extend runs`. +func (s runsGroupSpec) doc(app *App) *CommandDoc { + subs := []*CommandDoc{s.getDoc(app)} + if s.listable { + subs = append(subs, s.listDoc(app)) + } + subs = append(subs, s.watchDoc(app)) + if s.cancellable { + subs = append(subs, s.cancelDoc(app)) + } + subs = append(subs, s.deleteDoc(app)) + if s.updatable { + subs = append(subs, s.updateDoc(app)) + } return &CommandDoc{ Use: "runs", - Summary: "Inspect and follow runs across all processor types", - Group: "Inspection", - WhenToUse: `Use these commands to inspect, watch, list, update, cancel, or delete -runs by their opaque ID. The run type (extract/parse/classify/split/ -workflow/edit) is auto-detected from the ID prefix, so a single -'extend runs get' or 'extend runs watch' works across all kinds.`, - Details: `Operations on runs identified by their opaque ID. The run type is -auto-detected from the ID prefix (exr_, pr_, clr_, splr_, -workflow_run_, edr_).`, - Subcommands: []*CommandDoc{ - newRunsGetDoc(app), - newRunsWatchDoc(app), - newRunsListDoc(app), - newRunsCancelDoc(app), - newRunsDeleteDoc(app), - newRunsUpdateDoc(app), + Summary: fmt.Sprintf("Inspect and follow %s runs", s.name()), + WhenToUse: fmt.Sprintf(`Use these commands to operate on %s runs (%s...) by ID: fetch current +state, poll to a terminal state, %sor delete the record.`, + s.name(), extendx.RunIDPrefix(s.kind), s.optionalVerbsProse()), + Details: fmt.Sprintf(`Operations on %s runs identified by their %s ID. Each run type has its +own runs group; an ID with a different prefix is rejected with a +pointer to the owning command.`, s.name(), extendx.RunIDPrefix(s.kind)), + Subcommands: subs, + } +} + +// optionalVerbsProse lists the capability-dependent operations for the +// group WhenToUse sentence. +func (s runsGroupSpec) optionalVerbsProse() string { + var parts []string + if s.listable { + parts = append(parts, "list with filters") + } + if s.cancellable { + parts = append(parts, "cancel in-flight runs") + } + if s.updatable { + parts = append(parts, "update name/metadata") + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, ", ") + ", " +} + +func (s runsGroupSpec) getDoc(app *App) *CommandDoc { + var responseType string + details := fmt.Sprintf(`Fetches the %s run and renders it in the same per-type format as the +originating command.`, s.name()) + examples := []Example{ + {Label: "Basic", Cmd: fmt.Sprintf("extend %s %s", s.path("runs get"), s.exampleID)}, + {Label: "As JSON", Cmd: fmt.Sprintf("extend %s %s -o json", s.path("runs get"), s.exampleID)}, + } + gotchas := []string{ + fmt.Sprintf("This command never waits; use 'extend %s' for live polling.", s.path("runs watch")), + } + if s.responseType { + details += ` + +--response-type url returns a presigned URL to the parsed output +instead of the inline payload (useful for large documents).` + examples = append(examples, Example{Label: "URL response", Cmd: fmt.Sprintf("extend %s %s --response-type url -o json", s.path("runs get"), s.exampleID)}) + } + return &CommandDoc{ + Use: "get ", + Summary: fmt.Sprintf("Fetch a single %s run by ID", s.name()), + Triggers: []string{ + fmt.Sprintf("fetch %s %s run by id", articleFor(s.name()), s.name()), + fmt.Sprintf("get the current status of %s %s run", articleFor(s.name()), s.name()), + fmt.Sprintf("check whether %s %s run completed", articleFor(s.name()), s.name()), + }, + WhenToUse: fmt.Sprintf(`Use to retrieve the current state of %s %s run by its ID. Unlike the +action command, this never waits or polls; it returns whatever state the +run is currently in. To wait for a terminal state, use 'extend %s'.`, + articleFor(s.name()), s.name(), s.path("runs watch")), + Details: details, + Examples: examples, + Gotchas: gotchas, + SeeAlso: s.seeAlso("get"), + Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTypedRunsGet(cmd.Context(), app, s.kind, args[0], responseType) + }, + Configure: func(cmd *cobra.Command) { + if s.responseType { + cmd.Flags().StringVar(&responseType, "response-type", "", "Output payload shape: json|url (url returns a presigned link to the parsed output)") + } }, } } -func newRunsUpdateDoc(app *App) *CommandDoc { +func (s runsGroupSpec) watchDoc(app *App) *CommandDoc { + var ( + timeout time.Duration + exitStatus bool + ) + gotchas := append([]string{ + "Without --exit-status, the command exits 0 on any successful poll regardless of run status.", + }, s.watchGotchas...) + return &CommandDoc{ + Use: "watch ", + Summary: fmt.Sprintf("Poll %s %s run until it reaches a terminal state", articleFor(s.name()), s.name()), + Triggers: []string{ + fmt.Sprintf("watch %s %s run until it finishes", articleFor(s.name()), s.name()), + fmt.Sprintf("poll %s %s run for terminal state", articleFor(s.name()), s.name()), + fmt.Sprintf("follow %s run progress live", s.name()), + }, + WhenToUse: fmt.Sprintf(`Use to block until %s %s run reaches a terminal state. Combine with +--exit-status to gate downstream scripts on success.`, articleFor(s.name()), s.name()), + Details: fmt.Sprintf(`Block until the run reaches a terminal state, showing a spinner with +status transitions. The final result is rendered using the same format +as the originating command. + +Use --exit-status for shell composition: the command exits non-zero if +the run terminates in %s, so: + + extend %s --exit-status && downstream-script.sh + +works as expected.`, statusListProse(s.watchFailures), s.path("runs watch")), + Examples: []Example{ + {Label: "Basic", Cmd: fmt.Sprintf("extend %s %s", s.path("runs watch"), s.exampleID)}, + {Label: "Custom timeout", Cmd: fmt.Sprintf("extend %s %s --timeout 5m", s.path("runs watch"), s.exampleID)}, + {Label: "Gate downstream script", Cmd: fmt.Sprintf("extend %s %s --exit-status && deploy.sh", s.path("runs watch"), s.exampleID)}, + }, + Gotchas: gotchas, + SeeAlso: s.seeAlso("watch"), + Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, + Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, + Failures: s.watchFailures, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTypedRunsWatch(cmd.Context(), app, s, args[0], timeout, exitStatus) + }, + Configure: func(cmd *cobra.Command) { + cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") + cmd.Flags().BoolVar(&exitStatus, "exit-status", false, "Exit non-zero when the run terminates in "+statusListProse(s.watchFailures)) + }, + } +} + +func (s runsGroupSpec) cancelDoc(app *App) *CommandDoc { + var yes bool + return &CommandDoc{ + Use: "cancel ", + Summary: fmt.Sprintf("Cancel %s %s run by ID", articleFor(s.name()), s.name()), + Triggers: []string{ + fmt.Sprintf("cancel an in-flight %s run", s.name()), + fmt.Sprintf("stop a running %s run", s.name()), + fmt.Sprintf("abort a non-terminal %s run", s.name()), + }, + WhenToUse: fmt.Sprintf(`Use to attempt to cancel a non-terminal %s run.`, s.name()), + Details: fmt.Sprintf(`Cancellation is best-effort: an in-flight run may still complete before +the cancellation takes effect. The terminal status will be CANCELLED if +cancellation succeeded, or the original outcome (PROCESSED/FAILED/etc.) +if the run finished first. + +Cancel stops a running operation; it does not remove the historical +record. Use 'extend %s' for that.`, s.path("runs delete")), + Examples: []Example{ + {Label: "With prompt", Cmd: fmt.Sprintf("extend %s %s", s.path("runs cancel"), s.exampleID)}, + {Label: "Skip confirmation", Cmd: fmt.Sprintf("extend %s %s --yes", s.path("runs cancel"), s.exampleID)}, + }, + Gotchas: []string{ + "Cancellation is best-effort; an in-flight run may complete first.", + fmt.Sprintf("Cancel does not delete the run record; use 'extend %s' to remove the history.", s.path("runs delete")), + }, + SeeAlso: s.seeAlso("cancel"), + Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runTypedRunsCancel(cmd.Context(), app, s.kind, args[0], yes) + }, + Configure: func(cmd *cobra.Command) { + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + }, + } +} + +func (s runsGroupSpec) deleteDoc(app *App) *CommandDoc { + var yes bool + stopHint := "" + if s.cancellable { + stopHint = fmt.Sprintf(" To stop a still-running operation, use 'extend %s' instead.", s.path("runs cancel")) + } + return &CommandDoc{ + Use: "delete ", + Summary: fmt.Sprintf("Delete %s %s run record", articleFor(s.name()), s.name()), + Triggers: []string{ + fmt.Sprintf("delete %s %s run record", articleFor(s.name()), s.name()), + fmt.Sprintf("remove %s %s run from workspace history", articleFor(s.name()), s.name()), + fmt.Sprintf("clean up old %s runs", s.name()), + }, + WhenToUse: fmt.Sprintf(`Use to permanently remove %s %s run's historical record once it has +reached a terminal state.%s`, articleFor(s.name()), s.name(), stopHint), + Details: `Deletion is permanent and the record cannot be recovered. Use this to +clean up runs from the workspace inventory; it does not affect billing.`, + Examples: []Example{ + {Label: "With prompt", Cmd: fmt.Sprintf("extend %s %s", s.path("runs delete"), s.exampleID)}, + {Label: "Skip confirmation", Cmd: fmt.Sprintf("extend %s %s --yes", s.path("runs delete"), s.exampleID)}, + }, + Gotchas: []string{ + "Deletion is permanent; the record cannot be recovered.", + "Deletion does not affect billing or already-emitted webhook events.", + }, + SeeAlso: s.seeAlso("delete"), + Output: OutputSpec{TTY: OutputNone, Pipe: OutputNone}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + id := args[0] + if err := extendx.ValidateRunID(s.kind, id, "delete"); err != nil { + return err + } + return deleteWithConfirm(cmd.Context(), app, "run", id, yes, + func(ctx context.Context, id string) error { + c, err := app.NewClient() + if err != nil { + return err + } + return deleteRun(ctx, c, s.kind, id) + }) + }, + Configure: func(cmd *cobra.Command) { + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + }, + } +} + +// updateDoc is workflow-only: workflow runs are the single kind with a +// PATCH endpoint (rename + metadata). +func (s runsGroupSpec) updateDoc(app *App) *CommandDoc { var ( fromFile string name string meta metaFlags ) return &CommandDoc{ - Use: "update ", - Summary: "Update workflow run name and metadata (workflow runs only)", + Use: "update ", + Summary: "Update workflow run name and metadata", Triggers: []string{ "update metadata on a workflow run", "rename a workflow run", @@ -59,33 +397,27 @@ func newRunsUpdateDoc(app *App) *CommandDoc { "patch a workflow run's metadata", }, WhenToUse: `Use to rename a workflow run or attach/modify its metadata, in-flight or -completed. Only workflow runs (workflow_run_...) support this; other run -types do not.`, +completed. Workflow runs are the only run type with an update endpoint.`, Details: `Provide a JSON body with --from-file (inline JSON, path, file:// URI, or - for stdin; overrides everything), or set fields individually with --name (rename the run), --metadata, and --tag.`, Examples: []Example{ - {Label: "Rename", Cmd: `extend runs update workflow_run_abc --name "Q3 reprocess"`}, - {Label: "Add metadata + tag", Cmd: "extend runs update workflow_run_abc --metadata customer=acme --tag prod"}, - {Label: "From patch file", Cmd: "extend runs update workflow_run_abc --from-file patch.json"}, - {Label: "Inline patch", Cmd: `extend runs update workflow_run_abc --from-file '{"metadata":{"customer":"acme"}}'`}, + {Label: "Rename", Cmd: `extend workflows runs update workflow_run_abc --name "Q3 reprocess"`}, + {Label: "Add metadata + tag", Cmd: "extend workflows runs update workflow_run_abc --metadata customer=acme --tag prod"}, + {Label: "From patch file", Cmd: "extend workflows runs update workflow_run_abc --from-file patch.json"}, + {Label: "Inline patch", Cmd: `extend workflows runs update workflow_run_abc --from-file '{"metadata":{"customer":"acme"}}'`}, }, Gotchas: []string{ - "Only workflow runs support updates; the command rejects other run types.", "--from-file overrides --name/--metadata/--tag if both are passed.", "Pass at least one of --from-file, --name, --metadata, or --tag; otherwise the command rejects with 'nothing to update'.", }, - SeeAlso: []string{"runs get", "runs list"}, + SeeAlso: s.seeAlso("update"), Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := args[0] - kind, ok := extendx.RunKindFromID(id) - if !ok { - return fmt.Errorf("cannot determine run type from id %q", id) - } - if kind != extendx.KindWorkflow { - return fmt.Errorf("only workflow runs (workflow_run_...) support metadata updates; got %s run", kind) + if err := extendx.ValidateRunID(extendx.KindWorkflow, id, "update"); err != nil { + return err } cli, err := app.NewClient() if err != nil { @@ -129,113 +461,56 @@ types do not.`, } } -func newRunsGetDoc(app *App) *CommandDoc { - var responseType string - return &CommandDoc{ - Use: "get ", - Summary: "Fetch a single run by ID", - Triggers: []string{ - "fetch a run by id", - "get the current status of a run", - "inspect an extract or workflow run", - "check whether a run completed", - }, - WhenToUse: `Use to retrieve the current state of a run by its ID. Unlike action -commands, this never waits or polls; it returns whatever state the run -is currently in. To wait for a terminal state, use 'extend runs watch'.`, - Details: `The run type is auto-detected from the ID prefix (exr_ extract, pr_ -parse, clr_ classify, splr_ split, workflow_run_, edr_ edit), so a -single 'extend runs get' call works across all kinds. - -For parse runs, --response-type url returns a presigned URL to the -parsed output instead of the inline payload (useful for large documents).`, - Examples: []Example{ - {Label: "Extract run", Cmd: "extend runs get exr_xK9mLPq"}, - {Label: "Parse run as YAML", Cmd: "extend runs get pr_pJDa8iX -o yaml"}, - {Label: "Parse run, URL response", Cmd: "extend runs get pr_pJDa8iX --response-type url -o json"}, - {Label: "Just classify confidence", Cmd: "extend runs get clr_kMXk --jq '.output.confidence' -o raw"}, - }, - Gotchas: []string{ - "--response-type only applies to parse runs; the command rejects it for other types.", - "This command never waits; use 'extend runs watch' for live polling.", - }, - SeeAlso: []string{"runs watch", "runs list", "runs cancel", "runs delete"}, - Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runRunsGet(cmd.Context(), app, args[0], responseType) - }, - Configure: func(cmd *cobra.Command) { - cmd.Flags().StringVar(&responseType, "response-type", "", "Parse runs only: json|url output payload shape") - }, +// seeAlso builds the sibling cross-references for one leaf, excluding +// itself and filtered to the capabilities this kind actually has. +func (s runsGroupSpec) seeAlso(self string) []string { + var out []string + add := func(action string) { + if action != self { + out = append(out, s.path("runs "+action)) + } + } + add("get") + add("watch") + if s.listable { + add("list") + } + if s.cancellable { + add("cancel") } + add("delete") + return out } -func newRunsWatchDoc(app *App) *CommandDoc { - var ( - timeout time.Duration - exitStatus bool - ) - return &CommandDoc{ - Use: "watch ", - Summary: "Poll a run until it reaches a terminal state", - Triggers: []string{ - "watch a run until it finishes", - "poll a run for terminal state", - "block until extract or workflow run completes", - "follow run progress live", - }, - WhenToUse: `Use to block until a run reaches a terminal state. Combine with ---exit-status to gate downstream scripts on success.`, - Details: `Block until the run reaches a terminal state, showing a spinner with -status transitions. The final result is rendered using the same per-type -natural format as the originating command. - -Use --exit-status for shell composition: the command exits non-zero if -the run terminates in FAILED or CANCELLED state, so: - - extend runs watch --exit-status && downstream-script.sh - -works as expected.`, - Examples: []Example{ - {Label: "Basic", Cmd: "extend runs watch exr_xK9mLPq"}, - {Label: "Custom timeout", Cmd: "extend runs watch pr_pJDa8iX --timeout 5m"}, - {Label: "Gate downstream script", Cmd: "extend runs watch clr_kMXk --exit-status && deploy.sh"}, - }, - Gotchas: []string{ - "Without --exit-status, the command exits 0 on any successful poll regardless of run status.", - "Watching uses the short polling profile uniformly, even for workflow runs (live progress is the explicit ask).", - }, - SeeAlso: []string{"runs get", "runs list", "batches watch"}, - Output: OutputSpec{TTY: OutputPretty, Pipe: OutputJSON}, - Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, - Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runRunsWatch(cmd.Context(), app, args[0], timeout, exitStatus) - }, - Configure: func(cmd *cobra.Command) { - cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") - cmd.Flags().BoolVar(&exitStatus, "exit-status", false, "Exit non-zero on FAILED or CANCELLED") - }, +// statusListProse renders a status slice as "FAILED or CANCELLED" / +// "FAILED, CANCELLED, or REJECTED" for help prose. +func statusListProse(statuses []extendx.RunStatus) string { + names := make([]string, len(statuses)) + for i, s := range statuses { + names[i] = string(s) + } + switch len(names) { + case 0: + return "" + case 1: + return names[0] + case 2: + return names[0] + " or " + names[1] } + return strings.Join(names[:len(names)-1], ", ") + ", or " + names[len(names)-1] } -func runRunsGet(ctx context.Context, app *App, id, responseType string) error { - cli, err := app.NewClient() - if err != nil { +func runTypedRunsGet(ctx context.Context, app *App, kind extendx.RunKind, id, responseType string) error { + if err := extendx.ValidateRunID(kind, id, "get"); err != nil { return err } - kind, ok := extendx.RunKindFromID(id) - if !ok { - return fmt.Errorf("cannot determine run type from id %q (expected exr_/pr_/clr_/splr_/workflow_run_/edr_ prefix)", id) - } - if responseType != "" && kind != extendx.KindParse { - return fmt.Errorf("--response-type is only supported for parse runs (pr_...); got %s run", kind) - } if responseType != "" && responseType != "json" && responseType != "url" { return fmt.Errorf("--response-type must be one of: json|url") } + cli, err := app.NewClient() + if err != nil { + return err + } switch kind { case extendx.KindExtract: run, err := cli.ExtractRuns.Retrieve(ctx, id, &extend.ExtractRunsRetrieveRequest{}) @@ -286,32 +561,32 @@ func runRunsGet(ctx context.Context, app *App, id, responseType string) error { } } -func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duration, exitStatus bool) error { +func runTypedRunsWatch(ctx context.Context, app *App, s runsGroupSpec, id string, timeout time.Duration, exitStatus bool) error { + if err := extendx.ValidateRunID(s.kind, id, "watch"); err != nil { + return err + } cli, err := app.NewClient() if err != nil { return err } - kind, ok := extendx.RunKindFromID(id) - if !ok { - return fmt.Errorf("cannot determine run type from id %q (expected exr_/pr_/clr_/splr_/workflow_run_/edr_ prefix)", id) - } sp := app.IO.StartSpinner(fmt.Sprintf("Watching %s...", id)) // Watching uses the short profile uniformly, even for workflow // runs: users invoking `runs watch` are explicitly asking for live // progress and expect responsive updates. opts := extendx.WaitProfileOptions(extendx.ProfileShort, timeout) + watchCmd := "extend " + s.path("runs watch") var status extendx.RunStatus var renderErr error - switch kind { + switch s.kind { case extendx.KindExtract: final, err := waitForExtractRun(ctx, cli, id, opts, func(r *extend.ExtractRun) { sp.Update(fmt.Sprintf("Run %s: %s", r.ID, r.Status)) }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderWithDefault(app, final, output.FormatJSON) @@ -321,7 +596,7 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderParseResult(app, final, "markdown") @@ -331,7 +606,7 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderClassifyResult(app, final) @@ -341,7 +616,7 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderSplitResult(app, final) @@ -351,7 +626,7 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderWorkflowResult(app, final) @@ -361,13 +636,13 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio }) sp.Stop("") if err != nil { - return formatWatchWaitError(err, id) + return formatWatchWaitError(err, id, watchCmd) } status = extendx.RunStatus(final.Status) renderErr = renderEditResult(app, final) default: sp.Stop("") - return fmt.Errorf("unsupported run kind %s", kind) + return fmt.Errorf("unsupported run kind %s", s.kind) } if renderErr != nil { @@ -379,6 +654,8 @@ func runRunsWatch(ctx context.Context, app *App, id string, timeout time.Duratio return fmt.Errorf("run %s failed", id) case extendx.StatusCancelled: return fmt.Errorf("run %s was cancelled", id) + case extendx.StatusRejected: + return fmt.Errorf("run %s was rejected", id) } } return nil @@ -427,108 +704,10 @@ func relTimeFromISO(iso string) string { return relTime(t) } -func newRunsCancelDoc(app *App) *CommandDoc { - var yes bool - return &CommandDoc{ - Use: "cancel ", - Summary: "Cancel a run by ID", - Triggers: []string{ - "cancel an in-flight run", - "stop a running extract or workflow run", - "abort a non-terminal run", - }, - WhenToUse: `Use to attempt to cancel a non-terminal run. The run type is determined -from the ID prefix.`, - Details: `Parse runs cannot be cancelled (the API rejects the attempt). - -Cancellation is best-effort: an in-flight run may still complete before -the cancellation takes effect. The terminal status will be CANCELLED if -cancellation succeeded, or the original outcome (PROCESSED/FAILED/etc.) -if the run finished first. - -Cancel stops a running operation; it does not remove the historical -record. Use 'extend runs delete' for that.`, - Examples: []Example{ - {Label: "With prompt", Cmd: "extend runs cancel exr_xK9"}, - {Label: "Skip confirmation", Cmd: "extend runs cancel workflow_run_abc --yes"}, - }, - Gotchas: []string{ - "Parse runs cannot be cancelled (API rejects).", - "Cancellation is best-effort; an in-flight run may complete first.", - "Cancel does not delete the run record; use 'extend runs delete' to remove the history.", - }, - SeeAlso: []string{"runs get", "runs delete", "runs watch"}, - Output: OutputSpec{TTY: OutputJSON, Pipe: OutputJSON}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runRunsCancel(cmd.Context(), app, args[0], yes) - }, - Configure: func(cmd *cobra.Command) { - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - }, - } -} - -func newRunsDeleteDoc(app *App) *CommandDoc { - var yes bool - return &CommandDoc{ - Use: "delete ", - Summary: "Delete a run record (any run type)", - Triggers: []string{ - "delete a run record", - "remove a run from workspace history", - "clean up old runs", - }, - WhenToUse: `Use to permanently remove a run's historical record once it has -reached a terminal state. To stop a still-running operation, use -'extend runs cancel' instead.`, - Details: `The run type is auto-detected from the ID prefix -(exr_/pr_/clr_/splr_/edr_/workflow_run_). Deletion is permanent and the -record cannot be recovered. Use this to clean up runs from the workspace -inventory; it does not affect billing.`, - Examples: []Example{ - {Label: "With prompt", Cmd: "extend runs delete exr_xK9"}, - {Label: "Skip confirmation", Cmd: "extend runs delete pr_abc --yes"}, - }, - Gotchas: []string{ - "Deletion is permanent; the record cannot be recovered.", - "Deletion does not affect billing or already-emitted webhook events.", - }, - SeeAlso: []string{"runs get", "runs cancel", "runs list"}, - Output: OutputSpec{TTY: OutputNone, Pipe: OutputNone}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runRunsDelete(cmd.Context(), app, args[0], yes) - }, - Configure: func(cmd *cobra.Command) { - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - }, - } -} - -func runRunsDelete(ctx context.Context, app *App, id string, yes bool) error { - if _, ok := extendx.RunKindFromID(id); !ok { - return fmt.Errorf("cannot determine run type from id %q", id) - } - return deleteWithConfirm(ctx, app, "run", id, yes, - func(ctx context.Context, id string) error { - c, err := app.NewClient() - if err != nil { - return err - } - return deleteRun(ctx, c, id) - }) -} - -// deleteRun dispatches to the right per-kind delete endpoint on the -// SDK client based on the run ID's prefix. Centralized so both -// `extend runs delete` and any other generic deleter can share the -// dispatch. -func deleteRun(ctx context.Context, c *sdkclient.Client, id string) error { - kind, ok := extendx.RunKindFromID(id) - if !ok { - return fmt.Errorf("unknown run id prefix: %s", id) - } +// deleteRun calls the per-kind delete endpoint on the SDK client. The +// kind comes from the invoked typed command; the ID has already been +// validated against it. +func deleteRun(ctx context.Context, c *sdkclient.Client, kind extendx.RunKind, id string) error { switch kind { case extendx.KindExtract: _, err := c.ExtractRuns.Delete(ctx, id, &extend.ExtractRunsDeleteRequest{}) @@ -553,14 +732,10 @@ func deleteRun(ctx context.Context, c *sdkclient.Client, id string) error { } } -// cancelRun dispatches a cancel call to the right per-kind endpoint -// based on the run ID's prefix. Parse and edit runs return -// extendx.ErrNotCancellable. -func cancelRun(ctx context.Context, c *sdkclient.Client, id string) error { - kind, ok := extendx.RunKindFromID(id) - if !ok { - return fmt.Errorf("unknown run id prefix: %s", id) - } +// cancelRun calls the per-kind cancel endpoint. Only kinds with a +// cancel endpoint have a cancel command, so parse/edit never reach +// this switch. +func cancelRun(ctx context.Context, c *sdkclient.Client, kind extendx.RunKind, id string) error { switch kind { case extendx.KindExtract: _, err := c.ExtractRuns.Cancel(ctx, id, &extend.ExtractRunsCancelRequest{}) @@ -574,19 +749,17 @@ func cancelRun(ctx context.Context, c *sdkclient.Client, id string) error { case extendx.KindWorkflow: _, err := c.WorkflowRuns.Cancel(ctx, id, &extend.WorkflowRunsCancelRequest{}) return err - case extendx.KindParse, extendx.KindEdit: - return fmt.Errorf("%s runs are not cancellable: %w", kind, extendx.ErrNotCancellable) default: return fmt.Errorf("unsupported run kind: %s", kind) } } -func runRunsCancel(ctx context.Context, app *App, id string, yes bool) error { - cli, err := app.NewClient() - if err != nil { +func runTypedRunsCancel(ctx context.Context, app *App, kind extendx.RunKind, id string, yes bool) error { + if err := extendx.ValidateRunID(kind, id, "cancel"); err != nil { return err } - if err := extendx.CanCancel(id); err != nil { + cli, err := app.NewClient() + if err != nil { return err } @@ -604,7 +777,7 @@ func runRunsCancel(ctx context.Context, app *App, id string, yes bool) error { } } - if err := cancelRun(ctx, cli, id); err != nil { + if err := cancelRun(ctx, cli, kind, id); err != nil { return err } fmt.Fprintf(app.IO.ErrOut, "%s Cancelled %s\n", paletteFor(app.IO).Green("✓"), id) diff --git a/internal/cli/runs_list.go b/internal/cli/runs_list.go index 8a1ae9c..b4881d8 100644 --- a/internal/cli/runs_list.go +++ b/internal/cli/runs_list.go @@ -14,14 +14,14 @@ import ( "github.com/extend-hq/extend-cli/internal/extendx" ) -// newRunsListDoc returns the typed documentation for `extend runs list`. -// Pulled out of runs.go because the listing path (per-kind page funcs, -// the cross-kind common-options parser, and the summary-row formatters) -// is half of the file by line count and stands alone from the rest of -// the runs subcommand surface. -func newRunsListDoc(app *App) *CommandDoc { +// listDoc returns the typed documentation for `extend runs list`. +// Lives in runs_list.go with the per-kind page funcs, the cross-kind +// common-options parser, and the summary-row formatters that implement +// it. Only listable kinds attach this leaf (edit runs have no list +// endpoint), and each kind registers exactly the filter flags its +// endpoint supports. +func (s runsGroupSpec) listDoc(app *App) *CommandDoc { var ( - runType string status string using string batchID string @@ -35,44 +35,60 @@ func newRunsListDoc(app *App) *CommandDoc { sortBy string sortDir string ) + batchExample := "bpr_xK9mLPq" + if s.kind == extendx.KindParse { + batchExample = "bpar_xK9mLPq" + } else if s.kind == extendx.KindWorkflow { + batchExample = "batch_xK9mLPq" + } + examples := []Example{ + {Label: "First page", Cmd: fmt.Sprintf("extend %s", s.path("runs list"))}, + {Label: "Filter by status", Cmd: fmt.Sprintf("extend %s --status PROCESSED", s.path("runs list"))}, + {Label: "Runs in a batch", Cmd: fmt.Sprintf("extend %s --batch %s", s.path("runs list"), batchExample)}, + {Label: "Next page", Cmd: fmt.Sprintf("extend %s --page-token ", s.path("runs list"))}, + } + if s.usingFlag != "" { + examples = append(examples, Example{ + Label: "Filter by " + s.usingFlag, + Cmd: fmt.Sprintf("extend %s --using %s", s.path("runs list"), s.usingExample), + }) + } + if s.sourceFilters { + examples = append(examples, Example{ + Label: "Runs spawned by a workflow", + Cmd: fmt.Sprintf("extend %s --source WORKFLOW_RUN --source-id workflow_run_x", s.path("runs list")), + }) + } + if s.sortable { + examples = append(examples, Example{ + Label: "Custom sort", + Cmd: fmt.Sprintf("extend %s --sort-by updatedAt --sort asc", s.path("runs list")), + }) + } return &CommandDoc{ Use: "list", - Summary: "List runs of a given processor type", + Summary: fmt.Sprintf("List %s runs with filters", s.name()), Triggers: []string{ - "list runs by processor type", - "find recent runs of a workflow", - "page through extract runs", - "see runs in a batch", - "filter runs by status or processor", + fmt.Sprintf("list %s runs in the workspace", s.name()), + fmt.Sprintf("page through %s runs", s.name()), + fmt.Sprintf("filter %s runs by status", s.name()), + fmt.Sprintf("see %s runs in a batch", s.name()), }, - WhenToUse: `Use to enumerate runs of a single type with rich filtering. Pass ---type extract|parse|classify|split|workflow (edit is not listable; use -'extend runs get' for individual edit runs).`, - Details: `Most filter flags map directly to documented query parameters on the -run-list endpoints; the wire shape varies slightly by type (e.g. parse -runs ignore --using, --sort-by, and --sort; workflow runs ignore ---source and --source-id). + WhenToUse: fmt.Sprintf(`Use to enumerate %s runs with filtering by status, batch, and file +name. The ID column feeds 'extend %s '.`, s.name(), s.path("runs get")), + Details: `Filter flags map directly to documented query parameters on the +run-list endpoint. ` + paginationGuidance, - Examples: []Example{ - {Label: "All extract runs", Cmd: "extend runs list --type extract"}, - {Label: "Filter by status + processor", Cmd: "extend runs list --type extract --using ex_abc --status PROCESSED"}, - {Label: "Workflow runs by file name", Cmd: "extend runs list --type workflow --using workflow_abc --file-name invoice"}, - {Label: "Runs spawned by a workflow", Cmd: "extend runs list --type extract --source WORKFLOW_RUN --source-id workflow_run_x"}, - {Label: "Runs in a batch", Cmd: "extend runs list --type extract --batch bpr_xK9mLPq"}, - {Label: "Next page", Cmd: "extend runs list --type extract --page-token "}, - {Label: "Custom sort", Cmd: "extend runs list --type extract --sort-by updatedAt --sort asc"}, - }, - Gotchas: []string{ - "--type is required.", - "Edit runs are not listable; use 'extend runs get edr_...' for individual edit runs.", - "Parse runs ignore --using, --sort-by, and --sort; workflow runs ignore --source and --source-id.", - }, - SeeAlso: []string{"runs get", "runs watch", "batches get"}, - Output: OutputSpec{TTY: OutputTable, Pipe: OutputJSON}, + Examples: examples, + SeeAlso: s.seeAlso("list"), + Output: OutputSpec{TTY: OutputTable, Pipe: OutputJSON}, RunE: func(cmd *cobra.Command, args []string) error { - return runRunsList(cmd, app, runsListParams{ - runType: runType, + cli, err := app.NewClient() + if err != nil { + return err + } + rows, pages, err := collectListRows(cmd.Context(), cli, s.kind, runsListParams{ status: status, using: using, batchID: batchID, @@ -86,28 +102,43 @@ runs ignore --using, --sort-by, and --sort; workflow runs ignore sortBy: sortBy, sortDir: sortDir, }) + if err != nil { + return err + } + return renderListForCmd(cmd, app, pages, []string{"id", "status", "processor", "created"}, rows, "No runs.") }, Configure: func(cmd *cobra.Command) { - cmd.Flags().StringVar(&runType, "type", "", "Run type: extract|parse|classify|split|workflow (edit is not listable; use 'extend runs get')") - cmd.Flags().StringVar(&status, "status", "", "Filter by status (varies by type; workflow also supports NEEDS_REVIEW|REJECTED|CANCELLING; parse excludes CANCELLED)") - cmd.Flags().StringVar(&using, "using", "", "Filter by processor ID (ex_/cl_/spl_/workflow_; ignored for parse)") - cmd.Flags().StringVar(&batchID, "batch", "", "Filter by batch run ID (bpr_..., or bpar_... for parse)") - cmd.Flags().StringVar(&source, "source", "", "Filter by run source: API|STUDIO|WORKFLOW_RUN|ADMIN|... (ignored for workflow)") - cmd.Flags().StringVar(&sourceID, "source-id", "", "Filter by source resource ID, e.g. workflow_run_xxx (ignored for workflow)") + statusHelp := "Filter by status: PENDING|PROCESSING|PROCESSED|FAILED|CANCELLED" + switch s.kind { + case extendx.KindParse: + statusHelp = "Filter by status: PENDING|PROCESSING|PROCESSED|FAILED" + case extendx.KindWorkflow: + statusHelp = "Filter by status: PENDING|PROCESSING|PROCESSED|FAILED|CANCELLED|NEEDS_REVIEW|REJECTED|CANCELLING" + } + cmd.Flags().StringVar(&status, "status", "", statusHelp) + if s.usingFlag != "" { + cmd.Flags().StringVar(&using, "using", "", fmt.Sprintf("Filter by %s ID (%s...)", s.usingFlag, s.usingExample[:strings.Index(s.usingExample, "_")+1])) + } + batchHelp := "Filter by batch run ID (" + batchExample[:strings.Index(batchExample, "_")+1] + "...)" + cmd.Flags().StringVar(&batchID, "batch", "", batchHelp) + if s.sourceFilters { + cmd.Flags().StringVar(&source, "source", "", "Filter by run source: API|STUDIO|WORKFLOW_RUN|ADMIN|...") + cmd.Flags().StringVar(&sourceID, "source-id", "", "Filter by source resource ID, e.g. workflow_run_xxx") + } cmd.Flags().StringVar(&fileName, "file-name", "", "Filter to runs whose file name contains this substring") cmd.Flags().IntVar(&limit, "limit", 20, "Page size used in each API request (advanced)") cmd.Flags().IntVar(&maxN, "max", 0, "Stop after at most N total results, auto-paginating internally (0 = single page, the default)") cmd.Flags().StringVar(&pageToken, "page-token", "", "Resume from a specific page (cursor from a previous response; advanced — prefer --max)") cmd.Flags().BoolVar(&all, "all", false, "Fetch every page (use --max for a bounded fetch)") - cmd.Flags().StringVar(&sortBy, "sort-by", "", "Sort by: updatedAt|createdAt (server default: updatedAt; ignored for parse)") - cmd.Flags().StringVar(&sortDir, "sort", "desc", "Sort direction: asc|desc (ignored for parse)") - _ = cmd.MarkFlagRequired("type") + if s.sortable { + cmd.Flags().StringVar(&sortBy, "sort-by", "", "Sort by: updatedAt|createdAt (server default: updatedAt)") + cmd.Flags().StringVar(&sortDir, "sort", "desc", "Sort direction: asc|desc") + } }, } } type runsListParams struct { - runType string status string using string batchID string @@ -122,43 +153,6 @@ type runsListParams struct { sortDir string } -func runRunsList(cmd *cobra.Command, app *App, p runsListParams) error { - ctx := cmd.Context() - cli, err := app.NewClient() - if err != nil { - return err - } - kind, err := parseRunKind(p.runType) - if err != nil { - return err - } - - rows, pages, err := collectListRows(ctx, cli, kind, p) - if err != nil { - return err - } - - return renderListForCmd(cmd, app, pages, []string{"id", "status", "processor", "created"}, rows, "No runs.") -} - -func parseRunKind(s string) (extendx.RunKind, error) { - switch strings.ToLower(s) { - case "extract": - return extendx.KindExtract, nil - case "parse": - return extendx.KindParse, nil - case "classify": - return extendx.KindClassify, nil - case "split": - return extendx.KindSplit, nil - case "workflow": - return extendx.KindWorkflow, nil - case "edit": - return extendx.KindEdit, nil - } - return "", fmt.Errorf("unknown run type %q (want extract|parse|classify|split|workflow|edit)", s) -} - func collectListRows(ctx context.Context, cli *sdkclient.Client, kind extendx.RunKind, p runsListParams) ([][]string, []any, error) { var rows [][]string var rawPages []any @@ -182,7 +176,7 @@ func collectListRows(ctx context.Context, cli *sdkclient.Client, kind extendx.Ru case extendx.KindWorkflow: pageRows, page, nextToken, err = listWorkflowPage(ctx, cli, p, pageToken) case extendx.KindEdit: - return nil, nil, fmt.Errorf("listing edit runs is not supported by the API; use 'extend runs get edr_...' for individual edit runs") + return nil, nil, fmt.Errorf("listing edit runs is not supported by the API; use 'extend edit runs get edr_...' for individual edit runs") } if err != nil { return nil, nil, err @@ -388,8 +382,8 @@ func listSplitPage(ctx context.Context, cli *sdkclient.Client, p runsListParams, func listWorkflowPage(ctx context.Context, cli *sdkclient.Client, p runsListParams, pageToken string) ([][]string, any, string, error) { // Workflow runs have no source/sourceId filters at the server; - // common.SourceID is ignored here (silently — the CLI flag is - // documented as ignored for --type workflow). + // the workflows list command doesn't register those flags, so + // common.SourceID is always nil here. common, err := parseRunsListCommon(p, pageToken) if err != nil { return nil, nil, "", err diff --git a/internal/cli/runs_test.go b/internal/cli/runs_test.go index b333f69..94518af 100644 --- a/internal/cli/runs_test.go +++ b/internal/cli/runs_test.go @@ -8,20 +8,23 @@ import ( "time" extend "github.com/extend-hq/extend-go-sdk" + + "github.com/extend-hq/extend-cli/internal/extendx" ) -func TestRunsGet_DispatchesByPrefix(t *testing.T) { +func TestTypedRunsGet_RoutesToKindEndpoint(t *testing.T) { tests := []struct { name string + kind extendx.RunKind runID string wantPath string }{ - {"extract", "exr_abc", "/extract_runs/exr_abc"}, - {"parse", "pr_abc", "/parse_runs/pr_abc"}, - {"classify", "clr_abc", "/classify_runs/clr_abc"}, - {"split", "splr_abc", "/split_runs/splr_abc"}, - {"workflow", "workflow_run_abc", "/workflow_runs/workflow_run_abc"}, - {"edit", "edr_abc", "/edit_runs/edr_abc"}, + {"extract", extendx.KindExtract, "exr_abc", "/extract_runs/exr_abc"}, + {"parse", extendx.KindParse, "pr_abc", "/parse_runs/pr_abc"}, + {"classify", extendx.KindClassify, "clr_abc", "/classify_runs/clr_abc"}, + {"split", extendx.KindSplit, "splr_abc", "/split_runs/splr_abc"}, + {"workflow", extendx.KindWorkflow, "workflow_run_abc", "/workflow_runs/workflow_run_abc"}, + {"edit", extendx.KindEdit, "edr_abc", "/edit_runs/edr_abc"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -35,8 +38,8 @@ func TestRunsGet_DispatchesByPrefix(t *testing.T) { ta := newTestApp(t, srv) ta.app.Format = "json" - if err := runRunsGet(context.Background(), ta.app, tc.runID, ""); err != nil { - t.Fatalf("runRunsGet: %v", err) + if err := runTypedRunsGet(context.Background(), ta.app, tc.kind, tc.runID, ""); err != nil { + t.Fatalf("runTypedRunsGet: %v", err) } if got := srv.lastRequest().Path; got != tc.wantPath { t.Errorf("hit %q, want %q", got, tc.wantPath) @@ -45,25 +48,39 @@ func TestRunsGet_DispatchesByPrefix(t *testing.T) { } } -func TestRunsGet_UnknownPrefixErrors(t *testing.T) { +func TestTypedRunsGet_UnknownPrefixErrors(t *testing.T) { srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("server should not be called for unknown prefix") }) ta := newTestApp(t, srv) - err := runRunsGet(context.Background(), ta.app, "nope_xxx", "") - if err == nil || !strings.Contains(err.Error(), "cannot determine run type") { - t.Errorf("expected 'cannot determine run type' error, got %v", err) + err := runTypedRunsGet(context.Background(), ta.app, extendx.KindExtract, "nope_xxx", "") + if err == nil || !strings.Contains(err.Error(), "not a recognized extract run ID") { + t.Errorf("expected unrecognized-ID error, got %v", err) + } +} + +// TestTypedRunsGet_MismatchedKindRedirects is the core no-prefix- +// inference contract: a valid run ID passed to the wrong typed group +// must fail fast (no API call) and name the owning command. +func TestTypedRunsGet_MismatchedKindRedirects(t *testing.T) { + srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be called for a mismatched ID") + }) + ta := newTestApp(t, srv) + err := runTypedRunsGet(context.Background(), ta.app, extendx.KindParse, "exr_abc", "") + if err == nil || !strings.Contains(err.Error(), "extend extract runs get exr_abc") { + t.Errorf("expected redirect to 'extend extract runs get', got %v", err) } } -func TestRunsGet_ParseResponseTypeQuery(t *testing.T) { +func TestTypedRunsGet_ParseResponseTypeQuery(t *testing.T) { srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"id": "pr_abc", "status": "PROCESSED"}) }) ta := newTestApp(t, srv) ta.app.Format = "json" - if err := runRunsGet(context.Background(), ta.app, "pr_abc", "url"); err != nil { - t.Fatalf("runRunsGet: %v", err) + if err := runTypedRunsGet(context.Background(), ta.app, extendx.KindParse, "pr_abc", "url"); err != nil { + t.Fatalf("runTypedRunsGet: %v", err) } req := srv.lastRequest() if req.Path != "/parse_runs/pr_abc" || req.Query != "responseType=url" { @@ -71,35 +88,45 @@ func TestRunsGet_ParseResponseTypeQuery(t *testing.T) { } } -func TestRunsGet_ResponseTypeRejectedForNonParse(t *testing.T) { +func TestTypedRunsGet_InvalidResponseTypeRejected(t *testing.T) { srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("server should not be called") }) ta := newTestApp(t, srv) - err := runRunsGet(context.Background(), ta.app, "exr_abc", "url") - if err == nil || !strings.Contains(err.Error(), "only supported for parse runs") { - t.Fatalf("expected parse-only response-type error, got %v", err) + err := runTypedRunsGet(context.Background(), ta.app, extendx.KindParse, "pr_abc", "yaml") + if err == nil || !strings.Contains(err.Error(), "json|url") { + t.Fatalf("expected response-type validation error, got %v", err) } } -func TestRunsCancel_RejectsParseRuns(t *testing.T) { +func TestTypedRunsCancel_MismatchedKindRedirects(t *testing.T) { srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("server should not be called when refusing parse cancel") + t.Fatal("server should not be called for a mismatched ID") }) ta := newTestApp(t, srv) - err := runRunsCancel(context.Background(), ta.app, "pr_abc", true) - if err == nil || !strings.Contains(err.Error(), "parse runs cannot be cancelled") { - t.Errorf("expected parse-rejection error, got %v", err) + // A workflow-run ID handed to extract's cancel must redirect to + // the workflows group. + err := runTypedRunsCancel(context.Background(), ta.app, extendx.KindExtract, "workflow_run_abc", true) + if err == nil || !strings.Contains(err.Error(), "extend workflows runs cancel") { + t.Errorf("expected redirect naming the workflows runs group, got %v", err) + } + // Parse runs have no cancel command at all; a parse ID handed to + // another kind's cancel must not suggest a nonexistent command. + err = runTypedRunsCancel(context.Background(), ta.app, extendx.KindExtract, "pr_abc", true) + if err == nil || !strings.Contains(err.Error(), "parse runs do not support cancel") { + t.Errorf("expected parse-cannot-cancel error, got %v", err) } } -func TestRunsDelete_DispatchesByPrefix(t *testing.T) { +func TestTypedRunsDelete_HitsKindEndpoint(t *testing.T) { srv := newFakeServer(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"id": "clr_abc"}) }) ta := newTestApp(t, srv) - if err := runRunsDelete(context.Background(), ta.app, "clr_abc", true); err != nil { - t.Fatalf("runRunsDelete: %v", err) + cmd := findCmd(t, ta.app, "classify", "runs", "delete") + cmd.SetArgs([]string{"clr_abc", "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("delete: %v", err) } req := srv.lastRequest() if req.Method != http.MethodDelete || req.Path != "/classify_runs/clr_abc" { @@ -129,14 +156,21 @@ func TestRunsList_AllAutoPaginates(t *testing.T) { } }) ta := newTestApp(t, srv) - if err := runRunsList(stubCmdWithCtx(context.Background(), "list"), ta.app, runsListParams{ - runType: "extract", + cli, err := ta.app.NewClient() + if err != nil { + t.Fatalf("client: %v", err) + } + rows, _, err := collectListRows(context.Background(), cli, extendx.KindExtract, runsListParams{ limit: 5, all: true, sortDir: "desc", - }); err != nil { + }) + if err != nil { t.Fatalf("list: %v", err) } + if len(rows) != 3 { + t.Errorf("expected 3 rows across pages, got %d", len(rows)) + } if page != 2 { t.Errorf("expected 2 pages fetched, got %d", page) } @@ -199,14 +233,17 @@ func TestRunsList_TypeRoutesToCorrectEndpoint(t *testing.T) { writeJSON(w, 200, map[string]any{"object": "list", "data": []any{}}) }) ta := newTestApp(t, srv) - if err := runRunsList(stubCmdWithCtx(context.Background(), "list"), ta.app, runsListParams{ - runType: "extract", + cli, err := ta.app.NewClient() + if err != nil { + t.Fatalf("client: %v", err) + } + if _, _, err := collectListRows(context.Background(), cli, extendx.KindExtract, runsListParams{ status: "PROCESSED", batchID: "bpr_xyz", limit: 5, sortDir: "desc", }); err != nil { - t.Fatalf("runRunsList: %v", err) + t.Fatalf("collectListRows: %v", err) } req := srv.lastRequest() if req.Path != "/extract_runs" { @@ -226,8 +263,11 @@ func TestRunsList_AllFiltersOnExtract(t *testing.T) { writeJSON(w, 200, map[string]any{"object": "list", "data": []any{}}) }) ta := newTestApp(t, srv) - if err := runRunsList(stubCmdWithCtx(context.Background(), "list"), ta.app, runsListParams{ - runType: "extract", + cli, err := ta.app.NewClient() + if err != nil { + t.Fatalf("client: %v", err) + } + if _, _, err := collectListRows(context.Background(), cli, extendx.KindExtract, runsListParams{ using: "ex_abc", source: "WORKFLOW_RUN", sourceID: "workflow_run_x", @@ -236,7 +276,7 @@ func TestRunsList_AllFiltersOnExtract(t *testing.T) { sortDir: "asc", limit: 20, }); err != nil { - t.Fatalf("runRunsList: %v", err) + t.Fatalf("collectListRows: %v", err) } q := srv.lastRequest().Query for _, expected := range []string{ @@ -263,14 +303,17 @@ func TestRunsList_ParseDropsSort(t *testing.T) { writeJSON(w, 200, map[string]any{"object": "list", "data": []any{}}) }) ta := newTestApp(t, srv) - if err := runRunsList(stubCmdWithCtx(context.Background(), "list"), ta.app, runsListParams{ - runType: "parse", + cli, err := ta.app.NewClient() + if err != nil { + t.Fatalf("client: %v", err) + } + if _, _, err := collectListRows(context.Background(), cli, extendx.KindParse, runsListParams{ using: "ex_abc", // ignored — parse has no processor sortBy: "createdAt", sortDir: "asc", limit: 3, }); err != nil { - t.Fatalf("runRunsList: %v", err) + t.Fatalf("collectListRows: %v", err) } q := srv.lastRequest().Query if !strings.Contains(q, "maxPageSize=3") { @@ -289,7 +332,7 @@ func TestRunsWatch_ExitStatusOnFailedRun(t *testing.T) { }) ta := newTestApp(t, srv) ta.app.Format = "json" - err := runRunsWatch(context.Background(), ta.app, "exr_fail", 5*time.Second, true) + err := runTypedRunsWatch(context.Background(), ta.app, extractRunsSpec(), "exr_fail", 5*time.Second, true) if err == nil || !strings.Contains(err.Error(), "failed") { t.Errorf("expected --exit-status to surface FAILED as error, got %v", err) } @@ -301,7 +344,7 @@ func TestRunsWatch_ExitStatusFalseHidesFailure(t *testing.T) { }) ta := newTestApp(t, srv) ta.app.Format = "json" - if err := runRunsWatch(context.Background(), ta.app, "exr_fail", 5*time.Second, false); err != nil { + if err := runTypedRunsWatch(context.Background(), ta.app, extractRunsSpec(), "exr_fail", 5*time.Second, false); err != nil { t.Errorf("without --exit-status, FAILED should not error; got %v", err) } } @@ -313,7 +356,7 @@ func TestRunsWatch_TimeoutCancelsPolling(t *testing.T) { ta := newTestApp(t, srv) ta.app.Format = "json" start := time.Now() - err := runRunsWatch(context.Background(), ta.app, "exr_slow", 200*time.Millisecond, false) + err := runTypedRunsWatch(context.Background(), ta.app, extractRunsSpec(), "exr_slow", 200*time.Millisecond, false) elapsed := time.Since(start) if err == nil { t.Fatal("expected timeout error") @@ -376,7 +419,7 @@ func TestRunsUpdate_NameFlag(t *testing.T) { writeJSON(w, 200, map[string]any{"id": "workflow_run_abc", "object": "workflow_run", "status": "PROCESSED"}) }) ta := newTestApp(t, srv) - cmd := findCmd(t, ta.app, "runs", "update") + cmd := findCmd(t, ta.app, "workflows", "runs", "update") cmd.SetArgs([]string{"workflow_run_abc", "--name", "Q3 reprocess"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v", err) diff --git a/internal/cli/skill.go b/internal/cli/skill.go index 61bea5b..b29e950 100644 --- a/internal/cli/skill.go +++ b/internal/cli/skill.go @@ -62,8 +62,10 @@ var descriptionVerbs = []descriptionVerb{ {"classifying or identifying the type of a document (e.g. telling MSA from SOW from NDA)", "classify"}, {"splitting multi-document bundles into segments", "split"}, {"filling PDF forms via a values schema", "edit"}, - {"running multi-step document AI workflows", "run"}, - {"inspecting, watching, or listing Extend runs by ID (exr_, pr_, clr_, splr_, edr_, workflow_run_)", "runs"}, + {"running multi-step document AI workflows", "workflows"}, + // Run inspection is typed per verb (extract runs, workflows runs, ...); + // the guard maps to the primary verb whose runs subgroup carries the shape. + {"inspecting, watching, or listing Extend runs by ID (exr_, pr_, clr_, splr_, edr_, workflow_run_)", "extract"}, {"uploading documents to an Extend workspace and managing the resulting file_xxx IDs", "files"}, } @@ -98,7 +100,7 @@ type resourceFamily struct { // IDPrefix is the ID prefix for instances of this resource: "ex_", etc. IDPrefix string // RunVerb is the action verb that consumes this resource type: - // "extract" for extractors, "run" for workflows. + // "extract" for extractors, "workflows run" for workflows. RunVerb string } @@ -110,7 +112,7 @@ var resourceFamilies = []resourceFamily{ {Plural: "extractors", Singular: "extractor", IDPrefix: "ex_", RunVerb: "extract"}, {Plural: "classifiers", Singular: "classifier", IDPrefix: "cl_", RunVerb: "classify"}, {Plural: "splitters", Singular: "splitter", IDPrefix: "spl_", RunVerb: "split"}, - {Plural: "workflows", Singular: "workflow", IDPrefix: "workflow_", RunVerb: "run"}, + {Plural: "workflows", Singular: "workflow", IDPrefix: "workflow_", RunVerb: "workflows run"}, } // processorFamilyCommands is the canonical seven-command shape every @@ -126,25 +128,40 @@ var processorFamilyCommands = []string{ "versions create", } +// workflowExtraCommands are the commands the workflows family exposes +// beyond the shared seven-command processor shape: the run launcher and +// the typed workflow runs subgroup. Tests verify the workflows family +// has exactly processorFamilyCommands + these. +var workflowExtraCommands = []string{ + "run", + "run batch", + "runs get", + "runs list", + "runs watch", + "runs cancel", + "runs delete", + "runs update", +} + // waitDefaultVerbs lists the action verbs whose runs wait for terminal // state by default. The "Wait, async, watch" prose section asserts these // behaviours; a test asserts each entry's CommandDoc.Wait.DefaultsToWait // matches the documented value. var waitDefaultVerbs = []string{"extract", "classify", "parse", "split", "edit"} -// asyncDefaultVerbs lists the action verbs whose runs are async by +// asyncDefaultVerbs lists the command paths whose runs are async by // default (workflow runs only, currently). Same guard as -// waitDefaultVerbs. -var asyncDefaultVerbs = []string{"run"} +// waitDefaultVerbs; entries are space-separated command paths. +var asyncDefaultVerbs = []string{"workflows run"} // paginationExampleCommand is the command path used in the bash example // in the Pagination section. A test asserts it resolves and that each // flag in paginationExampleFlags exists on it. -var paginationExampleCommand = []string{"runs", "list"} +var paginationExampleCommand = []string{"extract", "runs", "list"} // paginationExampleFlags are the flags referenced in the pagination // example. Asserted to exist on paginationExampleCommand. -var paginationExampleFlags = []string{"type", "using", "status", "page-token", "all", "max", "output"} +var paginationExampleFlags = []string{"using", "status", "page-token", "all", "max", "output"} // renderDescription assembles the YAML `description` field from // descriptionVerbs and the disambiguation examples. Public via tests so @@ -209,7 +226,7 @@ func writeSkillActiveBehaviour(b *strings.Builder) { b.WriteString("## When this skill is active\n\n") b.WriteString("- **Documents come from disk, not from messages.** When the user references a document (\"this contract\", \"these invoices\", \"the PDF\") without giving a path, glance at the current working directory for matching files (`*.pdf`, `*.png`, `*.jpg`, `*.tif`) before asking. Real users say \"this PDF\" when there's exactly one in cwd.\n") b.WriteString("- **File uploads always go through `extend files upload`.** Never substitute a host-tool File API (e.g. an inline file upload tool that returns its own `file_xxx` ID). The skill's file IDs are only legitimate when produced by `extend files upload` or returned in another `extend` response.\n") - b.WriteString("- **Run IDs (`exr_`/`pr_`/`clr_`/`splr_`/`edr_`/`workflow_run_`) are Extend's, not the host's.** When the user mentions one, reach for `extend runs get|watch|cancel` — not a host-tool task tracker.\n") + b.WriteString("- **Run IDs (`exr_`/`pr_`/`clr_`/`splr_`/`edr_`/`workflow_run_`) are Extend's, not the host's.** When the user mentions one, reach for the typed runs commands (`extend runs get|watch|cancel`) — not a host-tool task tracker.\n") b.WriteString("- **\"OCR\" alone is ambiguous; the user's intent disambiguates.** If they want specific values out (totals, line items, dates, names) → `extract` with a configured extractor. If they want raw text or markdown of the page → `parse`. \"OCR this receipt and grab the total\" is `extract`, not `parse`.\n\n") } @@ -250,33 +267,46 @@ func writeSkillPickActions(b *strings.Builder, root *CommandDoc) { fmt.Fprintf(b, "| %s | `extend %s` |\n", sub.Summary, sub.Use) } - b.WriteString("\n`` is a local file path (auto-uploaded), a `file_xxx` ID, or an `https://` URL. For batches of up to 1,000 inputs, use ` batch`.\n\n") + // The workflow-run launcher lives under the workflows resource + // group rather than at the top level, so add its row explicitly. + for _, sub := range root.Subcommands { + if sub.Name() != "workflows" { + continue + } + for _, wsub := range sub.Subcommands { + if wsub.Name() == "run" { + fmt.Fprintf(b, "| %s | `extend workflows %s` |\n", wsub.Summary, wsub.Use) + } + } + } + + b.WriteString("\n`` is a local file path (auto-uploaded), a `file_xxx` ID, or an `https://` URL. For batches of up to 1,000 inputs, use ` batch` or `workflows run batch`.\n\n") b.WriteString("Every action verb that needs a processor takes `--using ` — the ID prefix tells you the type: `ex_*` (extractors), `cl_*` (classifiers), `spl_*` (splitters), `workflow_*` (workflows). `parse` runs alone (no processor); `edit` takes `--instructions` (free-form prose). See `extend edit --help` for the full set.\n\n") } func writeSkillWait(b *strings.Builder) { b.WriteString("## Wait, async, watch\n\n") b.WriteString("Action verbs (`extract`/`classify`/`parse`/`split`/`edit`) **wait by default** for terminal state and print the result. Pass `--wait=false` to return the run ID immediately.\n\n") - b.WriteString("`extend run` (workflow runs) is **async by default** because workflow runs can take minutes to hours. Pass `--wait` to block on it.\n\n") - b.WriteString("Follow a run by ID, regardless of type:\n\n") - b.WriteString(" extend runs watch \n\n") - b.WriteString("The run type is auto-detected from the ID prefix (`exr_`, `pr_`, `clr_`, `splr_`, `workflow_run_`, `edr_`). Use `--exit-status` to gate downstream scripts on success:\n\n") - b.WriteString(" extend runs watch exr_xxx --exit-status && downstream-script.sh\n\n") - b.WriteString("To inspect current state without polling: `extend runs get `.\n\n") + b.WriteString("`extend workflows run` is **async by default** because workflow runs can take minutes to hours. Pass `--wait` to block on it.\n\n") + b.WriteString("Run inspection is typed per verb; the ID prefix names the owner (`exr_` extract, `pr_` parse, `clr_` classify, `splr_` split, `edr_` edit, `workflow_run_` workflows):\n\n") + b.WriteString(" extend extract runs watch exr_xxx\n\n") + b.WriteString("A wrong-type ID fails fast, naming the right command. Use `--exit-status` to gate downstream scripts on success:\n\n") + b.WriteString(" extend extract runs watch exr_xxx --exit-status && downstream-script.sh\n\n") + b.WriteString("To inspect state without polling: `extend runs get `.\n\n") b.WriteString("**Run-type quirks** (the things that defy reasonable assumptions):\n\n") - b.WriteString("- **Edit runs** (`edr_*`) are not listable — the API has no `LIST /edit_runs`. Use `extend runs get edr_xxx` for individual edit runs.\n") - b.WriteString("- **Parse runs** (`pr_*`) cannot be cancelled; the API rejects the attempt. Other run types support best-effort cancel.\n") - b.WriteString("- **Workflow batches** (returned by `extend run batch`) have **no GET endpoint**. `extend batches get`/`watch` work only on processor batches (`bpr_*`). Track workflow batches with `extend runs list --type workflow --batch `.\n\n") + b.WriteString("- **Edit runs** (`edr_*`) are not listable; the API has no `LIST /edit_runs`.\n") + b.WriteString("- **Parse and edit runs** have no cancel command; other run types support best-effort cancel.\n") + b.WriteString("- **Workflow batches** have **no GET endpoint** (hence no `workflows batches` commands); track them with `extend workflows runs list --batch `.\n\n") b.WriteString("For the per-command wait/profile/failure-status table: `extend help lifecycle`.\n\n") } func writeSkillPagination(b *strings.Builder) { b.WriteString("## Pagination\n\n") b.WriteString("List commands return one page by default. Pass `--max N` to fetch up to N total results — the CLI auto-paginates internally and never makes you handle page tokens:\n\n") - b.WriteString(` extend runs list --type extract --status FAILED --max 100 + b.WriteString(` extend extract runs list --status FAILED --max 100 `) - b.WriteString("Use `--all` only when you genuinely want every result (scripts, not agents). Power users can still cursor explicitly with `--page-token`, but most callers should not need to see tokens at all.\n\n") + b.WriteString("Use `--all` only when you genuinely want every result (scripts, not agents). Power users can still cursor explicitly with `--page-token`.\n\n") b.WriteString("`--jq ` filters JSON output before rendering, but cannot combine with `-o markdown` (markdown is not JSON). Use `-o json --jq '...'` and select the markdown chunk paths instead.\n\n") } @@ -339,8 +369,8 @@ func writeSkillWorkflows(b *strings.Builder) { 4. Run it asynchronously, or add --wait to block until terminal: - RUN=$(extend run invoice.pdf --using "$WORKFLOW" --version v1 -o id) - extend runs watch "$RUN" + RUN=$(extend workflows run invoice.pdf --using "$WORKFLOW" --version v1 -o id) + extend workflows runs watch "$RUN" ### Process a folder of inputs and inspect failures @@ -350,15 +380,15 @@ func writeSkillWorkflows(b *strings.Builder) { 2. Wait for the batch to finish; gate downstream work on success: - extend batches watch "$BATCH" --exit-status || echo "batch failed" + extend extract batches watch "$BATCH" --exit-status || echo "batch failed" 3. List runs that failed (or any other status) for inspection: - extend runs list --type extract --batch "$BATCH" --status FAILED -o json + extend extract runs list --batch "$BATCH" --status FAILED -o json -4. Pull a specific failed run's full payload (auto-detects type from prefix): +4. Pull a specific failed run's full payload: - extend runs get exr_yyy -o json + extend extract runs get exr_yyy -o json ### Configure a webhook for workflow completions @@ -562,7 +592,8 @@ func writeCatalogProcessorFamilies(b *strings.Builder) { b.WriteString("- `extend versions list ` — List published versions.\n") b.WriteString("- `extend versions get ` — Show one version (or the draft).\n") b.WriteString("- `extend versions create --release-type major|minor` — Publish the draft as a new version.\n\n") - b.WriteString("**Workflows differ:** `versions create` uses `--name ` instead of `--release-type`. The deployed name is what `extend run --version` references.\n\n") + b.WriteString("**Workflows differ:** `versions create` uses `--name ` instead of `--release-type`. The deployed name is what `extend workflows run --version` references.\n\n") + b.WriteString("**Workflows also expose runs:** `extend workflows run ` / `run batch` start runs; `extend workflows runs get|list|watch|cancel|delete|update` inspect and control them (the action verbs' `runs` shape, plus `update`).\n\n") } // writeCatalogEntry renders one command in the catalog as a single line: diff --git a/internal/cli/skill_test.go b/internal/cli/skill_test.go index 5aba9fb..4d66abe 100644 --- a/internal/cli/skill_test.go +++ b/internal/cli/skill_test.go @@ -159,7 +159,7 @@ func TestSkillIncludesWorkflowLifecycleRecipe(t *testing.T) { "### Create, deploy, and run a workflow", "extend workflows create", "extend workflows versions create", - "extend run invoice.pdf --using", + "extend workflows run invoice.pdf --using", "classificationId", "cannot use version \"latest\"", } { @@ -447,6 +447,11 @@ func TestSkillResourceFamiliesShareShape(t *testing.T) { } got := flattenCommandPaths(fam, "") want := append([]string(nil), processorFamilyCommands...) + if f.Plural == "workflows" { + // Workflows carry the run launcher and the typed runs + // subgroup on top of the shared processor shape. + want = append(want, workflowExtraCommands...) + } gotSorted := append([]string(nil), got...) sortStrings(gotSorted) @@ -458,6 +463,26 @@ func TestSkillResourceFamiliesShareShape(t *testing.T) { } } +// findDocByPath resolves a space-separated command path ("workflows +// run") to its CommandDoc, or nil if any segment is missing. +func findDocByPath(root *CommandDoc, path string) *CommandDoc { + d := root + for _, seg := range strings.Fields(path) { + var next *CommandDoc + for _, sub := range d.Subcommands { + if sub.Name() == seg { + next = sub + break + } + } + if next == nil { + return nil + } + d = next + } + return d +} + // flattenCommandPaths returns the relative space-separated paths of // every IsCommand leaf under d. Used to compare a resource family's // shape to processorFamilyCommands. @@ -527,9 +552,9 @@ func TestSkillWaitClaimsMatchTree(t *testing.T) { } for _, verb := range asyncDefaultVerbs { - d, ok := subs[verb] - if !ok { - t.Errorf("asyncDefaultVerbs entry %q not in root.Subcommands", verb) + d := findDocByPath(root, verb) + if d == nil { + t.Errorf("asyncDefaultVerbs entry %q does not resolve in the doc tree", verb) continue } if d.Wait == nil { diff --git a/internal/cli/split.go b/internal/cli/split.go index 6ed8fcb..f4474c1 100644 --- a/internal/cli/split.go +++ b/internal/cli/split.go @@ -80,7 +80,7 @@ path, a file:// URI, or '-' to read from stdin. "Exactly one of --using or --config is required (server schema rejects both or neither).", "--patch requires --using; for a standalone config use --config instead.", }, - SeeAlso: []string{"parse", "split batch", "runs watch", "runs get"}, + SeeAlso: []string{"parse", "split batch", "split runs watch", "split runs get"}, Output: OutputSpec{TTY: OutputTable, Pipe: OutputJSON}, Wait: &WaitSpec{Profile: extendx.ProfileShort, DefaultsToWait: true}, Failures: []extendx.RunStatus{extendx.StatusFailed, extendx.StatusCancelled}, @@ -120,7 +120,7 @@ path, a file:// URI, or '-' to read from stdin. cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum total time to wait for the run to reach a terminal state (not a per-HTTP-request timeout; see --http-timeout)") meta.attach(cmd) }, - Subcommands: []*CommandDoc{newSplitBatchDoc(app)}, + Subcommands: []*CommandDoc{newSplitBatchDoc(app), splitRunsSpec().doc(app), splitBatchesSpec().doc(app)}, } } @@ -210,7 +210,7 @@ func runSplit(ctx context.Context, app *App, p splitParams) error { }) sp.Stop("") if err != nil { - return formatActionWaitError(err, run.ID) + return formatActionWaitError(err, run.ID, "extend split runs watch") } if err := renderSplitResult(app, final); err != nil { diff --git a/internal/cli/text_input_test.go b/internal/cli/text_input_test.go index 68736e7..b516071 100644 --- a/internal/cli/text_input_test.go +++ b/internal/cli/text_input_test.go @@ -73,7 +73,7 @@ func TestWorkflowRun_TextInputForwardsText(t *testing.T) { writeJSON(w, 200, map[string]any{"id": "workflow_run_x", "object": "workflow_run", "status": "PENDING"}) }) ta := newTestApp(t, srv) - cmd := findCmd(t, ta.app, "run") + cmd := findCmd(t, ta.app, "workflows", "run") cmd.SetArgs([]string{"--using", "workflow_abc", "--text", "wf text"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute: %v", err) diff --git a/internal/cli/topics.go b/internal/cli/topics.go index 56bf913..ac3ff72 100644 --- a/internal/cli/topics.go +++ b/internal/cli/topics.go @@ -132,7 +132,7 @@ func renderAuthTopicBody(_ *CommandDoc) string { b.WriteString(" EXTEND_API_KEY, so you can keep separate keys for test/prod side-by-side:\n\n") b.WriteString(" export EXTEND_API_KEY=sk_prod_xxx\n") b.WriteString(" export EXTEND_TEST_API_KEY=sk_test_xxx\n") - b.WriteString(" extend --env test runs list --type extract\n\n") + b.WriteString(" extend --env test extract runs list\n\n") b.WriteString(" Other env vars (workspace, region) are not split per environment;\n") b.WriteString(" the docs note workflow definitions are shared between environments while\n") b.WriteString(" runs and data are isolated by the key in use.\n") @@ -170,7 +170,8 @@ func renderOutputTopicBody(root *CommandDoc) string { if e.Doc.Output.TTY == "" && e.Doc.Output.Pipe == "" { continue } - // Convert dotted path "extend.runs.watch" into "extend runs watch". + // Convert dotted path "extend.extract.runs.watch" into + // "extend extract runs watch". spaced := strings.ReplaceAll(e.Path, ".", " ") if len(spaced) > pathLen { pathLen = len(spaced) @@ -191,13 +192,13 @@ func renderOutputTopicBody(root *CommandDoc) string { b.WriteString(" TTYs). Pass that value to the next call's --page-token to advance.\n\n") b.WriteString(" Page tokens are bound to the originating query on the server, so\n") b.WriteString(" every follow-up call must repeat the same filter flags as the\n") - b.WriteString(" first one (--type, --using, --status, --batch, --sort, etc.).\n") + b.WriteString(" first one (--using, --status, --batch, --sort, etc.).\n") b.WriteString(" Changing filters between pages yields incorrect results.\n\n") b.WriteString(" Recommended pattern (agents and scripts) — note FILTERS reused:\n\n") - b.WriteString(" FILTERS=(--type extract --using ex_abc --status PROCESSED)\n") + b.WriteString(" FILTERS=(--using ex_abc --status PROCESSED)\n") b.WriteString(" tok=\"\"\n") b.WriteString(" while :; do\n") - b.WriteString(" page=$(extend runs list \"${FILTERS[@]}\" \\\n") + b.WriteString(" page=$(extend extract runs list \"${FILTERS[@]}\" \\\n") b.WriteString(" --page-token \"$tok\" -o json)\n") b.WriteString(" echo \"$page\" | jq '.data[]' # do work on this page\n") b.WriteString(" tok=$(echo \"$page\" | jq -r '.nextPageToken')\n") @@ -219,8 +220,8 @@ func renderLifecycleTopicBody(root *CommandDoc) string { b.WriteString("Most action commands (extract, classify, split, parse, edit) wait by\n") b.WriteString("default for the run to reach a terminal state, then print the result.\n") b.WriteString("Pass --async to return the run ID immediately. Workflow runs (extend\n") - b.WriteString("run) are different: they return immediately by default; pass --wait to\n") - b.WriteString("block.\n\n") + b.WriteString("workflows run) are different: they return immediately by default; pass\n") + b.WriteString("--wait to block.\n\n") b.WriteString("Polling profiles:\n\n") for _, spec := range extendx.WaitProfileSpecs() { fmt.Fprintf(&b, " %-6s %v -> %v\n", spec.Profile, spec.Interval, spec.MaxInterval) @@ -288,10 +289,12 @@ func renderLifecycleTopicBody(root *CommandDoc) string { } b.WriteString("\nWatching:\n\n") - b.WriteString(" Use `extend runs watch ` for any run, or `extend batches watch`\n") - b.WriteString(" for batch runs. Both accept --exit-status, which propagates the\n") - b.WriteString(" terminal status to the exit code:\n\n") - b.WriteString(" extend runs watch --exit-status && downstream-script.sh\n\n") + b.WriteString(" Use the typed watch commands: `extend runs watch ` for a\n") + b.WriteString(" run (extract, parse, classify, split, edit, workflows) and\n") + b.WriteString(" `extend batches watch ` for a processor or parse batch.\n") + b.WriteString(" Both accept --exit-status, which propagates the terminal status to\n") + b.WriteString(" the exit code:\n\n") + b.WriteString(" extend extract runs watch --exit-status && downstream-script.sh\n\n") b.WriteString(" Without --exit-status, watch commands exit 0 on any successful\n") b.WriteString(" poll regardless of run status. That is useful when you want the\n") b.WriteString(" JSON output and plan to inspect the status field yourself.\n\n") diff --git a/internal/cli/wait_err.go b/internal/cli/wait_err.go index 2b1c288..8fdfe54 100644 --- a/internal/cli/wait_err.go +++ b/internal/cli/wait_err.go @@ -38,19 +38,19 @@ func runFailureError(id string, reason, message *string) error { // // For *extendx.WaitTimeoutError we render an actionable message that // names the elapsed --timeout and the two recovery paths: raise -// --timeout, or detach with --wait=false and follow up with -// `extend runs watch `. Without this guidance, agents tend to -// silently retry the same blocking command (as observed in the -// agent-experience transcripts) instead of switching to the cheap -// async path. -func formatActionWaitError(err error, runID string) error { +// --timeout, or detach with --wait=false and follow up with the typed +// watch command (watchCmd, e.g. "extend extract runs watch"). Without +// this guidance, agents tend to silently retry the same blocking +// command (as observed in the agent-experience transcripts) instead +// of switching to the cheap async path. +func formatActionWaitError(err error, runID, watchCmd string) error { if err == nil { return nil } var wt *extendx.WaitTimeoutError if errors.As(err, &wt) { - return fmt.Errorf("run %s did not finish within --timeout %s; rerun with a larger --timeout, or detach with --wait=false and poll using 'extend runs watch %s'", - runID, wt.Timeout, runID) + return fmt.Errorf("run %s did not finish within --timeout %s; rerun with a larger --timeout, or detach with --wait=false and poll using '%s %s'", + runID, wt.Timeout, watchCmd, runID) } return fmt.Errorf("wait: %w", err) } @@ -58,15 +58,15 @@ func formatActionWaitError(err error, runID string) error { // formatWatchWaitError is the runs-watch / batches-watch specialization. // There is no --wait=false alternative here (the command itself is the // polling loop), so the actionable hint just nudges the user toward a -// larger --timeout on retry. -func formatWatchWaitError(err error, id string) error { +// larger --timeout on retry of watchCmd (e.g. "extend parse runs watch"). +func formatWatchWaitError(err error, id, watchCmd string) error { if err == nil { return nil } var wt *extendx.WaitTimeoutError if errors.As(err, &wt) { - return fmt.Errorf("run %s still not in a terminal state after --timeout %s; rerun 'extend runs watch %s --timeout '", - id, wt.Timeout, id) + return fmt.Errorf("run %s still not in a terminal state after --timeout %s; rerun '%s %s --timeout '", + id, wt.Timeout, watchCmd, id) } return fmt.Errorf("wait: %w", err) } diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index 0a2c1cb..94aa7fe 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -118,7 +118,7 @@ func readBody(app *App, path string) ([]byte, error) { // deleteWithConfirm is shared between webhook endpoint and webhook // subscription delete commands. The TTY-aware confirmation matches the -// pattern used elsewhere (extend files delete, extend runs delete). +// pattern used elsewhere (extend files delete, extend extract runs delete). func deleteWithConfirm(ctx context.Context, app *App, label, id string, yes bool, fn func(context.Context, string) error) error { if !yes { if !app.IO.IsStdinTTY() { diff --git a/internal/cli/workflows.go b/internal/cli/workflows.go index fef3e78..5097236 100644 --- a/internal/cli/workflows.go +++ b/internal/cli/workflows.go @@ -22,7 +22,7 @@ func workflowAccessor() processorAccessor[*extend.Workflow, *extend.WorkflowSumm noun: "workflow", pluralNoun: "workflows", exampleID: "workflow_abc", - runVerb: "run", + runVerb: "workflows run", bodyDoc: workflowBodyDoc, rowFields: func(w *extend.WorkflowSummary) []string { return []string{w.ID, w.Name, relTime(w.CreatedAt)} @@ -88,3 +88,23 @@ func workflowAccessor() processorAccessor[*extend.Workflow, *extend.WorkflowSumm }, } } + +// newWorkflowsDoc assembles the full `extend workflows` group: the +// generic resource accessor (list/get/create/update/versions) plus the +// run launcher (`workflows run`, with `workflows run batch`) and the +// typed workflow runs subgroup (`workflows runs get|list|watch|cancel| +// delete|update`). Workflow batches have no retrieval endpoint, so +// there is deliberately no `workflows batches` group; track a batch +// with `workflows runs list --batch `. +func newWorkflowsDoc(app *App) *CommandDoc { + doc := workflowAccessor().doc(app) + doc.Summary = "Run, inspect, and manage workflows" + doc.WhenToUse = `Use these commands to run workflows on documents, follow their runs, +and discover, inspect, create, update, and version workflows in the +workspace.` + doc.Subcommands = append(doc.Subcommands, + newWorkflowsRunDoc(app), + workflowRunsSpec().doc(app), + ) + return doc +} diff --git a/internal/extendx/batches.go b/internal/extendx/batches.go index 791318a..36e9a31 100644 --- a/internal/extendx/batches.go +++ b/internal/extendx/batches.go @@ -2,17 +2,24 @@ package extendx import ( "errors" + "fmt" "strings" ) -// BatchKind identifies which server endpoint produced a batch ID. Used -// by `extend batches get/watch` to dispatch on prefix and surface the -// correct error for workflow batches (which have no retrieval endpoint). +// BatchKind identifies which server endpoint produced a batch ID. +// Batch commands are typed per verb (`extend extract batches get`, +// `extend parse batches watch`, ...); the prefix table exists only to +// validate a pasted ID against the invoked command and to surface the +// correct error for workflow batches (which have no retrieval +// endpoint). type BatchKind string const ( // BatchKindProcessor matches IDs returned by /extract_runs/batch, // /classify_runs/batch, and /split_runs/batch (server prefix `bpr_`). + // The prefix is shared across the three processor types, so prefix + // validation cannot tell an extract batch from a classify batch; + // the server is the authority on the exact type. BatchKindProcessor BatchKind = "processor" // BatchKindParse matches IDs returned by /parse_runs/batch (`bpar_`). BatchKindParse BatchKind = "parse" @@ -35,28 +42,36 @@ func BatchKindFromID(id string) (BatchKind, bool) { return "", false } -// ErrWorkflowBatchNotRetrievable is returned by GetBatchRun and -// WaitForBatchRun when called with a workflow batch ID. The server has -// no public retrieval endpoint for workflow batches; use -// ListWorkflowRuns with the BatchID filter to poll progress. -var ErrWorkflowBatchNotRetrievable = errors.New("workflow batches (batch_*) cannot be retrieved via /batch_runs/{id}; use 'extend runs list --type workflow --batch ' to track progress") +// ErrWorkflowBatchNotRetrievable is returned by batch get/watch paths +// when called with a workflow batch ID. The server has no public +// retrieval endpoint for workflow batches; use the workflow runs list +// with the BatchID filter to poll progress. +var ErrWorkflowBatchNotRetrievable = errors.New("workflow batches (batch_*) cannot be retrieved via /batch_runs/{id}; use 'extend workflows runs list --batch ' to track progress") -// ErrNotCancellable signals that the run referred to by an ID prefix -// has no cancel endpoint. Parse and edit runs fall into this bucket. -var ErrNotCancellable = errors.New("run type is not cancellable") - -// CanCancel validates that a run ID points to a cancellable run kind. -// Returns nil if the ID is recognized AND the kind supports cancel. -func CanCancel(id string) error { - kind, ok := RunKindFromID(id) +// ValidateBatchID checks that id carries the ID prefix the invoked +// typed batch command expects. expected is BatchKindProcessor for the +// extract/classify/split batch commands and BatchKindParse for parse. +// A workflow batch ID always fails with the no-retrieval-endpoint +// error. Because bpr_ is shared by extract, classify, and split, a +// processor-kind match here does not guarantee the exact type; the +// server resolves that. +func ValidateBatchID(expected BatchKind, id, action string) error { + actual, ok := BatchKindFromID(id) if !ok { - return errors.New("unknown run id prefix") + prefix := "bpr_" + if expected == BatchKindParse { + prefix = "bpar_" + } + return fmt.Errorf("%q is not a recognized batch run ID (expected %s prefix)", id, prefix) + } + if actual == BatchKindWorkflow { + return ErrWorkflowBatchNotRetrievable } - if kind == KindParse { - return errors.New("parse runs cannot be cancelled") + if actual == expected { + return nil } - if kind == KindEdit { - return errors.New("edit runs cannot be cancelled") + if expected == BatchKindParse { + return fmt.Errorf("%s is an ID for extract, classify, or split batches, not parse batches; use 'extend batches %s %s'", id, action, id) } - return nil + return fmt.Errorf("%s is an ID for parse batches; use 'extend parse batches %s %s'", id, action, id) } diff --git a/internal/extendx/batches_test.go b/internal/extendx/batches_test.go index c55513c..6a22646 100644 --- a/internal/extendx/batches_test.go +++ b/internal/extendx/batches_test.go @@ -2,6 +2,7 @@ package extendx import ( "errors" + "strings" "testing" ) @@ -36,38 +37,44 @@ func TestBatchKindFromID(t *testing.T) { } } -func TestCanCancel(t *testing.T) { - // Cancellable kinds: extract, classify, split, workflow. - cancellable := []string{"exr_x", "clr_x", "splr_x", "workflow_run_x"} - for _, id := range cancellable { - if err := CanCancel(id); err != nil { - t.Errorf("CanCancel(%q) = %v; want nil", id, err) - } +func TestValidateBatchID(t *testing.T) { + // Matching kinds pass. + if err := ValidateBatchID(BatchKindProcessor, "bpr_x", "get"); err != nil { + t.Errorf("ValidateBatchID(processor, bpr_x) = %v; want nil", err) + } + if err := ValidateBatchID(BatchKindParse, "bpar_x", "watch"); err != nil { + t.Errorf("ValidateBatchID(parse, bpar_x) = %v; want nil", err) } - // Non-cancellable: parse, edit. Each returns a specific error. - if err := CanCancel("pr_x"); err == nil { - t.Error("CanCancel(parse) = nil; want non-nil") + // Workflow batch IDs always fail with the sentinel, regardless of + // the expected kind. + for _, expected := range []BatchKind{BatchKindProcessor, BatchKindParse} { + if err := ValidateBatchID(expected, "batch_x", "get"); !errors.Is(err, ErrWorkflowBatchNotRetrievable) { + t.Errorf("ValidateBatchID(%s, batch_x) = %v; want ErrWorkflowBatchNotRetrievable", expected, err) + } } - if err := CanCancel("edr_x"); err == nil { - t.Error("CanCancel(edit) = nil; want non-nil") + // Cross-kind mismatches redirect to the other command family. + if err := ValidateBatchID(BatchKindParse, "bpr_x", "get"); err == nil { + t.Error("ValidateBatchID(parse, bpr_x) = nil; want mismatch error") } - // Unknown prefix. - err := CanCancel("file_x") + err := ValidateBatchID(BatchKindProcessor, "bpar_x", "watch") if err == nil { - t.Error("CanCancel(unknown) = nil; want non-nil") + t.Fatal("ValidateBatchID(processor, bpar_x) = nil; want mismatch error") } - // Empty. - if err := CanCancel(""); err == nil { - t.Error("CanCancel(\"\") = nil; want non-nil") + if want := "extend parse batches watch bpar_x"; !strings.Contains(err.Error(), want) { + t.Errorf("mismatch error %q does not mention %q", err, want) + } + // Unknown prefixes fail. + if err := ValidateBatchID(BatchKindProcessor, "exr_x", "get"); err == nil { + t.Error("ValidateBatchID(processor, exr_x) = nil; want non-nil") + } + if err := ValidateBatchID(BatchKindProcessor, "", "get"); err == nil { + t.Error("ValidateBatchID(processor, \"\") = nil; want non-nil") } } func TestErrSentinels(t *testing.T) { - // Lock the sentinel identities — callers compare with errors.Is. + // Lock the sentinel identity — callers compare with errors.Is. if !errors.Is(ErrWorkflowBatchNotRetrievable, ErrWorkflowBatchNotRetrievable) { t.Error("ErrWorkflowBatchNotRetrievable must be identity-comparable") } - if !errors.Is(ErrNotCancellable, ErrNotCancellable) { - t.Error("ErrNotCancellable must be identity-comparable") - } } diff --git a/internal/extendx/client.go b/internal/extendx/client.go index 927663c..daf0cb7 100644 --- a/internal/extendx/client.go +++ b/internal/extendx/client.go @@ -260,7 +260,7 @@ func sanitized(e *APIError) *APIError { } // IsNotFound reports whether err is a 404 from the API. Used by -// commands like `extend runs get` to surface "no such run" hints. +// commands like `extend extract runs get` to surface "no such run" hints. func IsNotFound(err error) bool { if apiErr, ok := AsAPIError(err); ok { return apiErr.StatusCode == http.StatusNotFound diff --git a/internal/extendx/files.go b/internal/extendx/files.go index 09cb384..dd3e4e7 100644 --- a/internal/extendx/files.go +++ b/internal/extendx/files.go @@ -325,6 +325,22 @@ func BuildEditSchemaFile(ref FileRef) (*extend.EditSchemasGenerateRequestFile, e }, nil } +// BuildFormDetectionFile converts a FileRef into the SDK's per-endpoint +// file union for /form_detection_runs (URL and ID inputs only). +func BuildFormDetectionFile(ref FileRef) (*extend.FormDetectionRunsCreateRequestFile, error) { + fromURL, fromID, _, err := fileFromRef(ref) + if err != nil { + return nil, err + } + if fromURL == nil && fromID == nil { + return nil, errors.New("form detection runs only accept URL or file-ID inputs") + } + return &extend.FormDetectionRunsCreateRequestFile{ + FileFromURL: fromURL, + FileFromID: fromID, + }, nil +} + // BuildWorkflowFile converts a FileRef into the SDK's per-endpoint // file union for /workflow_runs. func BuildWorkflowFile(ref FileRef) (*extend.WorkflowRunsCreateRequestFile, error) { diff --git a/internal/extendx/files_test.go b/internal/extendx/files_test.go index 9349ebd..b5a9cfe 100644 --- a/internal/extendx/files_test.go +++ b/internal/extendx/files_test.go @@ -248,6 +248,13 @@ func TestBuildEditSchemaFile_RejectsText(t *testing.T) { } } +func TestBuildFormDetectionFile_RejectsText(t *testing.T) { + _, err := BuildFormDetectionFile(FileRef{Text: "raw"}) + if err == nil { + t.Error("BuildFormDetectionFile(text) = nil; want non-nil") + } +} + func TestBuildWorkflowFile_AcceptsAllInputs(t *testing.T) { // Workflows pass the file through to the underlying step; // builder accepts URL, ID, and Text. diff --git a/internal/extendx/runs.go b/internal/extendx/runs.go index a337653..f4bef95 100644 --- a/internal/extendx/runs.go +++ b/internal/extendx/runs.go @@ -1,6 +1,9 @@ package extendx -import "strings" +import ( + "fmt" + "strings" +) // RunStatus is the canonical CLI representation of a run's lifecycle // state. The SDK exposes a per-kind enum (ProcessorRunStatus, @@ -58,8 +61,11 @@ func (s RunStatus) IsTerminal() bool { return false } -// RunKind names a run resource type. Used by `extend runs get/watch/cancel` -// to dispatch on the run's ID prefix to the right SDK sub-client. +// RunKind names a run resource type. Commands are typed per kind +// (`extend extract runs get`, `extend workflows runs watch`, ...), so +// the kind is always chosen by the invoked command; the ID-prefix +// table below exists only to fail fast with a pointer to the right +// command when an ID of a different type is pasted. type RunKind string const ( @@ -71,6 +77,38 @@ const ( KindEdit RunKind = "edit" ) +// Verb returns the CLI command group that owns this kind's typed runs +// subcommands ("extend runs ..."). It differs from the kind +// name only for workflows, whose resource group is plural. +func (k RunKind) Verb() string { + if k == KindWorkflow { + return "workflows" + } + return string(k) +} + +// RunIDPrefix returns the server-issued ID prefix for a run kind. +func RunIDPrefix(k RunKind) string { + switch k { + case KindExtract: + return "exr_" + case KindParse: + return "pr_" + case KindClassify: + return "clr_" + case KindSplit: + return "splr_" + case KindWorkflow: + return "workflow_run_" + case KindEdit: + return "edr_" + } + return "" +} + +// RunKindFromID maps an ID prefix back to its run kind. Used only for +// mismatch validation (ValidateRunID) and human-readable error +// messages, never for command dispatch. func RunKindFromID(id string) (RunKind, bool) { switch { case strings.HasPrefix(id, "exr_"): @@ -88,3 +126,43 @@ func RunKindFromID(id string) (RunKind, bool) { } return "", false } + +// SupportsRunAction reports whether a run kind has the given typed +// runs subcommand. Mirrors the capability flags in the CLI's +// runsGroupSpec table: parse and edit runs have no cancel endpoint, +// edit runs have no list endpoint, and only workflow runs support +// update. Used so mismatch errors never redirect to a command that +// doesn't exist for the ID's actual kind. +func SupportsRunAction(k RunKind, action string) bool { + switch action { + case "cancel": + return k != KindParse && k != KindEdit + case "list": + return k != KindEdit + case "update": + return k == KindWorkflow + } + return true +} + +// ValidateRunID checks that id carries the ID prefix for kind. Typed +// run commands call this before hitting the API so an ID of the wrong +// type fails fast with a pointer to the right command instead of a +// confusing server-side 404. action is the invoked leaf ("get", +// "watch", ...) and is echoed into the redirect hint when the ID's +// actual kind supports it. +func ValidateRunID(kind RunKind, id, action string) error { + actual, ok := RunKindFromID(id) + if !ok { + return fmt.Errorf("%q is not a recognized %s run ID (expected %s prefix)", id, kind, RunIDPrefix(kind)) + } + if actual == kind { + return nil + } + if SupportsRunAction(actual, action) { + return fmt.Errorf("%s is an ID for %s runs, not %s runs; use 'extend %s runs %s %s'", + id, actual, kind, actual.Verb(), action, id) + } + return fmt.Errorf("%s is an ID for %s runs, not %s runs; %s runs do not support %s", + id, actual, kind, actual, action) +} diff --git a/internal/extendx/runs_test.go b/internal/extendx/runs_test.go index c8c21e7..b6817b0 100644 --- a/internal/extendx/runs_test.go +++ b/internal/extendx/runs_test.go @@ -1,6 +1,9 @@ package extendx -import "testing" +import ( + "strings" + "testing" +) func TestRunKindFromID(t *testing.T) { cases := []struct { @@ -37,6 +40,101 @@ func TestRunKindFromID(t *testing.T) { } } +func TestValidateRunID(t *testing.T) { + // Matching kind and prefix passes for every kind. + matching := map[RunKind]string{ + KindExtract: "exr_x", + KindParse: "pr_x", + KindClassify: "clr_x", + KindSplit: "splr_x", + KindWorkflow: "workflow_run_x", + KindEdit: "edr_x", + } + for kind, id := range matching { + if err := ValidateRunID(kind, id, "get"); err != nil { + t.Errorf("ValidateRunID(%s, %q) = %v; want nil", kind, id, err) + } + } + // A mismatched ID redirects to the owning command group. + err := ValidateRunID(KindParse, "exr_x", "get") + if err == nil { + t.Fatal("ValidateRunID(parse, exr_x) = nil; want mismatch error") + } + if want := "extend extract runs get exr_x"; !strings.Contains(err.Error(), want) { + t.Errorf("mismatch error %q does not mention %q", err, want) + } + // Workflow runs redirect to the plural group. + err = ValidateRunID(KindExtract, "workflow_run_x", "watch") + if err == nil { + t.Fatal("ValidateRunID(extract, workflow_run_x) = nil; want mismatch error") + } + if want := "extend workflows runs watch workflow_run_x"; !strings.Contains(err.Error(), want) { + t.Errorf("mismatch error %q does not mention %q", err, want) + } + // When the ID's actual kind lacks the invoked action, the error + // must not suggest a command that doesn't exist. + err = ValidateRunID(KindExtract, "pr_x", "cancel") + if err == nil { + t.Fatal("ValidateRunID(extract, pr_x, cancel) = nil; want mismatch error") + } + if strings.Contains(err.Error(), "extend parse runs cancel") { + t.Errorf("mismatch error %q suggests nonexistent 'parse runs cancel'", err) + } + if want := "parse runs do not support cancel"; !strings.Contains(err.Error(), want) { + t.Errorf("mismatch error %q does not mention %q", err, want) + } + err = ValidateRunID(KindWorkflow, "exr_x", "update") + if err == nil { + t.Fatal("ValidateRunID(workflow, exr_x, update) = nil; want mismatch error") + } + if want := "extract runs do not support update"; !strings.Contains(err.Error(), want) { + t.Errorf("mismatch error %q does not mention %q", err, want) + } + // Unknown prefixes fail. + if err := ValidateRunID(KindExtract, "file_x", "get"); err == nil { + t.Error("ValidateRunID(extract, file_x) = nil; want non-nil") + } + if err := ValidateRunID(KindExtract, "", "get"); err == nil { + t.Error("ValidateRunID(extract, \"\") = nil; want non-nil") + } +} + +func TestSupportsRunAction(t *testing.T) { + cases := []struct { + kind RunKind + action string + want bool + }{ + {KindParse, "cancel", false}, + {KindEdit, "cancel", false}, + {KindExtract, "cancel", true}, + {KindWorkflow, "cancel", true}, + {KindEdit, "list", false}, + {KindParse, "list", true}, + {KindWorkflow, "update", true}, + {KindExtract, "update", false}, + {KindEdit, "get", true}, + {KindEdit, "watch", true}, + {KindEdit, "delete", true}, + } + for _, tc := range cases { + if got := SupportsRunAction(tc.kind, tc.action); got != tc.want { + t.Errorf("SupportsRunAction(%s, %s) = %v; want %v", tc.kind, tc.action, got, tc.want) + } + } +} + +func TestRunKindVerb(t *testing.T) { + if got := KindWorkflow.Verb(); got != "workflows" { + t.Errorf("KindWorkflow.Verb() = %q; want workflows", got) + } + for _, k := range []RunKind{KindExtract, KindParse, KindClassify, KindSplit, KindEdit} { + if got := k.Verb(); got != string(k) { + t.Errorf("%s.Verb() = %q; want %q", k, got, string(k)) + } + } +} + func TestRunStatus_IsTerminal(t *testing.T) { terminal := []RunStatus{ StatusProcessed, StatusFailed, StatusCancelled, diff --git a/internal/extendx/wait.go b/internal/extendx/wait.go index 73669ce..581866a 100644 --- a/internal/extendx/wait.go +++ b/internal/extendx/wait.go @@ -55,8 +55,8 @@ const ( // ProfileShort: 1s -> 10s. Used by extract, classify, split, parse, // edit, and `runs watch` on those run kinds. ProfileShort WaitProfile = "short" - // ProfileLong: 2s -> 30s. Used by workflow runs (`extend run`) and - // batch watching (`extend batches watch`). + // ProfileLong: 2s -> 30s. Used by workflow runs (`extend workflows + // run`) and batch watching (`extend batches watch`). ProfileLong WaitProfile = "long" ) diff --git a/test/integration/runs_inline_config_test.go b/test/integration/runs_inline_config_test.go index e915e63..951a3bf 100644 --- a/test/integration/runs_inline_config_test.go +++ b/test/integration/runs_inline_config_test.go @@ -108,7 +108,7 @@ func TestClassifyRun_InlineConfig(t *testing.T) { if !strings.HasPrefix(submitted.ID, "clr_") { t.Fatalf("expected clr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete classify run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete classify run", "classify", "runs", "delete", submitted.ID, "-y") } // TestSplitRun_InlineConfig is the split analog of the classify test above. @@ -134,5 +134,5 @@ func TestSplitRun_InlineConfig(t *testing.T) { if !strings.HasPrefix(submitted.ID, "splr_") { t.Fatalf("expected splr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete split run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete split run", "split", "runs", "delete", submitted.ID, "-y") } diff --git a/test/integration/runs_test.go b/test/integration/runs_test.go index 231e86f..d199600 100644 --- a/test/integration/runs_test.go +++ b/test/integration/runs_test.go @@ -67,19 +67,19 @@ func TestExtractRun_AsyncLifecycle(t *testing.T) { if !strings.HasPrefix(submitted.ID, "exr_") { t.Fatalf("expected exr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete extract run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete extract run", "extract", "runs", "delete", submitted.ID, "-y") // Wait for terminal state via the watch command rather than open-coding // a poll loop — the CLI's watcher has its own logic that's worth // exercising end-to-end. - watchRes := runExtend(t, env, "runs", "watch", submitted.ID, "--timeout", "2m") + watchRes := runExtend(t, env, "extract", "runs", "watch", submitted.ID, "--timeout", "2m") if watchRes.ExitCode != 0 { - t.Fatalf("runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) + t.Fatalf("extract runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) } // Now fetch the terminal state and verify shape. - getRes := runExtend(t, env, "runs", "get", submitted.ID, "-o", "json") - getRes.requireOK(t, "runs", "get", submitted.ID) + getRes := runExtend(t, env, "extract", "runs", "get", submitted.ID, "-o", "json") + getRes.requireOK(t, "extract", "runs", "get", submitted.ID) var run map[string]any getRes.decodeJSON(t, &run) @@ -140,13 +140,13 @@ func TestWorkflowRun_AsyncLifecycle(t *testing.T) { workflowID := pickFirstID(t, env, "workflows") submitRes := runExtend(t, env, - "run", "testdata/sample.txt", + "workflows", "run", "testdata/sample.txt", "--using", workflowID, "--wait", "--timeout", "5m", "-o", "json", ) - submitRes.requireOK(t, "run", "--using", workflowID) + submitRes.requireOK(t, "workflows", "run", "--using", workflowID) var run map[string]any submitRes.decodeJSON(t, &run) @@ -155,7 +155,7 @@ func TestWorkflowRun_AsyncLifecycle(t *testing.T) { if !strings.HasPrefix(id, "workflow_run_") { t.Fatalf("expected workflow_run_ prefix on run id, got %q", id) } - rememberCleanup(t, env, "delete workflow run", "runs", "delete", id, "-y") + rememberCleanup(t, env, "delete workflow run", "workflows", "runs", "delete", id, "-y") if obj, _ := run["object"].(string); obj != "workflow_run" { t.Errorf("object = %q, want workflow_run", obj) @@ -219,12 +219,12 @@ func TestWorkflowBatch_ReturnsBatchID(t *testing.T) { workflowID := pickFirstID(t, env, "workflows") res := runExtend(t, env, - "run", "batch", + "workflows", "run", "batch", "testdata/sample.txt", "testdata/sample.txt", "--using", workflowID, "-o", "json", ) - res.requireOK(t, "run", "batch", "--using", workflowID) + res.requireOK(t, "workflows", "run", "batch", "--using", workflowID) var got map[string]any res.decodeJSON(t, &got) @@ -271,15 +271,15 @@ func TestClassifyRun_AsyncLifecycle(t *testing.T) { if !strings.HasPrefix(submitted.ID, "clr_") { t.Fatalf("expected clr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete classify run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete classify run", "classify", "runs", "delete", submitted.ID, "-y") - watchRes := runExtend(t, env, "runs", "watch", submitted.ID, "--timeout", "2m") + watchRes := runExtend(t, env, "classify", "runs", "watch", submitted.ID, "--timeout", "2m") if watchRes.ExitCode != 0 { - t.Fatalf("runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) + t.Fatalf("classify runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) } - getRes := runExtend(t, env, "runs", "get", submitted.ID, "-o", "json") - getRes.requireOK(t, "runs", "get", submitted.ID) + getRes := runExtend(t, env, "classify", "runs", "get", submitted.ID, "-o", "json") + getRes.requireOK(t, "classify", "runs", "get", submitted.ID) var run map[string]any getRes.decodeJSON(t, &run) @@ -319,15 +319,15 @@ func TestSplitRun_AsyncLifecycle(t *testing.T) { if !strings.HasPrefix(submitted.ID, "splr_") { t.Fatalf("expected splr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete split run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete split run", "split", "runs", "delete", submitted.ID, "-y") - watchRes := runExtend(t, env, "runs", "watch", submitted.ID, "--timeout", "2m") + watchRes := runExtend(t, env, "split", "runs", "watch", submitted.ID, "--timeout", "2m") if watchRes.ExitCode != 0 { - t.Fatalf("runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) + t.Fatalf("split runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) } - getRes := runExtend(t, env, "runs", "get", submitted.ID, "-o", "json") - getRes.requireOK(t, "runs", "get", submitted.ID) + getRes := runExtend(t, env, "split", "runs", "get", submitted.ID, "-o", "json") + getRes.requireOK(t, "split", "runs", "get", submitted.ID) var run map[string]any getRes.decodeJSON(t, &run) @@ -353,28 +353,28 @@ func TestWorkflowRun_UpdateMetadata(t *testing.T) { workflowID := pickFirstID(t, env, "workflows") // Workflow runs are async by default (--wait defaults to false on - // `run`). We don't wait here because the metadata update endpoint - // accepts in-flight runs. + // `workflows run`). We don't wait here because the metadata update + // endpoint accepts in-flight runs. submitRes := runExtend(t, env, - "run", "testdata/sample.txt", + "workflows", "run", "testdata/sample.txt", "--using", workflowID, "-o", "json", ) - submitRes.requireOK(t, "run") + submitRes.requireOK(t, "workflows", "run") var submitted struct { ID string `json:"id"` } submitRes.decodeJSON(t, &submitted) - rememberCleanup(t, env, "delete workflow run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete workflow run", "workflows", "runs", "delete", submitted.ID, "-y") updateRes := runExtend(t, env, - "runs", "update", submitted.ID, + "workflows", "runs", "update", submitted.ID, "--metadata", "customer=acme", "--tag", "integration-test", "-o", "json", ) - updateRes.requireOK(t, "runs", "update", submitted.ID) + updateRes.requireOK(t, "workflows", "runs", "update", submitted.ID) var updated map[string]any updateRes.decodeJSON(t, &updated) @@ -423,9 +423,9 @@ func TestRunsCancel_ExtractRun(t *testing.T) { ID string `json:"id"` } submitRes.decodeJSON(t, &submitted) - rememberCleanup(t, env, "delete extract run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete extract run", "extract", "runs", "delete", submitted.ID, "-y") - cancelRes := runExtend(t, env, "runs", "cancel", submitted.ID, "-y") + cancelRes := runExtend(t, env, "extract", "runs", "cancel", submitted.ID, "-y") // Cancel can legitimately race with processing: if the run completes // before the cancel reaches the server, the API reports "cannot cancel // terminal run" (a 4xx). Either case is acceptable; what's NOT @@ -440,8 +440,8 @@ func TestRunsCancel_ExtractRun(t *testing.T) { // wait, the cancel never took effect and that's a bug. var status string for i := 0; i < 10; i++ { - getRes := runExtend(t, env, "runs", "get", submitted.ID, "-o", "json") - getRes.requireOK(t, "runs", "get", submitted.ID) + getRes := runExtend(t, env, "extract", "runs", "get", submitted.ID, "-o", "json") + getRes.requireOK(t, "extract", "runs", "get", submitted.ID) var got map[string]any getRes.decodeJSON(t, &got) status, _ = got["status"].(string) @@ -457,19 +457,19 @@ func TestRunsCancel_ExtractRun(t *testing.T) { } // TestRunsUpdate_RejectsNonWorkflowRun confirms the CLI's guard against -// using `runs update` on non-workflow run IDs (the server only supports -// metadata mutation on workflow runs). +// using `workflows runs update` on non-workflow run IDs (the server only +// supports metadata mutation on workflow runs). func TestRunsUpdate_RejectsNonWorkflowRun(t *testing.T) { env := requireEnv(t) // Use a fake-but-correctly-prefixed extract run ID. The CLI's guard // fires before any HTTP call so the test doesn't need a real run. res := runExtend(t, env, - "runs", "update", "exr_nonexistent", + "workflows", "runs", "update", "exr_nonexistent", "--metadata", "k=v", ) if res.ExitCode == 0 { - t.Fatalf("runs update on non-workflow run should fail; got success: %s", res.Stdout) + t.Fatalf("workflows runs update on non-workflow run should fail; got success: %s", res.Stdout) } if !strings.Contains(string(res.Stderr), "workflow run") { t.Errorf("error message should mention 'workflow run'; got: %s", res.Stderr) @@ -500,15 +500,15 @@ func TestParseRun_AsyncLifecycle(t *testing.T) { if !strings.HasPrefix(submitted.ID, "pr_") { t.Fatalf("expected pr_ prefix on run id, got %q", submitted.ID) } - rememberCleanup(t, env, "delete parse run", "runs", "delete", submitted.ID, "-y") + rememberCleanup(t, env, "delete parse run", "parse", "runs", "delete", submitted.ID, "-y") - watchRes := runExtend(t, env, "runs", "watch", submitted.ID, "--timeout", "2m") + watchRes := runExtend(t, env, "parse", "runs", "watch", submitted.ID, "--timeout", "2m") if watchRes.ExitCode != 0 { - t.Fatalf("runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) + t.Fatalf("parse runs watch %s exited %d\nstderr: %s", submitted.ID, watchRes.ExitCode, watchRes.Stderr) } - getRes := runExtend(t, env, "runs", "get", submitted.ID, "-o", "json") - getRes.requireOK(t, "runs", "get", submitted.ID) + getRes := runExtend(t, env, "parse", "runs", "get", submitted.ID, "-o", "json") + getRes.requireOK(t, "parse", "runs", "get", submitted.ID) var run map[string]any getRes.decodeJSON(t, &run)