primitive-admin 1.1.0-alpha.3 → 1.1.0-alpha.31

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 (99) hide show
  1. package/README.md +168 -21
  2. package/assets/skill/skills/primitive-platform/SKILL.md +226 -0
  3. package/dist/bin/primitive.js +198 -15
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +107 -0
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +464 -55
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +27 -13
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/auth.js +165 -5
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/blob-buckets.js +354 -0
  14. package/dist/src/commands/blob-buckets.js.map +1 -0
  15. package/dist/src/commands/catalog.js +17 -17
  16. package/dist/src/commands/catalog.js.map +1 -1
  17. package/dist/src/commands/collection-type-configs.js +178 -0
  18. package/dist/src/commands/collection-type-configs.js.map +1 -0
  19. package/dist/src/commands/collections.js +526 -0
  20. package/dist/src/commands/collections.js.map +1 -0
  21. package/dist/src/commands/comparisons.js +6 -6
  22. package/dist/src/commands/comparisons.js.map +1 -1
  23. package/dist/src/commands/cron-triggers.js +364 -0
  24. package/dist/src/commands/cron-triggers.js.map +1 -0
  25. package/dist/src/commands/database-types.js +462 -0
  26. package/dist/src/commands/database-types.js.map +1 -0
  27. package/dist/src/commands/databases.js +1067 -58
  28. package/dist/src/commands/databases.js.map +1 -1
  29. package/dist/src/commands/documents.js +510 -2
  30. package/dist/src/commands/documents.js.map +1 -1
  31. package/dist/src/commands/email-templates.js +281 -0
  32. package/dist/src/commands/email-templates.js.map +1 -0
  33. package/dist/src/commands/env.js +260 -0
  34. package/dist/src/commands/env.js.map +1 -0
  35. package/dist/src/commands/group-type-configs.js +189 -0
  36. package/dist/src/commands/group-type-configs.js.map +1 -0
  37. package/dist/src/commands/groups.js +28 -25
  38. package/dist/src/commands/groups.js.map +1 -1
  39. package/dist/src/commands/init.js +719 -188
  40. package/dist/src/commands/init.js.map +1 -1
  41. package/dist/src/commands/integrations.js +504 -33
  42. package/dist/src/commands/integrations.js.map +1 -1
  43. package/dist/src/commands/prompts.js +17 -16
  44. package/dist/src/commands/prompts.js.map +1 -1
  45. package/dist/src/commands/rule-sets.js +366 -0
  46. package/dist/src/commands/rule-sets.js.map +1 -0
  47. package/dist/src/commands/secrets.js +108 -0
  48. package/dist/src/commands/secrets.js.map +1 -0
  49. package/dist/src/commands/skill.js +29 -0
  50. package/dist/src/commands/skill.js.map +1 -0
  51. package/dist/src/commands/sync.js +2363 -182
  52. package/dist/src/commands/sync.js.map +1 -1
  53. package/dist/src/commands/tokens.js +9 -9
  54. package/dist/src/commands/tokens.js.map +1 -1
  55. package/dist/src/commands/users.js +426 -4
  56. package/dist/src/commands/users.js.map +1 -1
  57. package/dist/src/commands/webhooks.js +386 -0
  58. package/dist/src/commands/webhooks.js.map +1 -0
  59. package/dist/src/commands/workflows.js +614 -64
  60. package/dist/src/commands/workflows.js.map +1 -1
  61. package/dist/src/lib/api-client.js +942 -83
  62. package/dist/src/lib/api-client.js.map +1 -1
  63. package/dist/src/lib/config.js +51 -53
  64. package/dist/src/lib/config.js.map +1 -1
  65. package/dist/src/lib/constants.js +3 -0
  66. package/dist/src/lib/constants.js.map +1 -0
  67. package/dist/src/lib/credentials-store.js +307 -0
  68. package/dist/src/lib/credentials-store.js.map +1 -0
  69. package/dist/src/lib/env-resolver.js +121 -0
  70. package/dist/src/lib/env-resolver.js.map +1 -0
  71. package/dist/src/lib/init-config.js +87 -0
  72. package/dist/src/lib/init-config.js.map +1 -0
  73. package/dist/src/lib/migration-nag.js +163 -0
  74. package/dist/src/lib/migration-nag.js.map +1 -0
  75. package/dist/src/lib/output.js +58 -6
  76. package/dist/src/lib/output.js.map +1 -1
  77. package/dist/src/lib/paginate.js +42 -0
  78. package/dist/src/lib/paginate.js.map +1 -0
  79. package/dist/src/lib/project-config.js +209 -0
  80. package/dist/src/lib/project-config.js.map +1 -0
  81. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  82. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  83. package/dist/src/lib/skill-installer.js +135 -0
  84. package/dist/src/lib/skill-installer.js.map +1 -0
  85. package/dist/src/lib/sync-paths.js +102 -0
  86. package/dist/src/lib/sync-paths.js.map +1 -0
  87. package/dist/src/lib/template.js +279 -18
  88. package/dist/src/lib/template.js.map +1 -1
  89. package/dist/src/lib/toml-database-config.js +384 -0
  90. package/dist/src/lib/toml-database-config.js.map +1 -0
  91. package/dist/src/lib/toml-params-validator.js +183 -0
  92. package/dist/src/lib/toml-params-validator.js.map +1 -0
  93. package/dist/src/lib/version-check.js +172 -0
  94. package/dist/src/lib/version-check.js.map +1 -0
  95. package/dist/src/lib/workflow-fragments.js +121 -0
  96. package/dist/src/lib/workflow-fragments.js.map +1 -0
  97. package/dist/src/lib/workflow-toml-validator.js +328 -0
  98. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  99. package/package.json +7 -4
@@ -4,8 +4,87 @@ 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";
7
9
  import chalk from "chalk";
8
- import { success, error, info, warn, keyValue, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, } from "../lib/output.js";
10
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
11
+ /**
12
+ * Render the `Step Results` section of `workflows runs status`. Pure
13
+ * formatting helper — returns lines as strings so it's unit-testable
14
+ * (the caller console.logs them). For each step, emits:
15
+ *
16
+ * ` <id>: <status-label> <duration>`
17
+ *
18
+ * and, when applicable, indented follow-up lines:
19
+ *
20
+ * - `error_captured` step with `error` → ` error: <truncated message>`
21
+ * - forEach output with `errors[]` non-empty →
22
+ * ` forEach errors: <N/M> - first: <truncated message>`
23
+ *
24
+ * The truncation length matches `runs steps` ERROR column (80 chars,
25
+ * trailing "..." when truncated). See #688.
26
+ */
27
+ export function renderRunStatusStepResults(stepResults) {
28
+ const lines = [];
29
+ for (const step of stepResults || []) {
30
+ const statusLabel = step.skipped
31
+ ? chalk.gray("skipped")
32
+ : step.status === "error_captured"
33
+ ? chalk.yellow("error_captured")
34
+ : step.status === "failed"
35
+ ? chalk.red("failed")
36
+ : step.status === "completed"
37
+ ? chalk.green("completed")
38
+ : step.status || "?";
39
+ const durationLabel = formatDuration(step.durationMs);
40
+ lines.push(` ${step.id}: ${statusLabel} ${durationLabel}`);
41
+ // Single-step error_captured: show the captured error message.
42
+ if (step.status === "error_captured" && step.error) {
43
+ const errMsg = typeof step.error === "string" ? step.error : String(step.error);
44
+ const truncated = errMsg.length > 80 ? errMsg.slice(0, 77) + "..." : errMsg;
45
+ lines.push(` ${chalk.dim("error:")} ${chalk.yellow(truncated)}`);
46
+ }
47
+ else if (step.status === "error_captured" && step.errorDetails) {
48
+ // No top-level error but errorDetails present — hint to drill in.
49
+ lines.push(` ${chalk.dim("error: <see runs step-detail for full errorDetails>")}`);
50
+ }
51
+ // forEach aggregated errors: a one-line summary surfaces the count
52
+ // of `error_captured` iterations and a sample message (#688).
53
+ const fo = step.output;
54
+ if (fo && typeof fo === "object" && Array.isArray(fo.errors) && fo.errors.length > 0) {
55
+ const total = (Array.isArray(fo.items) ? fo.items.length : 0)
56
+ || (fo.totalSucceeded ?? 0) + (fo.totalFailed ?? 0);
57
+ const firstErr = (Array.isArray(fo.errorMessages) && fo.errorMessages.length > 0
58
+ ? fo.errorMessages[0]
59
+ : (fo.errors[0]?.error ?? "?"));
60
+ const firstStr = typeof firstErr === "string" ? firstErr : String(firstErr);
61
+ const truncated = firstStr.length > 80 ? firstStr.slice(0, 77) + "..." : firstStr;
62
+ lines.push(` ${chalk.dim("forEach errors:")} ${chalk.yellow(`${fo.totalFailed ?? fo.errors.length}/${total}`)} ${chalk.dim("- first:")} ${chalk.yellow(truncated)}`);
63
+ }
64
+ }
65
+ return lines;
66
+ }
67
+ /**
68
+ * Issue #687 (review feedback): render an unambiguous label for a workflow
69
+ * config slot ("active config", "draft config", etc.) when the slot may be
70
+ * unnamed. Falls back to a short ID prefix so users staging multiple unnamed
71
+ * configs can still tell them apart.
72
+ *
73
+ * formatConfigSlotLabel("active config", "v2", "01KRT...") → 'active config "v2"'
74
+ * formatConfigSlotLabel("active config", null, "01KRTABCDXYZ...") → 'active config (01KRTABCDXYZ…)'
75
+ * formatConfigSlotLabel("active config", null, null) → 'active config'
76
+ *
77
+ * Exported for testability.
78
+ */
79
+ export function formatConfigSlotLabel(slot, configName, configId, prefixLength = 12) {
80
+ if (configName) {
81
+ return `${slot} "${configName}"`;
82
+ }
83
+ if (configId) {
84
+ return `${slot} (${String(configId).slice(0, prefixLength)}…)`;
85
+ }
86
+ return slot;
87
+ }
9
88
  export function registerWorkflowsCommands(program) {
10
89
  const workflows = program
11
90
  .command("workflows")
@@ -63,35 +142,48 @@ Examples:
63
142
  .option("--name <name>", "Display name")
64
143
  .option("--description <desc>", "Description")
65
144
  .option("--from-file <path>", "Load workflow from TOML file")
145
+ .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
66
146
  .option("--json", "Output as JSON")
67
147
  .action(async (appId, options) => {
68
148
  const resolvedAppId = resolveAppId(appId, options);
69
149
  const client = new ApiClient();
70
150
  let payload;
71
151
  if (options.fromFile) {
152
+ let tomlData;
72
153
  try {
73
154
  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
- };
155
+ tomlData = TOML.parse(content);
90
156
  }
91
157
  catch (err) {
92
158
  error(`Failed to read TOML file: ${err.message}`);
93
159
  process.exit(1);
94
160
  }
161
+ // Issue #685: reject misnested headers (e.g. [steps.<id>.request])
162
+ // before we send anything to the server. These parse to TOML
163
+ // sub-tables that the runtime silently ignores. We validate
164
+ // outside the parse try-block so the diagnostic isn't masked by
165
+ // the generic "Failed to read TOML" handler.
166
+ const tomlErrors = validateWorkflowToml(tomlData);
167
+ if (tomlErrors.length > 0) {
168
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
169
+ process.exit(1);
170
+ }
171
+ const workflow = tomlData.workflow || tomlData;
172
+ payload = {
173
+ workflowKey: workflow.key || workflow.workflowKey,
174
+ name: workflow.name,
175
+ description: workflow.description,
176
+ steps: tomlData.steps || [],
177
+ inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
178
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
179
+ perUserMaxRunning: workflow.perUserMaxRunning,
180
+ perUserMaxQueued: workflow.perUserMaxQueued,
181
+ perAppMaxRunning: workflow.perAppMaxRunning,
182
+ perAppMaxQueued: workflow.perAppMaxQueued,
183
+ queueTtlSeconds: workflow.queueTtlSeconds,
184
+ dequeueOrder: workflow.dequeueOrder,
185
+ requiresClientApply: workflow.requiresClientApply,
186
+ };
95
187
  }
96
188
  else {
97
189
  if (!options.key || !options.name) {
@@ -105,6 +197,9 @@ Examples:
105
197
  steps: [],
106
198
  };
107
199
  }
200
+ if (options.requiresClientApply !== undefined) {
201
+ payload.requiresClientApply = options.requiresClientApply === "true";
202
+ }
108
203
  try {
109
204
  const result = await client.createWorkflow(resolvedAppId, payload);
110
205
  if (options.json) {
@@ -137,19 +232,23 @@ Examples:
137
232
  return;
138
233
  }
139
234
  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 || "-");
235
+ printResult("Workflow ID", wf.workflowId);
236
+ printResult("Key", wf.workflowKey);
237
+ printResult("Name", wf.name);
238
+ printResult("Description", wf.description);
239
+ printResult("Status", formatStatus(wf.status));
240
+ printResult("Active Config", wf.activeConfigId || "-");
241
+ printResult("Latest Revision", wf.latestRevision || "-");
242
+ printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
243
+ if (wf.accessRule) {
244
+ printResult("Access Rule", wf.accessRule);
245
+ }
147
246
  divider();
148
247
  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);
248
+ printResult(" Per User Max Running", wf.perUserMaxRunning);
249
+ printResult(" Per User Max Queued", wf.perUserMaxQueued);
250
+ printResult(" Per App Max Running", wf.perAppMaxRunning);
251
+ printResult(" Dequeue Order", wf.dequeueOrder);
153
252
  if (wf.inputSchema) {
154
253
  divider();
155
254
  info("Input Schema:");
@@ -202,6 +301,7 @@ Examples:
202
301
  .option("--per-user-max-running <n>", "Max running per user")
203
302
  .option("--per-user-max-queued <n>", "Max queued per user")
204
303
  .option("--dequeue-order <order>", "Dequeue order: fifo, lifo")
304
+ .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
205
305
  .option("--json", "Output as JSON")
206
306
  .action(async (workflowId, options) => {
207
307
  const resolvedAppId = resolveAppId(undefined, options);
@@ -218,6 +318,9 @@ Examples:
218
318
  payload.perUserMaxQueued = parseInt(options.perUserMaxQueued);
219
319
  if (options.dequeueOrder)
220
320
  payload.dequeueOrder = options.dequeueOrder;
321
+ if (options.requiresClientApply !== undefined) {
322
+ payload.requiresClientApply = options.requiresClientApply === "true";
323
+ }
221
324
  if (Object.keys(payload).length === 0) {
222
325
  error("No update options specified.");
223
326
  process.exit(1);
@@ -278,12 +381,39 @@ Examples:
278
381
  process.exit(1);
279
382
  }
280
383
  });
384
+ // Expand a workflow TOML and print the result (no server contact).
385
+ //
386
+ // Authors use this to inspect what `include = [...]` fragments produce
387
+ // before pushing. Surfaces include-collision and unique-id failures with
388
+ // helpful messages without going through `sync push`.
389
+ workflows
390
+ .command("expand")
391
+ .description("Expand a workflow TOML's include fragments and print the result")
392
+ .argument("<file>", "Path to the workflow TOML file")
393
+ .option("--format <format>", "Output format: json (default) or toml", "json")
394
+ .action((file, options) => {
395
+ try {
396
+ const expanded = expandWorkflow(file);
397
+ if (options.format === "toml") {
398
+ // Strip the `include` key (already removed by expander) and emit
399
+ // valid TOML so the result is round-trippable.
400
+ console.log(TOML.stringify(expanded));
401
+ }
402
+ else {
403
+ console.log(JSON.stringify(expanded, null, 2));
404
+ }
405
+ }
406
+ catch (err) {
407
+ error(err?.message ?? String(err));
408
+ process.exit(1);
409
+ }
410
+ });
281
411
  // Draft subcommand
282
412
  const draft = workflows.command("draft").description("Manage workflow draft");
283
413
  // Update draft
284
414
  draft
285
415
  .command("update")
286
- .description("Update workflow draft steps")
416
+ .description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
287
417
  .argument("<workflow-id>", "Workflow ID")
288
418
  .option("--app <app-id>", "App ID (uses current app if not specified)")
289
419
  .option("--from-file <path>", "Load steps from TOML file")
@@ -294,21 +424,27 @@ Examples:
294
424
  error("--from-file is required");
295
425
  process.exit(1);
296
426
  }
297
- let payload;
427
+ let tomlData;
298
428
  try {
299
429
  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
- };
430
+ tomlData = TOML.parse(content);
307
431
  }
308
432
  catch (err) {
309
433
  error(`Failed to read TOML file: ${err.message}`);
310
434
  process.exit(1);
311
435
  }
436
+ // Issue #685: reject misnested headers before pushing.
437
+ const tomlErrors = validateWorkflowToml(tomlData);
438
+ if (tomlErrors.length > 0) {
439
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
440
+ process.exit(1);
441
+ }
442
+ const workflow = tomlData.workflow || tomlData;
443
+ const payload = {
444
+ steps: tomlData.steps || [],
445
+ inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
446
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
447
+ };
312
448
  const client = new ApiClient();
313
449
  try {
314
450
  const result = await client.updateWorkflowDraft(resolvedAppId, workflowId, payload);
@@ -318,6 +454,10 @@ Examples:
318
454
  }
319
455
  success("Draft updated.");
320
456
  keyValue("Steps", result.draft?.steps?.length || 0);
457
+ // Issue #687: surface server-side deprecation hint when applicable.
458
+ if (result.deprecation) {
459
+ warn(`[deprecated] ${result.deprecation}`);
460
+ }
321
461
  }
322
462
  catch (err) {
323
463
  error(err.message);
@@ -327,7 +467,7 @@ Examples:
327
467
  // Publish workflow
328
468
  workflows
329
469
  .command("publish")
330
- .description("Publish the current draft as a new revision")
470
+ .description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
331
471
  .argument("<workflow-id>", "Workflow ID")
332
472
  .option("--app <app-id>", "App ID (uses current app if not specified)")
333
473
  .option("--json", "Output as JSON")
@@ -343,6 +483,10 @@ Examples:
343
483
  success("Workflow published.");
344
484
  keyValue("Revision ID", result.revision?.revisionId);
345
485
  keyValue("Published At", formatDate(result.revision?.publishedAt));
486
+ // Issue #687: surface server-side deprecation hint when applicable.
487
+ if (result.deprecation) {
488
+ warn(`[deprecated] ${result.deprecation}`);
489
+ }
346
490
  }
347
491
  catch (err) {
348
492
  error(err.message);
@@ -355,12 +499,18 @@ Examples:
355
499
  .description("Run a preview execution of the workflow")
356
500
  .argument("<workflow-id>", "Workflow ID")
357
501
  .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)")
502
+ .option("--config <config-id>", "Use specific configuration")
503
+ .option("--draft", "Force preview of the draft version, even if active is newer")
504
+ .option("--active", "Force preview of the active config, even if a newer draft exists (issue #687)")
359
505
  .option("--input <json>", "Root input as JSON")
360
506
  .option("--wait", "Wait for completion and show result")
361
507
  .option("--json", "Output as JSON")
362
508
  .action(async (workflowId, options) => {
363
509
  const resolvedAppId = resolveAppId(undefined, options);
510
+ if (options.draft && options.active) {
511
+ error("--draft and --active are mutually exclusive");
512
+ process.exit(1);
513
+ }
364
514
  let rootInput;
365
515
  if (options.input) {
366
516
  try {
@@ -376,7 +526,15 @@ Examples:
376
526
  const result = await client.previewWorkflow(resolvedAppId, workflowId, {
377
527
  rootInput,
378
528
  configId: options.config,
529
+ useDraft: options.draft || false,
530
+ // Issue #687: --active forces the active config even when a newer
531
+ // draft exists (the inverse of --draft).
532
+ preferActive: options.active || false,
379
533
  });
534
+ // Display warning from the server (e.g. "previewing draft because newer than active")
535
+ if (result.warning) {
536
+ warn(result.warning);
537
+ }
380
538
  if (!options.wait) {
381
539
  if (options.json) {
382
540
  json(result);
@@ -384,6 +542,23 @@ Examples:
384
542
  }
385
543
  success("Preview started.");
386
544
  keyValue("Instance ID", result.instanceId);
545
+ // Issue #687: name the side we ran so the user always knows which
546
+ // version produced the output (the "print the source" pattern).
547
+ const source = result.source;
548
+ if (source === "draft") {
549
+ info("Previewing draft version.");
550
+ }
551
+ else if (source === "active") {
552
+ // Issue #687 (review feedback): when configName is missing,
553
+ // fall back to a short ID prefix so users staging multiple
554
+ // unnamed configs can disambiguate.
555
+ const label = formatConfigSlotLabel("active config", result.configName, result.configId);
556
+ info(`Previewing ${label}.`);
557
+ }
558
+ else if (source === "config") {
559
+ const label = formatConfigSlotLabel("the requested config", result.configName, result.configId);
560
+ info(`Previewing ${label}.`);
561
+ }
387
562
  info("Use 'workflows runs status' to check progress.");
388
563
  return;
389
564
  }
@@ -395,7 +570,8 @@ Examples:
395
570
  await new Promise((r) => setTimeout(r, 1000));
396
571
  const statusResult = await client.getPreviewStatus(resolvedAppId, workflowId, result.instanceId);
397
572
  status = statusResult.status;
398
- if (status?.status === "completed" || status?.status === "failed") {
573
+ const s = status?.status;
574
+ if (s === "completed" || s === "complete" || s === "failed" || s === "errored") {
399
575
  break;
400
576
  }
401
577
  }
@@ -403,7 +579,7 @@ Examples:
403
579
  json({ instanceId: result.instanceId, status });
404
580
  return;
405
581
  }
406
- if (status?.status === "completed") {
582
+ if (status?.status === "completed" || status?.status === "complete") {
407
583
  success("Preview completed.");
408
584
  if (status.output) {
409
585
  console.log("\nOutput:");
@@ -417,7 +593,7 @@ Examples:
417
593
  });
418
594
  }
419
595
  }
420
- else if (status?.status === "failed") {
596
+ else if (status?.status === "failed" || status?.status === "errored") {
421
597
  error("Preview failed.");
422
598
  if (status.error) {
423
599
  console.log("\nError:");
@@ -506,9 +682,9 @@ Examples:
506
682
  if (status.stepResults && status.stepResults.length > 0) {
507
683
  divider();
508
684
  info("Step Results:");
509
- status.stepResults.forEach((step) => {
510
- console.log(` ${step.id}: ${formatDuration(step.durationMs)} ${step.skipped ? "(skipped)" : ""}`);
511
- });
685
+ for (const line of renderRunStatusStepResults(status.stepResults)) {
686
+ console.log(line);
687
+ }
512
688
  }
513
689
  if (status.error) {
514
690
  divider();
@@ -522,6 +698,357 @@ Examples:
522
698
  process.exit(1);
523
699
  }
524
700
  });
701
+ // Steps for a run
702
+ runs
703
+ .command("steps")
704
+ .description("Show step-level details for a workflow run")
705
+ .argument("<workflow-id>", "Workflow ID")
706
+ .argument("<run-id>", "Run ID")
707
+ .option("--app <app-id>", "App ID")
708
+ .option("--json", "Output as JSON")
709
+ .action(async (workflowId, runId, options) => {
710
+ const resolvedAppId = resolveAppId(undefined, options);
711
+ const client = new ApiClient();
712
+ try {
713
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
714
+ if (options.json) {
715
+ json(items);
716
+ return;
717
+ }
718
+ if (!items || items.length === 0) {
719
+ info("No step runs found.");
720
+ return;
721
+ }
722
+ console.log(formatTable(items, [
723
+ { header: "STEP", key: "stepId" },
724
+ { header: "KIND", key: "stepKind" },
725
+ {
726
+ header: "STATUS",
727
+ key: "status",
728
+ format: (v) => v === "completed" ? chalk.green(v) :
729
+ v === "failed" ? chalk.red(v) :
730
+ v === "skipped" ? chalk.gray(v) :
731
+ v === "error_captured" ? chalk.yellow(v) : v,
732
+ },
733
+ { header: "DURATION", key: "durationMs", format: formatDuration },
734
+ {
735
+ header: "TOKENS",
736
+ key: "totalTokens",
737
+ format: (v) => v ? String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-",
738
+ },
739
+ {
740
+ header: "ERROR",
741
+ key: "error",
742
+ format: (v) => v ? (v.length > 60 ? v.slice(0, 57) + "..." : v) : "",
743
+ },
744
+ ]));
745
+ }
746
+ catch (err) {
747
+ error(err.message);
748
+ process.exit(1);
749
+ }
750
+ });
751
+ // Step detail
752
+ runs
753
+ .command("step-detail")
754
+ .description("Show full details for a single step")
755
+ .argument("<workflow-id>", "Workflow ID")
756
+ .argument("<run-id>", "Run ID")
757
+ .argument("<step-id>", "Step ID")
758
+ .option("--app <app-id>", "App ID")
759
+ .option("--json", "Output as JSON")
760
+ .action(async (workflowId, runId, stepId, options) => {
761
+ const resolvedAppId = resolveAppId(undefined, options);
762
+ const client = new ApiClient();
763
+ try {
764
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
765
+ const step = items.find((s) => s.stepId === stepId);
766
+ if (!step) {
767
+ error(`Step "${stepId}" not found in run ${runId}`);
768
+ process.exit(1);
769
+ }
770
+ if (options.json) {
771
+ json(step);
772
+ return;
773
+ }
774
+ keyValue("Step", step.stepId);
775
+ keyValue("Kind", step.stepKind);
776
+ keyValue("Status", formatStatus(step.status));
777
+ keyValue("Duration", formatDuration(step.durationMs));
778
+ keyValue("Started", formatDate(step.startedAt));
779
+ keyValue("Ended", formatDate(step.endedAt));
780
+ if (step.totalTokens) {
781
+ keyValue("Tokens", `${step.inputTokens || 0} in / ${step.outputTokens || 0} out / ${step.totalTokens} total`);
782
+ }
783
+ if (step.retryCount) {
784
+ keyValue("Retries", String(step.retryCount));
785
+ }
786
+ if (step.config) {
787
+ divider();
788
+ info("Config (rendered):");
789
+ console.log(JSON.stringify(step.config, null, 2));
790
+ }
791
+ if (step.rawConfig) {
792
+ divider();
793
+ info("Config (raw):");
794
+ console.log(JSON.stringify(step.rawConfig, null, 2));
795
+ }
796
+ if (step.input) {
797
+ divider();
798
+ info("Input:");
799
+ console.log(JSON.stringify(step.input, null, 2));
800
+ }
801
+ if (step.output) {
802
+ divider();
803
+ info("Output:");
804
+ console.log(JSON.stringify(step.output, null, 2));
805
+ }
806
+ if (step.error) {
807
+ divider();
808
+ error("Error: " + step.error);
809
+ if (step.errorDetails) {
810
+ console.log(step.errorDetails);
811
+ }
812
+ }
813
+ if (step.templateWarnings && step.templateWarnings.length > 0) {
814
+ divider();
815
+ warn("Template Warnings:");
816
+ step.templateWarnings.forEach((w) => {
817
+ console.log(` ${w.expression}: ${w.message}`);
818
+ });
819
+ }
820
+ if (step.context) {
821
+ divider();
822
+ info("Context Snapshot:");
823
+ console.log(JSON.stringify(step.context, null, 2));
824
+ }
825
+ }
826
+ catch (err) {
827
+ error(err.message);
828
+ process.exit(1);
829
+ }
830
+ });
831
+ // Integration call logs for a run (cross-pivot from workflow-run to
832
+ // integrations.logs — see issue #699).
833
+ runs
834
+ .command("logs")
835
+ .description("List integration calls made by a specific workflow run (cross-pivot of `integrations logs`)")
836
+ .argument("<run-id>", "Workflow run ID")
837
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
838
+ .option("--limit <n>", "Number of logs to show", "100")
839
+ .option("--json", "Output as JSON")
840
+ .action(async (runId, options) => {
841
+ const resolvedAppId = resolveAppId(undefined, options);
842
+ const client = new ApiClient();
843
+ try {
844
+ const logs = await client.listWorkflowRunIntegrationLogs(resolvedAppId, runId, { limit: parseInt(options.limit) });
845
+ if (options.json) {
846
+ json(logs);
847
+ return;
848
+ }
849
+ if (!logs || logs.length === 0) {
850
+ info("No integration calls found for this run.");
851
+ return;
852
+ }
853
+ console.log(formatTable(logs, [
854
+ { header: "TIME", key: "timestamp", format: formatDate },
855
+ { header: "STEP", key: "stepId", format: (v) => v || "" },
856
+ {
857
+ header: "INTEGRATION",
858
+ key: "integrationKey",
859
+ format: (v) => v || "",
860
+ },
861
+ { header: "METHOD", key: "method" },
862
+ { header: "PATH", key: "path" },
863
+ { header: "STATUS", key: "status" },
864
+ { header: "DURATION", key: "durationMs", format: formatDuration },
865
+ { header: "TRACE", key: "traceId", format: formatId },
866
+ ]));
867
+ }
868
+ catch (err) {
869
+ error(err.message);
870
+ process.exit(1);
871
+ }
872
+ });
873
+ // Error summary
874
+ runs
875
+ .command("error")
876
+ .description("Show error details for a failed workflow run")
877
+ .argument("<workflow-id>", "Workflow ID")
878
+ .argument("<run-id>", "Run ID")
879
+ .option("--app <app-id>", "App ID")
880
+ .option("--json", "Output as JSON")
881
+ .action(async (workflowId, runId, options) => {
882
+ const resolvedAppId = resolveAppId(undefined, options);
883
+ const client = new ApiClient();
884
+ try {
885
+ const [runResult, stepsResult] = await Promise.all([
886
+ client.getWorkflowRunStatus(resolvedAppId, workflowId, runId),
887
+ client.getWorkflowStepRuns(resolvedAppId, workflowId, runId),
888
+ ]);
889
+ const failedStep = stepsResult.items.find((s) => s.status === "failed");
890
+ const errorInfo = {
891
+ runId,
892
+ status: runResult.run?.status,
893
+ errorMessage: runResult.run?.errorMessage || runResult.instanceStatus?.error?.message,
894
+ failedStep: failedStep ? {
895
+ stepId: failedStep.stepId,
896
+ stepKind: failedStep.stepKind,
897
+ error: failedStep.error,
898
+ errorDetails: failedStep.errorDetails,
899
+ input: failedStep.input,
900
+ config: failedStep.config,
901
+ } : null,
902
+ };
903
+ if (options.json) {
904
+ json(errorInfo);
905
+ return;
906
+ }
907
+ keyValue("Run ID", runId);
908
+ keyValue("Status", formatStatus(errorInfo.status));
909
+ if (errorInfo.errorMessage) {
910
+ divider();
911
+ error("Error: " + errorInfo.errorMessage);
912
+ }
913
+ if (failedStep) {
914
+ divider();
915
+ info(`Failed Step: ${failedStep.stepId} (${failedStep.stepKind})`);
916
+ if (failedStep.error) {
917
+ error(" " + failedStep.error);
918
+ }
919
+ if (failedStep.errorDetails) {
920
+ console.log(failedStep.errorDetails);
921
+ }
922
+ if (failedStep.input) {
923
+ divider();
924
+ info("Step Input:");
925
+ console.log(JSON.stringify(failedStep.input, null, 2));
926
+ }
927
+ if (failedStep.config) {
928
+ divider();
929
+ info("Step Config:");
930
+ console.log(JSON.stringify(failedStep.config, null, 2));
931
+ }
932
+ }
933
+ else {
934
+ info("No failed step found in step runs.");
935
+ }
936
+ }
937
+ catch (err) {
938
+ error(err.message);
939
+ process.exit(1);
940
+ }
941
+ });
942
+ // Failures list
943
+ runs
944
+ .command("failures")
945
+ .description("List recent workflow run failures")
946
+ .argument("<workflow-id>", "Workflow ID")
947
+ .option("--app <app-id>", "App ID")
948
+ .option("--limit <n>", "Number of failures to show", "10")
949
+ .option("--json", "Output as JSON")
950
+ .action(async (workflowId, options) => {
951
+ const resolvedAppId = resolveAppId(undefined, options);
952
+ const client = new ApiClient();
953
+ try {
954
+ const { items } = await client.listWorkflowRuns(resolvedAppId, workflowId, {
955
+ status: "failed",
956
+ limit: parseInt(options.limit),
957
+ });
958
+ if (options.json) {
959
+ json(items);
960
+ return;
961
+ }
962
+ if (!items || items.length === 0) {
963
+ info("No failures found.");
964
+ return;
965
+ }
966
+ console.log(formatTable(items, [
967
+ { header: "RUN ID", key: "runId", format: formatId },
968
+ { header: "ERROR", key: "errorMessage", format: (v) => v ? (v.length > 50 ? v.slice(0, 47) + "..." : v) : "-" },
969
+ { header: "STARTED", key: "startedAt", format: formatDate },
970
+ { header: "ENDED", key: "endedAt", format: formatDate },
971
+ ]));
972
+ }
973
+ catch (err) {
974
+ error(err.message);
975
+ process.exit(1);
976
+ }
977
+ });
978
+ // Analytics overview
979
+ const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
980
+ analyticsCmd
981
+ .command("overview")
982
+ .description("View workflow performance metrics")
983
+ .option("--app <app-id>", "App ID")
984
+ .option("--days <n>", "Time window in days", "7")
985
+ .option("--json", "Output as JSON")
986
+ .action(async (options) => {
987
+ const resolvedAppId = resolveAppId(undefined, options);
988
+ const client = new ApiClient();
989
+ try {
990
+ const result = await client.getWorkflowAnalytics(resolvedAppId, {
991
+ windowDays: parseInt(options.days),
992
+ });
993
+ if (options.json) {
994
+ json(result);
995
+ return;
996
+ }
997
+ info(`Workflow Analytics (${result.windowDays || options.days} days)`);
998
+ console.log();
999
+ keyValue("Total Runs", String(result.totals?.totalRuns ?? 0));
1000
+ keyValue("Success Rate", `${((result.totals?.successRate ?? 0) * 100).toFixed(1)}%`);
1001
+ keyValue("Avg Duration", formatDuration(result.totals?.avgDurationMs));
1002
+ if (result.tokenUsage) {
1003
+ console.log();
1004
+ info("Token Usage:");
1005
+ keyValue(" Input Tokens", String(result.tokenUsage.totalInputTokens ?? 0));
1006
+ keyValue(" Output Tokens", String(result.tokenUsage.totalOutputTokens ?? 0));
1007
+ keyValue(" Total Tokens", String(result.tokenUsage.totalTokens ?? 0));
1008
+ }
1009
+ }
1010
+ catch (err) {
1011
+ error(err.message);
1012
+ process.exit(1);
1013
+ }
1014
+ });
1015
+ analyticsCmd
1016
+ .command("top")
1017
+ .description("View top workflows by usage")
1018
+ .option("--app <app-id>", "App ID")
1019
+ .option("--days <n>", "Time window in days", "7")
1020
+ .option("--limit <n>", "Number of workflows to show", "10")
1021
+ .option("--json", "Output as JSON")
1022
+ .action(async (options) => {
1023
+ const resolvedAppId = resolveAppId(undefined, options);
1024
+ const client = new ApiClient();
1025
+ try {
1026
+ const result = await client.getTopWorkflows(resolvedAppId, {
1027
+ windowDays: parseInt(options.days),
1028
+ limit: parseInt(options.limit),
1029
+ });
1030
+ if (options.json) {
1031
+ json(result);
1032
+ return;
1033
+ }
1034
+ if (!result.items || result.items.length === 0) {
1035
+ info("No workflow activity in this period.");
1036
+ return;
1037
+ }
1038
+ console.log(formatTable(result.items, [
1039
+ { header: "WORKFLOW", key: "workflow" },
1040
+ { header: "RUNS", key: "executions" },
1041
+ { header: "SUCCESS", key: "success_pct", format: (v) => `${v}%` },
1042
+ { header: "P50", key: "p50", format: (v) => formatDuration(v) },
1043
+ { header: "P95", key: "p95", format: (v) => formatDuration(v) },
1044
+ { header: "TOKENS", key: "total_tokens", format: (v) => v ? String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-" },
1045
+ ]));
1046
+ }
1047
+ catch (err) {
1048
+ error(err.message);
1049
+ process.exit(1);
1050
+ }
1051
+ });
525
1052
  // ============================================
526
1053
  // CONFIGS SUBCOMMAND
527
1054
  // ============================================
@@ -593,12 +1120,12 @@ Examples:
593
1120
  json(config);
594
1121
  return;
595
1122
  }
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));
1123
+ printResult("Config ID", config.configId);
1124
+ printResult("Name", config.configName);
1125
+ printResult("Description", config.description || "-");
1126
+ printResult("Status", formatStatus(config.status));
1127
+ printResult("Created", formatDate(config.createdAt));
1128
+ printResult("Modified", formatDate(config.modifiedAt));
602
1129
  if (config.steps && config.steps.length > 0) {
603
1130
  divider();
604
1131
  info(`Steps (${config.steps.length}):`);
@@ -630,15 +1157,22 @@ Examples:
630
1157
  }
631
1158
  let steps = [];
632
1159
  if (options.fromFile) {
1160
+ let tomlData;
633
1161
  try {
634
1162
  const content = readFileSync(options.fromFile, "utf-8");
635
- const tomlData = TOML.parse(content);
636
- steps = tomlData.steps || [];
1163
+ tomlData = TOML.parse(content);
637
1164
  }
638
1165
  catch (err) {
639
1166
  error(`Failed to read TOML file: ${err.message}`);
640
1167
  process.exit(1);
641
1168
  }
1169
+ // Issue #685: reject misnested headers before pushing.
1170
+ const tomlErrors = validateWorkflowToml(tomlData);
1171
+ if (tomlErrors.length > 0) {
1172
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1173
+ process.exit(1);
1174
+ }
1175
+ steps = tomlData.steps || [];
642
1176
  }
643
1177
  const client = new ApiClient();
644
1178
  try {
@@ -678,15 +1212,22 @@ Examples:
678
1212
  if (options.description !== undefined)
679
1213
  payload.description = options.description;
680
1214
  if (options.fromFile) {
1215
+ let tomlData;
681
1216
  try {
682
1217
  const content = readFileSync(options.fromFile, "utf-8");
683
- const tomlData = TOML.parse(content);
684
- payload.steps = tomlData.steps || [];
1218
+ tomlData = TOML.parse(content);
685
1219
  }
686
1220
  catch (err) {
687
1221
  error(`Failed to read TOML file: ${err.message}`);
688
1222
  process.exit(1);
689
1223
  }
1224
+ // Issue #685: reject misnested headers before pushing.
1225
+ const tomlErrors = validateWorkflowToml(tomlData);
1226
+ if (tomlErrors.length > 0) {
1227
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1228
+ process.exit(1);
1229
+ }
1230
+ payload.steps = tomlData.steps || [];
690
1231
  }
691
1232
  if (Object.keys(payload).length === 0) {
692
1233
  error("No update options specified. Use --name, --description, or --from-file.");
@@ -925,13 +1466,15 @@ Examples:
925
1466
  json(result);
926
1467
  return;
927
1468
  }
928
- keyValue("Test Case ID", result.testCaseId);
929
- keyValue("Name", result.name);
930
- keyValue("Created", formatDate(result.createdAt));
1469
+ printResult("Test Case ID", result.testCaseId);
1470
+ printResult("Name", result.name);
1471
+ printResult("Created", formatDate(result.createdAt));
931
1472
  divider();
932
1473
  info("Input Variables:");
933
1474
  try {
934
- const vars = JSON.parse(result.inputVariables || "{}");
1475
+ const vars = typeof result.inputVariables === 'string'
1476
+ ? JSON.parse(result.inputVariables || "{}")
1477
+ : (result.inputVariables || {});
935
1478
  console.log(JSON.stringify(vars, null, 2));
936
1479
  }
937
1480
  catch {
@@ -939,13 +1482,16 @@ Examples:
939
1482
  }
940
1483
  if (result.expectedOutputPattern) {
941
1484
  divider();
942
- keyValue("Expected Pattern", result.expectedOutputPattern);
1485
+ printResult("Expected Pattern", result.expectedOutputPattern);
943
1486
  }
944
1487
  if (result.expectedOutputContains) {
945
1488
  divider();
946
1489
  info("Expected Contains:");
947
1490
  try {
948
- console.log(JSON.stringify(JSON.parse(result.expectedOutputContains), null, 2));
1491
+ const contains = typeof result.expectedOutputContains === 'string'
1492
+ ? JSON.parse(result.expectedOutputContains)
1493
+ : result.expectedOutputContains;
1494
+ console.log(JSON.stringify(contains, null, 2));
949
1495
  }
950
1496
  catch {
951
1497
  console.log(result.expectedOutputContains);
@@ -955,7 +1501,10 @@ Examples:
955
1501
  divider();
956
1502
  info("Expected JSON Subset:");
957
1503
  try {
958
- console.log(JSON.stringify(JSON.parse(result.expectedJsonSubset), null, 2));
1504
+ const subset = typeof result.expectedJsonSubset === 'string'
1505
+ ? JSON.parse(result.expectedJsonSubset)
1506
+ : result.expectedJsonSubset;
1507
+ console.log(JSON.stringify(subset, null, 2));
959
1508
  }
960
1509
  catch {
961
1510
  console.log(result.expectedJsonSubset);
@@ -1337,9 +1886,10 @@ Examples:
1337
1886
  while (result.status === "running") {
1338
1887
  await new Promise((r) => setTimeout(r, 2000));
1339
1888
  result = await fetchStatus();
1340
- process.stdout.write(`\r Completed: ${result.completed}/${result.results?.length || 0} `);
1889
+ // Progress goes to stderr so it can't corrupt JSON on stdout under --json.
1890
+ progress(` Completed: ${result.completed}/${result.results?.length || 0} `);
1341
1891
  }
1342
- console.log();
1892
+ progressEnd();
1343
1893
  }
1344
1894
  if (options.json) {
1345
1895
  json(result);