primitive-admin 1.1.0-alpha.4 → 1.1.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +325 -31
  2. package/assets/skill/skills/primitive-platform/SKILL.md +281 -0
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +268 -16
  5. package/dist/bin/primitive.js.map +1 -1
  6. package/dist/src/commands/admins.d.ts +2 -0
  7. package/dist/src/commands/admins.js +123 -18
  8. package/dist/src/commands/admins.js.map +1 -1
  9. package/dist/src/commands/analytics.d.ts +2 -0
  10. package/dist/src/commands/analytics.js +464 -55
  11. package/dist/src/commands/analytics.js.map +1 -1
  12. package/dist/src/commands/apps.d.ts +2 -0
  13. package/dist/src/commands/apps.js +55 -13
  14. package/dist/src/commands/apps.js.map +1 -1
  15. package/dist/src/commands/auth.d.ts +2 -0
  16. package/dist/src/commands/auth.js +177 -7
  17. package/dist/src/commands/auth.js.map +1 -1
  18. package/dist/src/commands/blob-buckets.d.ts +2 -0
  19. package/dist/src/commands/blob-buckets.js +422 -0
  20. package/dist/src/commands/blob-buckets.js.map +1 -0
  21. package/dist/src/commands/catalog.d.ts +2 -0
  22. package/dist/src/commands/catalog.js +34 -35
  23. package/dist/src/commands/catalog.js.map +1 -1
  24. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  25. package/dist/src/commands/collection-type-configs.js +178 -0
  26. package/dist/src/commands/collection-type-configs.js.map +1 -0
  27. package/dist/src/commands/collections.d.ts +2 -0
  28. package/dist/src/commands/collections.js +515 -0
  29. package/dist/src/commands/collections.js.map +1 -0
  30. package/dist/src/commands/comparisons.d.ts +2 -0
  31. package/dist/src/commands/comparisons.js +6 -6
  32. package/dist/src/commands/comparisons.js.map +1 -1
  33. package/dist/src/commands/cron-triggers.d.ts +2 -0
  34. package/dist/src/commands/cron-triggers.js +357 -0
  35. package/dist/src/commands/cron-triggers.js.map +1 -0
  36. package/dist/src/commands/database-types.d.ts +2 -0
  37. package/dist/src/commands/database-types.js +471 -0
  38. package/dist/src/commands/database-types.js.map +1 -0
  39. package/dist/src/commands/databases.d.ts +2 -0
  40. package/dist/src/commands/databases.js +1462 -83
  41. package/dist/src/commands/databases.js.map +1 -1
  42. package/dist/src/commands/documents.d.ts +2 -0
  43. package/dist/src/commands/documents.js +518 -11
  44. package/dist/src/commands/documents.js.map +1 -1
  45. package/dist/src/commands/email-templates.d.ts +2 -0
  46. package/dist/src/commands/email-templates.js +281 -0
  47. package/dist/src/commands/email-templates.js.map +1 -0
  48. package/dist/src/commands/env.d.ts +12 -0
  49. package/dist/src/commands/env.js +260 -0
  50. package/dist/src/commands/env.js.map +1 -0
  51. package/dist/src/commands/group-type-configs.d.ts +2 -0
  52. package/dist/src/commands/group-type-configs.js +181 -0
  53. package/dist/src/commands/group-type-configs.js.map +1 -0
  54. package/dist/src/commands/groups.d.ts +2 -0
  55. package/dist/src/commands/groups.js +48 -45
  56. package/dist/src/commands/groups.js.map +1 -1
  57. package/dist/src/commands/guides.d.ts +159 -0
  58. package/dist/src/commands/guides.js +449 -35
  59. package/dist/src/commands/guides.js.map +1 -1
  60. package/dist/src/commands/init.d.ts +17 -0
  61. package/dist/src/commands/init.js +811 -201
  62. package/dist/src/commands/init.js.map +1 -1
  63. package/dist/src/commands/integrations.d.ts +2 -0
  64. package/dist/src/commands/integrations.js +533 -46
  65. package/dist/src/commands/integrations.js.map +1 -1
  66. package/dist/src/commands/llm.d.ts +2 -0
  67. package/dist/src/commands/llm.js +4 -2
  68. package/dist/src/commands/llm.js.map +1 -1
  69. package/dist/src/commands/metadata.d.ts +2 -0
  70. package/dist/src/commands/metadata.js +194 -0
  71. package/dist/src/commands/metadata.js.map +1 -0
  72. package/dist/src/commands/prompts.d.ts +2 -0
  73. package/dist/src/commands/prompts.js +50 -52
  74. package/dist/src/commands/prompts.js.map +1 -1
  75. package/dist/src/commands/rule-sets.d.ts +3 -0
  76. package/dist/src/commands/rule-sets.js +388 -0
  77. package/dist/src/commands/rule-sets.js.map +1 -0
  78. package/dist/src/commands/scripts.d.ts +2 -0
  79. package/dist/src/commands/scripts.js +667 -0
  80. package/dist/src/commands/scripts.js.map +1 -0
  81. package/dist/src/commands/secrets.d.ts +2 -0
  82. package/dist/src/commands/secrets.js +108 -0
  83. package/dist/src/commands/secrets.js.map +1 -0
  84. package/dist/src/commands/skill.d.ts +2 -0
  85. package/dist/src/commands/skill.js +29 -0
  86. package/dist/src/commands/skill.js.map +1 -0
  87. package/dist/src/commands/sync.d.ts +175 -0
  88. package/dist/src/commands/sync.js +4412 -233
  89. package/dist/src/commands/sync.js.map +1 -1
  90. package/dist/src/commands/tokens.d.ts +2 -0
  91. package/dist/src/commands/tokens.js +122 -19
  92. package/dist/src/commands/tokens.js.map +1 -1
  93. package/dist/src/commands/users.d.ts +2 -0
  94. package/dist/src/commands/users.js +440 -22
  95. package/dist/src/commands/users.js.map +1 -1
  96. package/dist/src/commands/waitlist.d.ts +2 -0
  97. package/dist/src/commands/waitlist.js +10 -10
  98. package/dist/src/commands/waitlist.js.map +1 -1
  99. package/dist/src/commands/webhooks.d.ts +2 -0
  100. package/dist/src/commands/webhooks.js +391 -0
  101. package/dist/src/commands/webhooks.js.map +1 -0
  102. package/dist/src/commands/workflows.d.ts +49 -0
  103. package/dist/src/commands/workflows.js +1009 -123
  104. package/dist/src/commands/workflows.js.map +1 -1
  105. package/dist/src/lib/api-client.d.ts +1283 -0
  106. package/dist/src/lib/api-client.js +1129 -83
  107. package/dist/src/lib/api-client.js.map +1 -1
  108. package/dist/src/lib/auth-flow.d.ts +8 -0
  109. package/dist/src/lib/block-layout.d.ts +160 -0
  110. package/dist/src/lib/block-layout.js +451 -0
  111. package/dist/src/lib/block-layout.js.map +1 -0
  112. package/dist/src/lib/cli-manifest.d.ts +60 -0
  113. package/dist/src/lib/cli-manifest.js +70 -0
  114. package/dist/src/lib/cli-manifest.js.map +1 -0
  115. package/dist/src/lib/config.d.ts +53 -0
  116. package/dist/src/lib/config.js +92 -53
  117. package/dist/src/lib/config.js.map +1 -1
  118. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  119. package/dist/src/lib/confirm-prompt.js +110 -0
  120. package/dist/src/lib/confirm-prompt.js.map +1 -0
  121. package/dist/src/lib/constants.d.ts +2 -0
  122. package/dist/src/lib/constants.js +3 -0
  123. package/dist/src/lib/constants.js.map +1 -0
  124. package/dist/src/lib/crash-handlers.d.ts +20 -0
  125. package/dist/src/lib/crash-handlers.js +49 -0
  126. package/dist/src/lib/crash-handlers.js.map +1 -0
  127. package/dist/src/lib/credentials-store.d.ts +79 -0
  128. package/dist/src/lib/credentials-store.js +307 -0
  129. package/dist/src/lib/credentials-store.js.map +1 -0
  130. package/dist/src/lib/csv.d.ts +48 -0
  131. package/dist/src/lib/csv.js +177 -0
  132. package/dist/src/lib/csv.js.map +1 -0
  133. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  134. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  135. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  136. package/dist/src/lib/db-codegen/dbGenerator.d.ts +111 -0
  137. package/dist/src/lib/db-codegen/dbGenerator.js +371 -0
  138. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  139. package/dist/src/lib/db-codegen/dbNaming.d.ts +45 -0
  140. package/dist/src/lib/db-codegen/dbNaming.js +104 -0
  141. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  142. package/dist/src/lib/db-codegen/dbTemplates.d.ts +97 -0
  143. package/dist/src/lib/db-codegen/dbTemplates.js +159 -0
  144. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  145. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +78 -0
  146. package/dist/src/lib/db-codegen/dbTsTypes.js +112 -0
  147. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  148. package/dist/src/lib/env-resolver.d.ts +62 -0
  149. package/dist/src/lib/env-resolver.js +121 -0
  150. package/dist/src/lib/env-resolver.js.map +1 -0
  151. package/dist/src/lib/fetch.d.ts +5 -0
  152. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  153. package/dist/src/lib/generated-allowlist.js +220 -0
  154. package/dist/src/lib/generated-allowlist.js.map +1 -0
  155. package/dist/src/lib/init-config.d.ts +59 -0
  156. package/dist/src/lib/init-config.js +113 -0
  157. package/dist/src/lib/init-config.js.map +1 -0
  158. package/dist/src/lib/migration-nag.d.ts +49 -0
  159. package/dist/src/lib/migration-nag.js +163 -0
  160. package/dist/src/lib/migration-nag.js.map +1 -0
  161. package/dist/src/lib/output.d.ts +86 -0
  162. package/dist/src/lib/output.js +150 -8
  163. package/dist/src/lib/output.js.map +1 -1
  164. package/dist/src/lib/paginate.d.ts +33 -0
  165. package/dist/src/lib/paginate.js +42 -0
  166. package/dist/src/lib/paginate.js.map +1 -0
  167. package/dist/src/lib/project-config.d.ts +97 -0
  168. package/dist/src/lib/project-config.js +209 -0
  169. package/dist/src/lib/project-config.js.map +1 -0
  170. package/dist/src/lib/query-operators.d.ts +43 -0
  171. package/dist/src/lib/query-operators.js +80 -0
  172. package/dist/src/lib/query-operators.js.map +1 -0
  173. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  174. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  175. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  176. package/dist/src/lib/resolve-platform.d.ts +45 -0
  177. package/dist/src/lib/resolve-platform.js +43 -0
  178. package/dist/src/lib/resolve-platform.js.map +1 -0
  179. package/dist/src/lib/skill-installer.d.ts +23 -0
  180. package/dist/src/lib/skill-installer.js +135 -0
  181. package/dist/src/lib/skill-installer.js.map +1 -0
  182. package/dist/src/lib/snapshots.d.ts +99 -0
  183. package/dist/src/lib/snapshots.js +357 -0
  184. package/dist/src/lib/snapshots.js.map +1 -0
  185. package/dist/src/lib/sync-paths.d.ts +72 -0
  186. package/dist/src/lib/sync-paths.js +130 -0
  187. package/dist/src/lib/sync-paths.js.map +1 -0
  188. package/dist/src/lib/template.d.ts +97 -0
  189. package/dist/src/lib/template.js +336 -62
  190. package/dist/src/lib/template.js.map +1 -1
  191. package/dist/src/lib/token-inject.d.ts +56 -0
  192. package/dist/src/lib/token-inject.js +204 -0
  193. package/dist/src/lib/token-inject.js.map +1 -0
  194. package/dist/src/lib/toml-database-config.d.ts +152 -0
  195. package/dist/src/lib/toml-database-config.js +600 -0
  196. package/dist/src/lib/toml-database-config.js.map +1 -0
  197. package/dist/src/lib/toml-metadata-config.d.ts +139 -0
  198. package/dist/src/lib/toml-metadata-config.js +427 -0
  199. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  200. package/dist/src/lib/toml-params-validator.d.ts +129 -0
  201. package/dist/src/lib/toml-params-validator.js +298 -0
  202. package/dist/src/lib/toml-params-validator.js.map +1 -0
  203. package/dist/src/lib/version-check.d.ts +10 -0
  204. package/dist/src/lib/version-check.js +172 -0
  205. package/dist/src/lib/version-check.js.map +1 -0
  206. package/dist/src/lib/workflow-apply.d.ts +66 -0
  207. package/dist/src/lib/workflow-apply.js +117 -0
  208. package/dist/src/lib/workflow-apply.js.map +1 -0
  209. package/dist/src/lib/workflow-fragments.d.ts +41 -0
  210. package/dist/src/lib/workflow-fragments.js +121 -0
  211. package/dist/src/lib/workflow-fragments.js.map +1 -0
  212. package/dist/src/lib/workflow-toml-validator.d.ts +95 -0
  213. package/dist/src/lib/workflow-toml-validator.js +323 -0
  214. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  215. package/dist/src/types/index.d.ts +520 -0
  216. package/dist/src/validators.d.ts +64 -0
  217. package/dist/src/validators.js +63 -0
  218. package/dist/src/validators.js.map +1 -0
  219. package/package.json +18 -4
@@ -4,8 +4,112 @@ import * as TOML from "@iarna/toml";
4
4
  import { lookup as mimeLookup } from "mime-types";
5
5
  import { ApiClient } from "../lib/api-client.js";
6
6
  import { resolveAppId } from "../lib/config.js";
7
+ import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
8
+ import { expandWorkflow } from "../lib/workflow-fragments.js";
9
+ import { applyWorkflowBody, workflowKeyMismatchError, } from "../lib/workflow-apply.js";
10
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
7
11
  import chalk from "chalk";
8
- import { success, error, info, warn, keyValue, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, } from "../lib/output.js";
12
+ import { success, error, printApiError, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
13
+ /**
14
+ * Render the `Step Results` section of `workflows runs status`. Pure
15
+ * formatting helper — returns lines as strings so it's unit-testable
16
+ * (the caller console.logs them). For each step, emits:
17
+ *
18
+ * ` <id>: <status-label> <duration>`
19
+ *
20
+ * and, when applicable, indented follow-up lines:
21
+ *
22
+ * - `error_captured` step with `error` → ` error: <truncated message>`
23
+ * - forEach output with `errors[]` non-empty →
24
+ * ` forEach errors: <N/M> - first: <truncated message>`
25
+ *
26
+ * The truncation length matches `runs steps` ERROR column (80 chars,
27
+ * trailing "..." when truncated). See #688.
28
+ */
29
+ /**
30
+ * Terminal-status predicate for the `workflows preview --wait` poll loop
31
+ * (issue #941). A status is terminal when the run has reached an end state and
32
+ * the wait should stop polling — as opposed to a still-in-flight status
33
+ * ("running"/"queued"/"waiting") that should keep the loop going until the cap.
34
+ *
35
+ * The admin preview-status endpoint emits these terminal values (see
36
+ * `mapWorkflowStatus` / the server-side `isEnded` set in admin-api.ts):
37
+ * - "completed" (Cloudflare "complete" normalized)
38
+ * - "failed" (Cloudflare "errored" normalized)
39
+ * - "terminated" (an aborted preview — Codex P2: previously NOT treated as
40
+ * terminal, so an aborted preview was misreported as a wait timeout)
41
+ *
42
+ * We accept the un-normalized Cloudflare spellings too ("complete"/"errored")
43
+ * defensively, in case a status slips through un-normalized.
44
+ */
45
+ export function isPreviewWaitTerminalStatus(s) {
46
+ return (s === "completed" ||
47
+ s === "complete" ||
48
+ s === "failed" ||
49
+ s === "errored" ||
50
+ s === "terminated");
51
+ }
52
+ export function renderRunStatusStepResults(stepResults) {
53
+ const lines = [];
54
+ for (const step of stepResults || []) {
55
+ const statusLabel = step.skipped
56
+ ? chalk.gray("skipped")
57
+ : step.status === "error_captured"
58
+ ? chalk.yellow("error_captured")
59
+ : step.status === "failed"
60
+ ? chalk.red("failed")
61
+ : step.status === "completed"
62
+ ? chalk.green("completed")
63
+ : step.status || "?";
64
+ const durationLabel = formatDuration(step.durationMs);
65
+ lines.push(` ${step.id}: ${statusLabel} ${durationLabel}`);
66
+ // Single-step error_captured: show the captured error message.
67
+ if (step.status === "error_captured" && step.error) {
68
+ const errMsg = typeof step.error === "string" ? step.error : String(step.error);
69
+ const truncated = errMsg.length > 80 ? errMsg.slice(0, 77) + "..." : errMsg;
70
+ lines.push(` ${chalk.dim("error:")} ${chalk.yellow(truncated)}`);
71
+ }
72
+ else if (step.status === "error_captured" && step.errorDetails) {
73
+ // No top-level error but errorDetails present — hint to drill in.
74
+ lines.push(` ${chalk.dim("error: <see runs step-detail for full errorDetails>")}`);
75
+ }
76
+ // forEach aggregated errors: a one-line summary surfaces the count
77
+ // of `error_captured` iterations and a sample message (#688).
78
+ const fo = step.output;
79
+ if (fo && typeof fo === "object" && Array.isArray(fo.errors) && fo.errors.length > 0) {
80
+ const total = (Array.isArray(fo.items) ? fo.items.length : 0)
81
+ || (fo.totalSucceeded ?? 0) + (fo.totalFailed ?? 0);
82
+ const firstErr = (Array.isArray(fo.errorMessages) && fo.errorMessages.length > 0
83
+ ? fo.errorMessages[0]
84
+ : (fo.errors[0]?.error ?? "?"));
85
+ const firstStr = typeof firstErr === "string" ? firstErr : String(firstErr);
86
+ const truncated = firstStr.length > 80 ? firstStr.slice(0, 77) + "..." : firstStr;
87
+ lines.push(` ${chalk.dim("forEach errors:")} ${chalk.yellow(`${fo.totalFailed ?? fo.errors.length}/${total}`)} ${chalk.dim("- first:")} ${chalk.yellow(truncated)}`);
88
+ }
89
+ }
90
+ return lines;
91
+ }
92
+ /**
93
+ * Issue #687 (review feedback): render an unambiguous label for a workflow
94
+ * config slot ("active config", "draft config", etc.) when the slot may be
95
+ * unnamed. Falls back to a short ID prefix so users staging multiple unnamed
96
+ * configs can still tell them apart.
97
+ *
98
+ * formatConfigSlotLabel("active config", "v2", "01KRT...") → 'active config "v2"'
99
+ * formatConfigSlotLabel("active config", null, "01KRTABCDXYZ...") → 'active config (01KRTABCDXYZ…)'
100
+ * formatConfigSlotLabel("active config", null, null) → 'active config'
101
+ *
102
+ * Exported for testability.
103
+ */
104
+ export function formatConfigSlotLabel(slot, configName, configId, prefixLength = 12) {
105
+ if (configName) {
106
+ return `${slot} "${configName}"`;
107
+ }
108
+ if (configId) {
109
+ return `${slot} (${String(configId).slice(0, prefixLength)}…)`;
110
+ }
111
+ return slot;
112
+ }
9
113
  export function registerWorkflowsCommands(program) {
10
114
  const workflows = program
11
115
  .command("workflows")
@@ -63,35 +167,68 @@ Examples:
63
167
  .option("--name <name>", "Display name")
64
168
  .option("--description <desc>", "Description")
65
169
  .option("--from-file <path>", "Load workflow from TOML file")
170
+ .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
171
+ .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
66
172
  .option("--json", "Output as JSON")
67
173
  .action(async (appId, options) => {
68
174
  const resolvedAppId = resolveAppId(appId, options);
69
175
  const client = new ApiClient();
70
176
  let payload;
71
177
  if (options.fromFile) {
178
+ let tomlData;
72
179
  try {
73
180
  const content = readFileSync(options.fromFile, "utf-8");
74
- const tomlData = TOML.parse(content);
75
- const workflow = tomlData.workflow || tomlData;
76
- payload = {
77
- workflowKey: workflow.key || workflow.workflowKey,
78
- name: workflow.name,
79
- description: workflow.description,
80
- steps: tomlData.steps || [],
81
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
82
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
83
- perUserMaxRunning: workflow.perUserMaxRunning,
84
- perUserMaxQueued: workflow.perUserMaxQueued,
85
- perAppMaxRunning: workflow.perAppMaxRunning,
86
- perAppMaxQueued: workflow.perAppMaxQueued,
87
- queueTtlSeconds: workflow.queueTtlSeconds,
88
- dequeueOrder: workflow.dequeueOrder,
89
- };
181
+ tomlData = TOML.parse(content);
90
182
  }
91
183
  catch (err) {
92
184
  error(`Failed to read TOML file: ${err.message}`);
93
185
  process.exit(1);
94
186
  }
187
+ // Issue #685: reject misnested headers (e.g. [steps.<id>.request])
188
+ // before we send anything to the server. These parse to TOML
189
+ // sub-tables that the runtime silently ignores. We validate
190
+ // outside the parse try-block so the diagnostic isn't masked by
191
+ // the generic "Failed to read TOML" handler.
192
+ const tomlErrors = validateWorkflowToml(tomlData);
193
+ if (tomlErrors.length > 0) {
194
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
195
+ process.exit(1);
196
+ }
197
+ const workflow = tomlData.workflow || tomlData;
198
+ payload = {
199
+ workflowKey: workflow.key || workflow.workflowKey,
200
+ name: workflow.name,
201
+ description: workflow.description,
202
+ steps: tomlData.steps || [],
203
+ inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
204
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
205
+ perUserMaxRunning: workflow.perUserMaxRunning,
206
+ perUserMaxQueued: workflow.perUserMaxQueued,
207
+ perAppMaxRunning: workflow.perAppMaxRunning,
208
+ perAppMaxQueued: workflow.perAppMaxQueued,
209
+ queueTtlSeconds: workflow.queueTtlSeconds,
210
+ dequeueOrder: workflow.dequeueOrder,
211
+ requiresClientApply: workflow.requiresClientApply,
212
+ // #807: align create --from-file with sync/update — read syncCallable
213
+ // from the TOML so all three entry points behave identically.
214
+ syncCallable: workflow.syncCallable,
215
+ // #571: carry accessRule through so a TOML's access-control rule
216
+ // lands on create instead of being silently dropped. On create the
217
+ // server default is already null, so coalesce a falsy/absent rule to
218
+ // undefined (omit) rather than sending "" — both clear, but undefined
219
+ // is the precise "no rule declared" signal.
220
+ accessRule: workflow.accessRule || undefined,
221
+ // #1173: forward the workflow principal mode (`runAs`) from the TOML,
222
+ // matching `sync push` create (sync.ts ~3345). Without this, a
223
+ // `runAs="system"` TOML reached the server as non-system, so the
224
+ // identity validator correctly rejected system-only steps (e.g.
225
+ // `iterate-users`). Create semantics — `workflow.runAs || undefined`
226
+ // (absent/empty → server default), NOT the presence-aware clearing
227
+ // form that `sync push` update uses. `capabilities` is intentionally
228
+ // NOT forwarded: `sync push` doesn't serialize/persist it today, so
229
+ // forwarding it only here would create a new asymmetry (see #1173).
230
+ runAs: workflow.runAs || undefined,
231
+ };
95
232
  }
96
233
  else {
97
234
  if (!options.key || !options.name) {
@@ -105,6 +242,12 @@ Examples:
105
242
  steps: [],
106
243
  };
107
244
  }
245
+ if (options.requiresClientApply !== undefined) {
246
+ payload.requiresClientApply = options.requiresClientApply === "true";
247
+ }
248
+ if (options.syncCallable !== undefined) {
249
+ payload.syncCallable = options.syncCallable === "true";
250
+ }
108
251
  try {
109
252
  const result = await client.createWorkflow(resolvedAppId, payload);
110
253
  if (options.json) {
@@ -116,7 +259,9 @@ Examples:
116
259
  keyValue("Key", result.workflow?.workflowKey);
117
260
  }
118
261
  catch (err) {
119
- error(err.message);
262
+ // #1173: render the server's structured `details[]` (e.g. the specific
263
+ // identity-validation reason) instead of only the generic message.
264
+ printApiError(err);
120
265
  process.exit(1);
121
266
  }
122
267
  });
@@ -137,19 +282,30 @@ Examples:
137
282
  return;
138
283
  }
139
284
  const wf = result.workflow;
140
- keyValue("Workflow ID", wf.workflowId);
141
- keyValue("Key", wf.workflowKey);
142
- keyValue("Name", wf.name);
143
- keyValue("Description", wf.description);
144
- keyValue("Status", formatStatus(wf.status));
145
- keyValue("Active Config", wf.activeConfigId || "-");
146
- keyValue("Latest Revision", wf.latestRevision || "-");
285
+ printResult("Workflow ID", wf.workflowId);
286
+ printResult("Key", wf.workflowKey);
287
+ printResult("Name", wf.name);
288
+ printResult("Description", wf.description);
289
+ printResult("Status", formatStatus(wf.status));
290
+ printResult("Active Config", wf.activeConfigId || "-");
291
+ printResult("Latest Revision", wf.latestRevision || "-");
292
+ printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
293
+ printResult("Sync Callable", wf.syncCallable === true ? "yes" : "no");
294
+ // #1233 — runAs unset means the engine defaults to "caller"; show that
295
+ // default rather than a blank so the deployed principal mode is visible.
296
+ printResult("Run As", wf.runAs || "caller");
297
+ printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
298
+ ? wf.capabilities.join(", ")
299
+ : "-");
300
+ if (wf.accessRule) {
301
+ printResult("Access Rule", wf.accessRule);
302
+ }
147
303
  divider();
148
304
  info("Queue Settings:");
149
- keyValue(" Per User Max Running", wf.perUserMaxRunning);
150
- keyValue(" Per User Max Queued", wf.perUserMaxQueued);
151
- keyValue(" Per App Max Running", wf.perAppMaxRunning);
152
- keyValue(" Dequeue Order", wf.dequeueOrder);
305
+ printResult(" Per User Max Running", wf.perUserMaxRunning);
306
+ printResult(" Per User Max Queued", wf.perUserMaxQueued);
307
+ printResult(" Per App Max Running", wf.perAppMaxRunning);
308
+ printResult(" Dequeue Order", wf.dequeueOrder);
153
309
  if (wf.inputSchema) {
154
310
  divider();
155
311
  info("Input Schema:");
@@ -193,7 +349,7 @@ Examples:
193
349
  // Update workflow
194
350
  workflows
195
351
  .command("update")
196
- .description("Update workflow metadata")
352
+ .description("Update workflow metadata, or push a new body with --from-file")
197
353
  .argument("<workflow-id>", "Workflow ID")
198
354
  .option("--app <app-id>", "App ID (uses current app if not specified)")
199
355
  .option("--name <name>", "Display name")
@@ -202,9 +358,114 @@ Examples:
202
358
  .option("--per-user-max-running <n>", "Max running per user")
203
359
  .option("--per-user-max-queued <n>", "Max queued per user")
204
360
  .option("--dequeue-order <order>", "Dequeue order: fifo, lifo")
361
+ .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
362
+ .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
363
+ .option("--access-rule <expr>", "CEL expression for workflow access (empty string to clear)")
364
+ .option("--from-file <path>", "Push a revised body (metadata + steps) from a TOML file, keeping the same key/id. " +
365
+ "Overwrites the active config in place — live immediately (no separate publish). " +
366
+ "Explicit metadata flags above override values in the TOML.")
205
367
  .option("--json", "Output as JSON")
206
368
  .action(async (workflowId, options) => {
207
369
  const resolvedAppId = resolveAppId(undefined, options);
370
+ // --from-file: push a full body (metadata + steps) to the existing
371
+ // workflow, keeping its key/id. Symmetric with `create --from-file` and
372
+ // mirrors the `sync push` update-existing path via the shared
373
+ // `applyWorkflowBody` helper (#1249).
374
+ if (options.fromFile) {
375
+ let tomlData;
376
+ try {
377
+ // Expand top-level `include = [...]` workflow fragments before
378
+ // deriving steps, matching what `sync push` does (`parseTomlFile`
379
+ // runs `expandWorkflowTomlData`). `update --from-file` overwrites an
380
+ // existing workflow's live active config, so parsing the raw TOML
381
+ // and reading only `tomlData.steps` would drop fragment-supplied
382
+ // steps — an include-only workflow would overwrite the live body
383
+ // with an empty step list (silent data loss, #1286). `expandWorkflow`
384
+ // reads + parses + splices fragments; a TOML with no `include` key is
385
+ // returned unchanged, so the non-fragment case is unaffected.
386
+ tomlData = expandWorkflow(options.fromFile);
387
+ }
388
+ catch (err) {
389
+ error(`Failed to read TOML file: ${err.message}`);
390
+ process.exit(1);
391
+ }
392
+ // Issue #685: reject misnested headers before sending anything to the
393
+ // server — identical validation to `create --from-file`. Runs on the
394
+ // expanded data (post fragment splice), matching `sync push`.
395
+ const tomlErrors = validateWorkflowToml(tomlData);
396
+ if (tomlErrors.length > 0) {
397
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
398
+ process.exit(1);
399
+ }
400
+ const tomlWorkflow = tomlData.workflow || tomlData;
401
+ const steps = tomlData.steps || [];
402
+ const client = new ApiClient();
403
+ // Fetch the existing workflow: we need its real workflowKey (Fork 2
404
+ // key-mismatch guard) and its activeConfigId (live body overwrite).
405
+ let existing;
406
+ try {
407
+ const got = await client.getWorkflow(resolvedAppId, workflowId);
408
+ existing = got?.workflow;
409
+ }
410
+ catch (err) {
411
+ printApiError(err);
412
+ process.exit(1);
413
+ }
414
+ if (!existing) {
415
+ error(`Workflow not found: ${workflowId}`);
416
+ process.exit(1);
417
+ }
418
+ // Fork 2: a TOML key that contradicts the target's key is almost
419
+ // certainly the wrong file — error rather than silently ignore it.
420
+ const mismatch = workflowKeyMismatchError(tomlWorkflow, existing.workflowKey);
421
+ if (mismatch) {
422
+ error(mismatch);
423
+ process.exit(1);
424
+ }
425
+ // Fork 1 (B): explicit `update` flags override TOML-derived metadata.
426
+ const mergedWorkflow = { ...tomlWorkflow };
427
+ if (options.name)
428
+ mergedWorkflow.name = options.name;
429
+ if (options.description)
430
+ mergedWorkflow.description = options.description;
431
+ if (options.status)
432
+ mergedWorkflow.status = options.status;
433
+ if (options.perUserMaxRunning) {
434
+ mergedWorkflow.perUserMaxRunning = parseInt(options.perUserMaxRunning);
435
+ }
436
+ if (options.perUserMaxQueued) {
437
+ mergedWorkflow.perUserMaxQueued = parseInt(options.perUserMaxQueued);
438
+ }
439
+ if (options.dequeueOrder)
440
+ mergedWorkflow.dequeueOrder = options.dequeueOrder;
441
+ if (options.requiresClientApply !== undefined) {
442
+ mergedWorkflow.requiresClientApply = options.requiresClientApply === "true";
443
+ }
444
+ if (options.syncCallable !== undefined) {
445
+ mergedWorkflow.syncCallable = options.syncCallable === "true";
446
+ }
447
+ if (options.accessRule !== undefined) {
448
+ mergedWorkflow.accessRule = options.accessRule;
449
+ }
450
+ try {
451
+ const { updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, mergedWorkflow, steps, { activeConfigId: existing.activeConfigId });
452
+ if (options.json) {
453
+ json(fullWorkflow);
454
+ return;
455
+ }
456
+ success(`Workflow updated from ${options.fromFile} (${updateSlotLabel}).`);
457
+ keyValue("Workflow ID", workflowId);
458
+ keyValue("Key", existing.workflowKey);
459
+ keyValue("Steps", steps.length);
460
+ }
461
+ catch (err) {
462
+ // Surface the server's structured `details[]` (e.g. syncCallable /
463
+ // identity validation reasons) like create/sync push do.
464
+ printApiError(err);
465
+ process.exit(1);
466
+ }
467
+ return;
468
+ }
208
469
  const payload = {};
209
470
  if (options.name)
210
471
  payload.name = options.name;
@@ -218,6 +479,19 @@ Examples:
218
479
  payload.perUserMaxQueued = parseInt(options.perUserMaxQueued);
219
480
  if (options.dequeueOrder)
220
481
  payload.dequeueOrder = options.dequeueOrder;
482
+ if (options.requiresClientApply !== undefined) {
483
+ payload.requiresClientApply = options.requiresClientApply === "true";
484
+ }
485
+ if (options.syncCallable !== undefined) {
486
+ payload.syncCallable = options.syncCallable === "true";
487
+ }
488
+ // #571: presence guard, NOT truthiness — `--access-rule ""` must reach
489
+ // the server (it clears the rule) while an omitted flag leaves
490
+ // options.accessRule undefined so the server's hasOwnProperty guard
491
+ // leaves the existing rule untouched.
492
+ if (options.accessRule !== undefined) {
493
+ payload.accessRule = options.accessRule;
494
+ }
221
495
  if (Object.keys(payload).length === 0) {
222
496
  error("No update options specified.");
223
497
  process.exit(1);
@@ -232,7 +506,8 @@ Examples:
232
506
  success("Workflow updated.");
233
507
  }
234
508
  catch (err) {
235
- error(err.message);
509
+ // #1173: surface structured `details[]` on update too.
510
+ printApiError(err);
236
511
  process.exit(1);
237
512
  }
238
513
  });
@@ -246,24 +521,16 @@ Examples:
246
521
  .option("-y, --yes", "Skip confirmation prompt")
247
522
  .action(async (workflowId, options) => {
248
523
  const resolvedAppId = resolveAppId(undefined, options);
249
- if (!options.yes) {
250
- const action = options.hard ? "permanently delete" : "archive";
251
- const inquirer = await import("inquirer");
252
- const { confirm } = await inquirer.default.prompt([
253
- {
254
- type: "confirm",
255
- name: "confirm",
256
- message: `Are you sure you want to ${action} workflow ${workflowId}?`,
257
- default: false,
258
- },
259
- ]);
260
- if (!confirm) {
261
- info("Cancelled.");
262
- return;
263
- }
264
- }
265
524
  const client = new ApiClient();
266
525
  try {
526
+ if (!options.yes) {
527
+ const action = options.hard ? "permanently delete" : "archive";
528
+ const confirmed = await confirmPrompt(`Are you sure you want to ${action} workflow ${workflowId}?`);
529
+ if (!confirmed) {
530
+ info("Cancelled.");
531
+ return;
532
+ }
533
+ }
267
534
  if (options.hard) {
268
535
  await client.deleteWorkflow(resolvedAppId, workflowId);
269
536
  success("Workflow deleted.");
@@ -271,6 +538,10 @@ Examples:
271
538
  else {
272
539
  await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
273
540
  success("Workflow archived.");
541
+ // #1238 — archiving keeps the workflowKey reserved, so recreating
542
+ // under the same key fails with "workflowKey already exists". Point
543
+ // at the remedy so the reserved-key behavior is discoverable.
544
+ info(`The workflowKey stays reserved while archived. To free it for reuse, run: primitive workflows delete ${workflowId} --hard`);
274
545
  }
275
546
  }
276
547
  catch (err) {
@@ -278,12 +549,39 @@ Examples:
278
549
  process.exit(1);
279
550
  }
280
551
  });
552
+ // Expand a workflow TOML and print the result (no server contact).
553
+ //
554
+ // Authors use this to inspect what `include = [...]` fragments produce
555
+ // before pushing. Surfaces include-collision and unique-id failures with
556
+ // helpful messages without going through `sync push`.
557
+ workflows
558
+ .command("expand")
559
+ .description("Expand a workflow TOML's include fragments and print the result")
560
+ .argument("<file>", "Path to the workflow TOML file")
561
+ .option("--format <format>", "Output format: json (default) or toml", "json")
562
+ .action((file, options) => {
563
+ try {
564
+ const expanded = expandWorkflow(file);
565
+ if (options.format === "toml") {
566
+ // Strip the `include` key (already removed by expander) and emit
567
+ // valid TOML so the result is round-trippable.
568
+ console.log(TOML.stringify(expanded));
569
+ }
570
+ else {
571
+ console.log(JSON.stringify(expanded, null, 2));
572
+ }
573
+ }
574
+ catch (err) {
575
+ error(err?.message ?? String(err));
576
+ process.exit(1);
577
+ }
578
+ });
281
579
  // Draft subcommand
282
580
  const draft = workflows.command("draft").description("Manage workflow draft");
283
581
  // Update draft
284
582
  draft
285
583
  .command("update")
286
- .description("Update workflow draft steps")
584
+ .description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
287
585
  .argument("<workflow-id>", "Workflow ID")
288
586
  .option("--app <app-id>", "App ID (uses current app if not specified)")
289
587
  .option("--from-file <path>", "Load steps from TOML file")
@@ -294,21 +592,27 @@ Examples:
294
592
  error("--from-file is required");
295
593
  process.exit(1);
296
594
  }
297
- let payload;
595
+ let tomlData;
298
596
  try {
299
597
  const content = readFileSync(options.fromFile, "utf-8");
300
- const tomlData = TOML.parse(content);
301
- const workflow = tomlData.workflow || tomlData;
302
- payload = {
303
- steps: tomlData.steps || [],
304
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
305
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
306
- };
598
+ tomlData = TOML.parse(content);
307
599
  }
308
600
  catch (err) {
309
601
  error(`Failed to read TOML file: ${err.message}`);
310
602
  process.exit(1);
311
603
  }
604
+ // Issue #685: reject misnested headers before pushing.
605
+ const tomlErrors = validateWorkflowToml(tomlData);
606
+ if (tomlErrors.length > 0) {
607
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
608
+ process.exit(1);
609
+ }
610
+ const workflow = tomlData.workflow || tomlData;
611
+ const payload = {
612
+ steps: tomlData.steps || [],
613
+ inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
614
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
615
+ };
312
616
  const client = new ApiClient();
313
617
  try {
314
618
  const result = await client.updateWorkflowDraft(resolvedAppId, workflowId, payload);
@@ -318,8 +622,27 @@ Examples:
318
622
  }
319
623
  success("Draft updated.");
320
624
  keyValue("Steps", result.draft?.steps?.length || 0);
625
+ // Issue #687: surface server-side deprecation hint when applicable.
626
+ if (result.deprecation) {
627
+ warn(`[deprecated] ${result.deprecation}`);
628
+ }
321
629
  }
322
630
  catch (err) {
631
+ // Issue #987: config-model workflows reject the legacy draft slot with
632
+ // a 409 (the draft never executes). Surface the actionable config path
633
+ // cleanly instead of dumping a raw API error.
634
+ if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
635
+ error("This workflow has an active configuration, so the legacy draft slot is inert " +
636
+ "(it won't change what runs). For dev-loop edits, use 'sync push' — it writes the\n" +
637
+ "active config directly and activates the workflow end-to-end:\n" +
638
+ " primitive sync push\n" +
639
+ "For staged rollouts, create + activate a config, then flip the status to active\n" +
640
+ "(configs activate only sets the active config; it does not change the workflow status):\n" +
641
+ " primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
642
+ " primitive workflows configs activate <workflow-id> <config-id>\n" +
643
+ " primitive workflows update <workflow-id> --status active");
644
+ process.exit(1);
645
+ }
323
646
  error(err.message);
324
647
  process.exit(1);
325
648
  }
@@ -327,7 +650,7 @@ Examples:
327
650
  // Publish workflow
328
651
  workflows
329
652
  .command("publish")
330
- .description("Publish the current draft as a new revision")
653
+ .description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
331
654
  .argument("<workflow-id>", "Workflow ID")
332
655
  .option("--app <app-id>", "App ID (uses current app if not specified)")
333
656
  .option("--json", "Output as JSON")
@@ -343,8 +666,27 @@ Examples:
343
666
  success("Workflow published.");
344
667
  keyValue("Revision ID", result.revision?.revisionId);
345
668
  keyValue("Published At", formatDate(result.revision?.publishedAt));
669
+ // Issue #687: surface server-side deprecation hint when applicable.
670
+ if (result.deprecation) {
671
+ warn(`[deprecated] ${result.deprecation}`);
672
+ }
346
673
  }
347
674
  catch (err) {
675
+ // Issue #987: 'publish' is inert on config-model workflows (production
676
+ // runs read the active config), so the server rejects with a 409.
677
+ // Point the user at the config primitives instead of a raw API error.
678
+ if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
679
+ error("This workflow has an active configuration, so 'publish' is inert " +
680
+ "(it bumps a legacy pointer that doesn't change what runs). For dev-loop edits, use\n" +
681
+ "'sync push' — it writes the active config directly and activates the workflow end-to-end:\n" +
682
+ " primitive sync push\n" +
683
+ "For staged rollouts, create + activate a config, then flip the status to active\n" +
684
+ "(configs activate only sets the active config; it does not change the workflow status):\n" +
685
+ " primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
686
+ " primitive workflows configs activate <workflow-id> <config-id>\n" +
687
+ " primitive workflows update <workflow-id> --status active");
688
+ process.exit(1);
689
+ }
348
690
  error(err.message);
349
691
  process.exit(1);
350
692
  }
@@ -355,12 +697,18 @@ Examples:
355
697
  .description("Run a preview execution of the workflow")
356
698
  .argument("<workflow-id>", "Workflow ID")
357
699
  .option("--app <app-id>", "App ID (uses current app if not specified)")
358
- .option("--config <config-id>", "Use specific configuration (uses draft if not specified)")
700
+ .option("--config <config-id>", "Use specific configuration")
701
+ .option("--draft", "Force preview of the draft version, even if active is newer")
702
+ .option("--active", "Force preview of the active config, even if a newer draft exists (issue #687)")
359
703
  .option("--input <json>", "Root input as JSON")
360
704
  .option("--wait", "Wait for completion and show result")
361
705
  .option("--json", "Output as JSON")
362
706
  .action(async (workflowId, options) => {
363
707
  const resolvedAppId = resolveAppId(undefined, options);
708
+ if (options.draft && options.active) {
709
+ error("--draft and --active are mutually exclusive");
710
+ process.exit(1);
711
+ }
364
712
  let rootInput;
365
713
  if (options.input) {
366
714
  try {
@@ -376,7 +724,15 @@ Examples:
376
724
  const result = await client.previewWorkflow(resolvedAppId, workflowId, {
377
725
  rootInput,
378
726
  configId: options.config,
727
+ useDraft: options.draft || false,
728
+ // Issue #687: --active forces the active config even when a newer
729
+ // draft exists (the inverse of --draft).
730
+ preferActive: options.active || false,
379
731
  });
732
+ // Display warning from the server (e.g. "previewing draft because newer than active")
733
+ if (result.warning) {
734
+ warn(result.warning);
735
+ }
380
736
  if (!options.wait) {
381
737
  if (options.json) {
382
738
  json(result);
@@ -384,26 +740,72 @@ Examples:
384
740
  }
385
741
  success("Preview started.");
386
742
  keyValue("Instance ID", result.instanceId);
743
+ // Issue #687: name the side we ran so the user always knows which
744
+ // version produced the output (the "print the source" pattern).
745
+ const source = result.source;
746
+ if (source === "draft") {
747
+ info("Previewing draft version.");
748
+ }
749
+ else if (source === "active") {
750
+ // Issue #687 (review feedback): when configName is missing,
751
+ // fall back to a short ID prefix so users staging multiple
752
+ // unnamed configs can disambiguate.
753
+ const label = formatConfigSlotLabel("active config", result.configName, result.configId);
754
+ info(`Previewing ${label}.`);
755
+ }
756
+ else if (source === "config") {
757
+ const label = formatConfigSlotLabel("the requested config", result.configName, result.configId);
758
+ info(`Previewing ${label}.`);
759
+ }
387
760
  info("Use 'workflows runs status' to check progress.");
388
761
  return;
389
762
  }
390
- // Poll for completion
763
+ // Poll for completion. Issue #941: track whether the loop broke on a
764
+ // terminal state vs. ran to exhaustion, so a timed-out wait is
765
+ // distinguishable from a workflow that legitimately reported "running".
391
766
  info("Waiting for completion...");
392
767
  let status;
393
- const maxAttempts = 60;
768
+ // Fixed ~60s cap (60 × 1s sleep + RTT). PRIMITIVE_PREVIEW_WAIT_ATTEMPTS
769
+ // overrides the iteration count for tests that exercise the
770
+ // poll-loop-exhaustion path without a full 60s wait; unset in normal use.
771
+ const maxAttempts = (() => {
772
+ const raw = process.env.PRIMITIVE_PREVIEW_WAIT_ATTEMPTS;
773
+ const parsed = raw ? parseInt(raw, 10) : NaN;
774
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 60;
775
+ })();
776
+ let reachedTerminal = false;
394
777
  for (let i = 0; i < maxAttempts; i++) {
395
778
  await new Promise((r) => setTimeout(r, 1000));
396
779
  const statusResult = await client.getPreviewStatus(resolvedAppId, workflowId, result.instanceId);
397
780
  status = statusResult.status;
398
- if (status?.status === "completed" || status?.status === "failed") {
781
+ const s = status?.status;
782
+ // Issue #941 (Codex P2): an aborted preview reports
783
+ // status:"terminated" from the admin preview-status endpoint, which
784
+ // is a real terminal state — the wait did NOT time out. The shared
785
+ // predicate includes it so the loop breaks and `waitTimedOut` stays
786
+ // false (vs. the prior check that omitted "terminated" and let the
787
+ // loop run to the cap, misreporting an abort as a timeout).
788
+ if (isPreviewWaitTerminalStatus(s)) {
789
+ reachedTerminal = true;
399
790
  break;
400
791
  }
401
792
  }
793
+ // Issue #941: on poll-loop exhaustion with a still-non-terminal run,
794
+ // emit an explicit timeout signal and exit non-zero (124, the GNU
795
+ // `timeout` convention) instead of silently returning the bare
796
+ // non-terminal status as if the wait succeeded.
797
+ const waitTimedOut = !reachedTerminal;
402
798
  if (options.json) {
403
- json({ instanceId: result.instanceId, status });
799
+ // Additive field only — `status` is preserved so existing parsers
800
+ // don't break; `waitTimedOut` lets automation tell "still running,
801
+ // wait gave up" from "finished".
802
+ json({ instanceId: result.instanceId, status, waitTimedOut });
803
+ if (waitTimedOut) {
804
+ process.exit(124);
805
+ }
404
806
  return;
405
807
  }
406
- if (status?.status === "completed") {
808
+ if (status?.status === "completed" || status?.status === "complete") {
407
809
  success("Preview completed.");
408
810
  if (status.output) {
409
811
  console.log("\nOutput:");
@@ -417,15 +819,24 @@ Examples:
417
819
  });
418
820
  }
419
821
  }
420
- else if (status?.status === "failed") {
822
+ else if (status?.status === "failed" || status?.status === "errored") {
421
823
  error("Preview failed.");
422
824
  if (status.error) {
423
825
  console.log("\nError:");
424
826
  console.log(JSON.stringify(status.error, null, 2));
425
827
  }
426
828
  }
829
+ else if (status?.status === "terminated") {
830
+ // Issue #941 (Codex P2): the preview was aborted (e.g. via
831
+ // `workflows preview ... abort`, or an external terminate). This is a
832
+ // real terminal state — NOT a timeout — so render it distinctly and
833
+ // exit 0 (the wait succeeded in observing a terminal result).
834
+ warn("Preview was terminated (aborted before completion).");
835
+ }
427
836
  else {
428
- info("Preview still running. Check status with 'workflows runs status'.");
837
+ // Timed out without ever reaching a terminal state (issue #941).
838
+ error(`Wait timed out after ${maxAttempts}s; preview still running. Check status with 'workflows runs status'.`);
839
+ process.exit(124);
429
840
  }
430
841
  }
431
842
  catch (err) {
@@ -506,9 +917,9 @@ Examples:
506
917
  if (status.stepResults && status.stepResults.length > 0) {
507
918
  divider();
508
919
  info("Step Results:");
509
- status.stepResults.forEach((step) => {
510
- console.log(` ${step.id}: ${formatDuration(step.durationMs)} ${step.skipped ? "(skipped)" : ""}`);
511
- });
920
+ for (const line of renderRunStatusStepResults(status.stepResults)) {
921
+ console.log(line);
922
+ }
512
923
  }
513
924
  if (status.error) {
514
925
  divider();
@@ -522,6 +933,462 @@ Examples:
522
933
  process.exit(1);
523
934
  }
524
935
  });
936
+ // Steps for a run
937
+ runs
938
+ .command("steps")
939
+ .description("Show step-level details for a workflow run")
940
+ .argument("<workflow-id>", "Workflow ID")
941
+ .argument("<run-id>", "Run ID")
942
+ .option("--app <app-id>", "App ID")
943
+ .option("--json", "Output as JSON")
944
+ .action(async (workflowId, runId, options) => {
945
+ const resolvedAppId = resolveAppId(undefined, options);
946
+ const client = new ApiClient();
947
+ try {
948
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
949
+ if (options.json) {
950
+ json(items);
951
+ return;
952
+ }
953
+ if (!items || items.length === 0) {
954
+ info("No step runs found.");
955
+ return;
956
+ }
957
+ console.log(formatTable(items, [
958
+ { header: "STEP", key: "stepId" },
959
+ { header: "KIND", key: "stepKind" },
960
+ {
961
+ header: "STATUS",
962
+ key: "status",
963
+ format: (v) => v === "completed" ? chalk.green(v) :
964
+ v === "failed" ? chalk.red(v) :
965
+ v === "skipped" ? chalk.gray(v) :
966
+ v === "error_captured" ? chalk.yellow(v) : v,
967
+ },
968
+ { header: "DURATION", key: "durationMs", format: formatDuration },
969
+ {
970
+ header: "TOKENS",
971
+ key: "totalTokens",
972
+ format: (v) => v ? String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-",
973
+ },
974
+ {
975
+ header: "ERROR",
976
+ key: "error",
977
+ format: (v) => v ? (v.length > 60 ? v.slice(0, 57) + "..." : v) : "",
978
+ },
979
+ ]));
980
+ }
981
+ catch (err) {
982
+ error(err.message);
983
+ process.exit(1);
984
+ }
985
+ });
986
+ // Step detail
987
+ runs
988
+ .command("step-detail")
989
+ .description("Show full details for a single step")
990
+ .argument("<workflow-id>", "Workflow ID")
991
+ .argument("<run-id>", "Run ID")
992
+ .argument("<step-id>", "Step ID")
993
+ .option("--app <app-id>", "App ID")
994
+ .option("--json", "Output as JSON")
995
+ .action(async (workflowId, runId, stepId, options) => {
996
+ const resolvedAppId = resolveAppId(undefined, options);
997
+ const client = new ApiClient();
998
+ try {
999
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
1000
+ const step = items.find((s) => s.stepId === stepId);
1001
+ if (!step) {
1002
+ error(`Step "${stepId}" not found in run ${runId}`);
1003
+ process.exit(1);
1004
+ }
1005
+ if (options.json) {
1006
+ json(step);
1007
+ return;
1008
+ }
1009
+ keyValue("Step", step.stepId);
1010
+ keyValue("Kind", step.stepKind);
1011
+ keyValue("Status", formatStatus(step.status));
1012
+ keyValue("Duration", formatDuration(step.durationMs));
1013
+ keyValue("Started", formatDate(step.startedAt));
1014
+ keyValue("Ended", formatDate(step.endedAt));
1015
+ if (step.totalTokens) {
1016
+ keyValue("Tokens", `${step.inputTokens || 0} in / ${step.outputTokens || 0} out / ${step.totalTokens} total`);
1017
+ }
1018
+ if (step.retryCount) {
1019
+ keyValue("Retries", String(step.retryCount));
1020
+ }
1021
+ if (step.config) {
1022
+ divider();
1023
+ info("Config (rendered):");
1024
+ console.log(JSON.stringify(step.config, null, 2));
1025
+ }
1026
+ if (step.rawConfig) {
1027
+ divider();
1028
+ info("Config (raw):");
1029
+ console.log(JSON.stringify(step.rawConfig, null, 2));
1030
+ }
1031
+ if (step.input) {
1032
+ divider();
1033
+ info("Input:");
1034
+ console.log(JSON.stringify(step.input, null, 2));
1035
+ }
1036
+ if (step.output) {
1037
+ divider();
1038
+ info("Output:");
1039
+ console.log(JSON.stringify(step.output, null, 2));
1040
+ }
1041
+ if (step.error) {
1042
+ divider();
1043
+ error("Error: " + step.error);
1044
+ if (step.errorDetails) {
1045
+ console.log(step.errorDetails);
1046
+ }
1047
+ }
1048
+ if (step.templateWarnings && step.templateWarnings.length > 0) {
1049
+ divider();
1050
+ warn("Template Warnings:");
1051
+ step.templateWarnings.forEach((w) => {
1052
+ console.log(` ${w.expression}: ${w.message}`);
1053
+ });
1054
+ }
1055
+ if (step.context) {
1056
+ divider();
1057
+ info("Context Snapshot:");
1058
+ console.log(JSON.stringify(step.context, null, 2));
1059
+ }
1060
+ }
1061
+ catch (err) {
1062
+ error(err.message);
1063
+ process.exit(1);
1064
+ }
1065
+ });
1066
+ // Integration call logs for a run (cross-pivot from workflow-run to
1067
+ // integrations.logs — see issue #699).
1068
+ runs
1069
+ .command("logs")
1070
+ .description("List integration calls made by a specific workflow run (cross-pivot of `integrations logs`)")
1071
+ .argument("<run-id>", "Workflow run ID")
1072
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1073
+ .option("--limit <n>", "Number of logs to show", "100")
1074
+ .option("--json", "Output as JSON")
1075
+ .action(async (runId, options) => {
1076
+ const resolvedAppId = resolveAppId(undefined, options);
1077
+ const client = new ApiClient();
1078
+ try {
1079
+ const logs = await client.listWorkflowRunIntegrationLogs(resolvedAppId, runId, { limit: parseInt(options.limit) });
1080
+ if (options.json) {
1081
+ json(logs);
1082
+ return;
1083
+ }
1084
+ if (!logs || logs.length === 0) {
1085
+ info("No integration calls found for this run.");
1086
+ return;
1087
+ }
1088
+ console.log(formatTable(logs, [
1089
+ { header: "TIME", key: "timestamp", format: formatDate },
1090
+ { header: "STEP", key: "stepId", format: (v) => v || "" },
1091
+ {
1092
+ header: "INTEGRATION",
1093
+ key: "integrationKey",
1094
+ format: (v) => v || "",
1095
+ },
1096
+ { header: "METHOD", key: "method" },
1097
+ { header: "PATH", key: "path" },
1098
+ { header: "STATUS", key: "status" },
1099
+ { header: "DURATION", key: "durationMs", format: formatDuration },
1100
+ { header: "TRACE", key: "traceId", format: formatId },
1101
+ ]));
1102
+ }
1103
+ catch (err) {
1104
+ error(err.message);
1105
+ process.exit(1);
1106
+ }
1107
+ });
1108
+ // Error summary
1109
+ runs
1110
+ .command("error")
1111
+ .description("Show error details for a failed workflow run")
1112
+ .argument("<workflow-id>", "Workflow ID")
1113
+ .argument("<run-id>", "Run ID")
1114
+ .option("--app <app-id>", "App ID")
1115
+ .option("--json", "Output as JSON")
1116
+ .action(async (workflowId, runId, options) => {
1117
+ const resolvedAppId = resolveAppId(undefined, options);
1118
+ const client = new ApiClient();
1119
+ try {
1120
+ const [runResult, stepsResult] = await Promise.all([
1121
+ client.getWorkflowRunStatus(resolvedAppId, workflowId, runId),
1122
+ client.getWorkflowStepRuns(resolvedAppId, workflowId, runId),
1123
+ ]);
1124
+ const failedStep = stepsResult.items.find((s) => s.status === "failed");
1125
+ const errorInfo = {
1126
+ runId,
1127
+ status: runResult.run?.status,
1128
+ errorMessage: runResult.run?.errorMessage || runResult.instanceStatus?.error?.message,
1129
+ failedStep: failedStep ? {
1130
+ stepId: failedStep.stepId,
1131
+ stepKind: failedStep.stepKind,
1132
+ error: failedStep.error,
1133
+ errorDetails: failedStep.errorDetails,
1134
+ input: failedStep.input,
1135
+ config: failedStep.config,
1136
+ } : null,
1137
+ };
1138
+ if (options.json) {
1139
+ json(errorInfo);
1140
+ return;
1141
+ }
1142
+ keyValue("Run ID", runId);
1143
+ keyValue("Status", formatStatus(errorInfo.status));
1144
+ if (errorInfo.errorMessage) {
1145
+ divider();
1146
+ error("Error: " + errorInfo.errorMessage);
1147
+ }
1148
+ if (failedStep) {
1149
+ divider();
1150
+ info(`Failed Step: ${failedStep.stepId} (${failedStep.stepKind})`);
1151
+ if (failedStep.error) {
1152
+ error(" " + failedStep.error);
1153
+ }
1154
+ if (failedStep.errorDetails) {
1155
+ console.log(failedStep.errorDetails);
1156
+ }
1157
+ if (failedStep.input) {
1158
+ divider();
1159
+ info("Step Input:");
1160
+ console.log(JSON.stringify(failedStep.input, null, 2));
1161
+ }
1162
+ if (failedStep.config) {
1163
+ divider();
1164
+ info("Step Config:");
1165
+ console.log(JSON.stringify(failedStep.config, null, 2));
1166
+ }
1167
+ }
1168
+ else {
1169
+ info("No failed step found in step runs.");
1170
+ }
1171
+ }
1172
+ catch (err) {
1173
+ error(err.message);
1174
+ process.exit(1);
1175
+ }
1176
+ });
1177
+ // Failures list
1178
+ runs
1179
+ .command("failures")
1180
+ .description("List recent workflow run failures")
1181
+ .argument("<workflow-id>", "Workflow ID")
1182
+ .option("--app <app-id>", "App ID")
1183
+ .option("--limit <n>", "Number of failures to show", "10")
1184
+ .option("--json", "Output as JSON")
1185
+ .action(async (workflowId, options) => {
1186
+ const resolvedAppId = resolveAppId(undefined, options);
1187
+ const client = new ApiClient();
1188
+ try {
1189
+ const { items } = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1190
+ status: "failed",
1191
+ limit: parseInt(options.limit),
1192
+ });
1193
+ if (options.json) {
1194
+ json(items);
1195
+ return;
1196
+ }
1197
+ if (!items || items.length === 0) {
1198
+ info("No failures found.");
1199
+ return;
1200
+ }
1201
+ console.log(formatTable(items, [
1202
+ { header: "RUN ID", key: "runId", format: formatId },
1203
+ { header: "ERROR", key: "errorMessage", format: (v) => v ? (v.length > 50 ? v.slice(0, 47) + "..." : v) : "-" },
1204
+ { header: "STARTED", key: "startedAt", format: formatDate },
1205
+ { header: "ENDED", key: "endedAt", format: formatDate },
1206
+ ]));
1207
+ }
1208
+ catch (err) {
1209
+ error(err.message);
1210
+ process.exit(1);
1211
+ }
1212
+ });
1213
+ // ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
1214
+ const iterations = workflows
1215
+ .command("iterations")
1216
+ .description("Inspect and reset iterate-users iterations");
1217
+ iterations
1218
+ .command("list")
1219
+ .description("List iterate-users iterations for the app")
1220
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1221
+ .option("--json", "Output as JSON")
1222
+ .action(async (options) => {
1223
+ const resolvedAppId = resolveAppId(undefined, options);
1224
+ const client = new ApiClient();
1225
+ try {
1226
+ const { items } = await client.listIterations(resolvedAppId);
1227
+ if (options.json) {
1228
+ json(items);
1229
+ return;
1230
+ }
1231
+ if (!items || items.length === 0) {
1232
+ info("No iterations found.");
1233
+ return;
1234
+ }
1235
+ console.log(formatTable(items, [
1236
+ { header: "ITERATION", key: "iterationName" },
1237
+ { header: "STATUS", key: "status", format: formatStatus },
1238
+ { header: "ACQUIRED", key: "lastAcquireMode", format: (v) => v || "" },
1239
+ { header: "PROCESSED", key: "totalProcessed" },
1240
+ { header: "FAILED", key: "failedCount" },
1241
+ { header: "LAST RUN", key: "lastRunAt", format: formatDate },
1242
+ ]));
1243
+ }
1244
+ catch (err) {
1245
+ error(err.message);
1246
+ process.exit(1);
1247
+ }
1248
+ });
1249
+ iterations
1250
+ .command("get")
1251
+ .description("Show details for a single iterate-users iteration")
1252
+ .argument("<name>", "Iteration name")
1253
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1254
+ .option("--json", "Output as JSON")
1255
+ .action(async (name, options) => {
1256
+ const resolvedAppId = resolveAppId(undefined, options);
1257
+ const client = new ApiClient();
1258
+ try {
1259
+ const it = await client.getIteration(resolvedAppId, name);
1260
+ if (options.json) {
1261
+ json(it);
1262
+ return;
1263
+ }
1264
+ keyValue("Iteration", it.iterationName);
1265
+ keyValue("Status", formatStatus(it.status));
1266
+ keyValue("Acquired as", it.lastAcquireMode || "(unknown)");
1267
+ keyValue("Run ID", it.runId);
1268
+ keyValue("Instance ID", it.instanceId);
1269
+ keyValue("Started", formatDate(it.startedAt));
1270
+ keyValue("Last run", formatDate(it.lastRunAt));
1271
+ keyValue("Lock expires", formatDate(it.lockExpiresAt));
1272
+ keyValue("Processed", String(it.totalProcessed ?? 0));
1273
+ keyValue("Succeeded", String(it.succeededCount ?? 0));
1274
+ keyValue("Failed (total)", String(it.failedCount ?? 0));
1275
+ keyValue("Skipped", String(it.skippedCount ?? 0));
1276
+ if (it.lastError) {
1277
+ keyValue("Last error", it.lastError);
1278
+ }
1279
+ const sample = Array.isArray(it.failedUserIdsSample)
1280
+ ? it.failedUserIdsSample
1281
+ : [];
1282
+ if (sample.length > 0) {
1283
+ const label = it.failedUserIdsTruncated
1284
+ ? `Failed user IDs (sample, truncated; ${it.failedCount} total)`
1285
+ : `Failed user IDs (sample)`;
1286
+ keyValue(label, sample.join(", "));
1287
+ }
1288
+ }
1289
+ catch (err) {
1290
+ error(err.message);
1291
+ process.exit(1);
1292
+ }
1293
+ });
1294
+ iterations
1295
+ .command("reset")
1296
+ .description("Reset a terminal (completed/failed) iteration so the next trigger runs fresh")
1297
+ .argument("<name>", "Iteration name")
1298
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1299
+ .option("--json", "Output as JSON")
1300
+ .action(async (name, options) => {
1301
+ const resolvedAppId = resolveAppId(undefined, options);
1302
+ const client = new ApiClient();
1303
+ try {
1304
+ const res = await client.resetIteration(resolvedAppId, name);
1305
+ if (options.json) {
1306
+ json(res);
1307
+ return;
1308
+ }
1309
+ success(`Reset iteration "${name}" (was ${res.previousStatus}). The next trigger will run fresh.`);
1310
+ }
1311
+ catch (err) {
1312
+ // A running iteration is refused with 409 + lock metadata — surface a
1313
+ // clear message rather than a raw error.
1314
+ error(err.message);
1315
+ process.exit(1);
1316
+ }
1317
+ });
1318
+ // Analytics overview
1319
+ const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
1320
+ analyticsCmd
1321
+ .command("overview")
1322
+ .description("View workflow performance metrics")
1323
+ .option("--app <app-id>", "App ID")
1324
+ .option("--days <n>", "Time window in days", "7")
1325
+ .option("--json", "Output as JSON")
1326
+ .action(async (options) => {
1327
+ const resolvedAppId = resolveAppId(undefined, options);
1328
+ const client = new ApiClient();
1329
+ try {
1330
+ const result = await client.getWorkflowAnalytics(resolvedAppId, {
1331
+ windowDays: parseInt(options.days),
1332
+ });
1333
+ if (options.json) {
1334
+ json(result);
1335
+ return;
1336
+ }
1337
+ info(`Workflow Analytics (${result.windowDays || options.days} days)`);
1338
+ console.log();
1339
+ keyValue("Total Runs", String(result.totals?.totalRuns ?? 0));
1340
+ keyValue("Success Rate", `${((result.totals?.successRate ?? 0) * 100).toFixed(1)}%`);
1341
+ keyValue("Avg Duration", formatDuration(result.totals?.avgDurationMs));
1342
+ if (result.tokenUsage) {
1343
+ console.log();
1344
+ info("Token Usage:");
1345
+ keyValue(" Input Tokens", String(result.tokenUsage.totalInputTokens ?? 0));
1346
+ keyValue(" Output Tokens", String(result.tokenUsage.totalOutputTokens ?? 0));
1347
+ keyValue(" Total Tokens", String(result.tokenUsage.totalTokens ?? 0));
1348
+ }
1349
+ }
1350
+ catch (err) {
1351
+ error(err.message);
1352
+ process.exit(1);
1353
+ }
1354
+ });
1355
+ analyticsCmd
1356
+ .command("top")
1357
+ .description("View top workflows by usage")
1358
+ .option("--app <app-id>", "App ID")
1359
+ .option("--days <n>", "Time window in days", "7")
1360
+ .option("--limit <n>", "Number of workflows to show", "10")
1361
+ .option("--json", "Output as JSON")
1362
+ .action(async (options) => {
1363
+ const resolvedAppId = resolveAppId(undefined, options);
1364
+ const client = new ApiClient();
1365
+ try {
1366
+ const result = await client.getTopWorkflows(resolvedAppId, {
1367
+ windowDays: parseInt(options.days),
1368
+ limit: parseInt(options.limit),
1369
+ });
1370
+ if (options.json) {
1371
+ json(result);
1372
+ return;
1373
+ }
1374
+ if (!result.items || result.items.length === 0) {
1375
+ info("No workflow activity in this period.");
1376
+ return;
1377
+ }
1378
+ console.log(formatTable(result.items, [
1379
+ { header: "WORKFLOW", key: "workflow" },
1380
+ { header: "RUNS", key: "executions" },
1381
+ { header: "SUCCESS", key: "success_pct", format: (v) => `${v}%` },
1382
+ { header: "P50", key: "p50", format: (v) => formatDuration(v) },
1383
+ { header: "P95", key: "p95", format: (v) => formatDuration(v) },
1384
+ { header: "TOKENS", key: "total_tokens", format: (v) => v ? String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-" },
1385
+ ]));
1386
+ }
1387
+ catch (err) {
1388
+ error(err.message);
1389
+ process.exit(1);
1390
+ }
1391
+ });
525
1392
  // ============================================
526
1393
  // CONFIGS SUBCOMMAND
527
1394
  // ============================================
@@ -593,12 +1460,12 @@ Examples:
593
1460
  json(config);
594
1461
  return;
595
1462
  }
596
- keyValue("Config ID", config.configId);
597
- keyValue("Name", config.configName);
598
- keyValue("Description", config.description || "-");
599
- keyValue("Status", formatStatus(config.status));
600
- keyValue("Created", formatDate(config.createdAt));
601
- keyValue("Modified", formatDate(config.modifiedAt));
1463
+ printResult("Config ID", config.configId);
1464
+ printResult("Name", config.configName);
1465
+ printResult("Description", config.description || "-");
1466
+ printResult("Status", formatStatus(config.status));
1467
+ printResult("Created", formatDate(config.createdAt));
1468
+ printResult("Modified", formatDate(config.modifiedAt));
602
1469
  if (config.steps && config.steps.length > 0) {
603
1470
  divider();
604
1471
  info(`Steps (${config.steps.length}):`);
@@ -630,15 +1497,22 @@ Examples:
630
1497
  }
631
1498
  let steps = [];
632
1499
  if (options.fromFile) {
1500
+ let tomlData;
633
1501
  try {
634
1502
  const content = readFileSync(options.fromFile, "utf-8");
635
- const tomlData = TOML.parse(content);
636
- steps = tomlData.steps || [];
1503
+ tomlData = TOML.parse(content);
637
1504
  }
638
1505
  catch (err) {
639
1506
  error(`Failed to read TOML file: ${err.message}`);
640
1507
  process.exit(1);
641
1508
  }
1509
+ // Issue #685: reject misnested headers before pushing.
1510
+ const tomlErrors = validateWorkflowToml(tomlData);
1511
+ if (tomlErrors.length > 0) {
1512
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1513
+ process.exit(1);
1514
+ }
1515
+ steps = tomlData.steps || [];
642
1516
  }
643
1517
  const client = new ApiClient();
644
1518
  try {
@@ -678,15 +1552,22 @@ Examples:
678
1552
  if (options.description !== undefined)
679
1553
  payload.description = options.description;
680
1554
  if (options.fromFile) {
1555
+ let tomlData;
681
1556
  try {
682
1557
  const content = readFileSync(options.fromFile, "utf-8");
683
- const tomlData = TOML.parse(content);
684
- payload.steps = tomlData.steps || [];
1558
+ tomlData = TOML.parse(content);
685
1559
  }
686
1560
  catch (err) {
687
1561
  error(`Failed to read TOML file: ${err.message}`);
688
1562
  process.exit(1);
689
1563
  }
1564
+ // Issue #685: reject misnested headers before pushing.
1565
+ const tomlErrors = validateWorkflowToml(tomlData);
1566
+ if (tomlErrors.length > 0) {
1567
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1568
+ process.exit(1);
1569
+ }
1570
+ payload.steps = tomlData.steps || [];
690
1571
  }
691
1572
  if (Object.keys(payload).length === 0) {
692
1573
  error("No update options specified. Use --name, --description, or --from-file.");
@@ -772,15 +1653,14 @@ Examples:
772
1653
  .action(async (workflowId, configId, options) => {
773
1654
  const resolvedAppId = resolveAppId(undefined, options);
774
1655
  if (!options.yes) {
775
- const inquirer = await import("inquirer");
776
- const { confirm } = await inquirer.default.prompt([
777
- {
778
- type: "confirm",
779
- name: "confirm",
780
- message: `Are you sure you want to archive configuration ${configId}?`,
781
- default: false,
782
- },
783
- ]);
1656
+ let confirm;
1657
+ try {
1658
+ confirm = await confirmPrompt(`Are you sure you want to archive configuration ${configId}?`);
1659
+ }
1660
+ catch (err) {
1661
+ error(err.message);
1662
+ process.exit(1);
1663
+ }
784
1664
  if (!confirm) {
785
1665
  info("Cancelled.");
786
1666
  return;
@@ -925,13 +1805,15 @@ Examples:
925
1805
  json(result);
926
1806
  return;
927
1807
  }
928
- keyValue("Test Case ID", result.testCaseId);
929
- keyValue("Name", result.name);
930
- keyValue("Created", formatDate(result.createdAt));
1808
+ printResult("Test Case ID", result.testCaseId);
1809
+ printResult("Name", result.name);
1810
+ printResult("Created", formatDate(result.createdAt));
931
1811
  divider();
932
1812
  info("Input Variables:");
933
1813
  try {
934
- const vars = JSON.parse(result.inputVariables || "{}");
1814
+ const vars = typeof result.inputVariables === 'string'
1815
+ ? JSON.parse(result.inputVariables || "{}")
1816
+ : (result.inputVariables || {});
935
1817
  console.log(JSON.stringify(vars, null, 2));
936
1818
  }
937
1819
  catch {
@@ -939,13 +1821,16 @@ Examples:
939
1821
  }
940
1822
  if (result.expectedOutputPattern) {
941
1823
  divider();
942
- keyValue("Expected Pattern", result.expectedOutputPattern);
1824
+ printResult("Expected Pattern", result.expectedOutputPattern);
943
1825
  }
944
1826
  if (result.expectedOutputContains) {
945
1827
  divider();
946
1828
  info("Expected Contains:");
947
1829
  try {
948
- console.log(JSON.stringify(JSON.parse(result.expectedOutputContains), null, 2));
1830
+ const contains = typeof result.expectedOutputContains === 'string'
1831
+ ? JSON.parse(result.expectedOutputContains)
1832
+ : result.expectedOutputContains;
1833
+ console.log(JSON.stringify(contains, null, 2));
949
1834
  }
950
1835
  catch {
951
1836
  console.log(result.expectedOutputContains);
@@ -955,7 +1840,10 @@ Examples:
955
1840
  divider();
956
1841
  info("Expected JSON Subset:");
957
1842
  try {
958
- console.log(JSON.stringify(JSON.parse(result.expectedJsonSubset), null, 2));
1843
+ const subset = typeof result.expectedJsonSubset === 'string'
1844
+ ? JSON.parse(result.expectedJsonSubset)
1845
+ : result.expectedJsonSubset;
1846
+ console.log(JSON.stringify(subset, null, 2));
959
1847
  }
960
1848
  catch {
961
1849
  console.log(result.expectedJsonSubset);
@@ -1056,15 +1944,14 @@ Examples:
1056
1944
  .action(async (workflowId, testCaseId, options) => {
1057
1945
  const resolvedAppId = resolveAppId(undefined, options);
1058
1946
  if (!options.yes) {
1059
- const inquirer = await import("inquirer");
1060
- const { confirm } = await inquirer.default.prompt([
1061
- {
1062
- type: "confirm",
1063
- name: "confirm",
1064
- message: `Are you sure you want to delete test case ${testCaseId}?`,
1065
- default: false,
1066
- },
1067
- ]);
1947
+ let confirm;
1948
+ try {
1949
+ confirm = await confirmPrompt(`Are you sure you want to delete test case ${testCaseId}?`);
1950
+ }
1951
+ catch (err) {
1952
+ error(err.message);
1953
+ process.exit(1);
1954
+ }
1068
1955
  if (!confirm) {
1069
1956
  info("Cancelled.");
1070
1957
  return;
@@ -1337,9 +2224,10 @@ Examples:
1337
2224
  while (result.status === "running") {
1338
2225
  await new Promise((r) => setTimeout(r, 2000));
1339
2226
  result = await fetchStatus();
1340
- process.stdout.write(`\r Completed: ${result.completed}/${result.results?.length || 0} `);
2227
+ // Progress goes to stderr so it can't corrupt JSON on stdout under --json.
2228
+ progress(` Completed: ${result.completed}/${result.results?.length || 0} `);
1341
2229
  }
1342
- console.log();
2230
+ progressEnd();
1343
2231
  }
1344
2232
  if (options.json) {
1345
2233
  json(result);
@@ -1383,15 +2271,14 @@ Examples:
1383
2271
  .action(async (workflowId, batchId, options) => {
1384
2272
  const resolvedAppId = resolveAppId(undefined, options);
1385
2273
  if (!options.yes) {
1386
- const inquirer = await import("inquirer");
1387
- const { confirm } = await inquirer.default.prompt([
1388
- {
1389
- type: "confirm",
1390
- name: "confirm",
1391
- message: `Are you sure you want to cancel batch ${batchId}?`,
1392
- default: false,
1393
- },
1394
- ]);
2274
+ let confirm;
2275
+ try {
2276
+ confirm = await confirmPrompt(`Are you sure you want to cancel batch ${batchId}?`);
2277
+ }
2278
+ catch (err) {
2279
+ error(err.message);
2280
+ process.exit(1);
2281
+ }
1395
2282
  if (!confirm) {
1396
2283
  info("Cancelled.");
1397
2284
  return;
@@ -1555,15 +2442,14 @@ Examples:
1555
2442
  .action(async (workflowId, testCaseId, filename, options) => {
1556
2443
  const resolvedAppId = resolveAppId(undefined, options);
1557
2444
  if (!options.yes) {
1558
- const inquirer = await import("inquirer");
1559
- const { confirm } = await inquirer.default.prompt([
1560
- {
1561
- type: "confirm",
1562
- name: "confirm",
1563
- message: `Are you sure you want to delete attachment "${filename}"?`,
1564
- default: false,
1565
- },
1566
- ]);
2445
+ let confirm;
2446
+ try {
2447
+ confirm = await confirmPrompt(`Are you sure you want to delete attachment "${filename}"?`);
2448
+ }
2449
+ catch (err) {
2450
+ error(err.message);
2451
+ process.exit(1);
2452
+ }
1567
2453
  if (!confirm) {
1568
2454
  info("Cancelled.");
1569
2455
  return;