primitive-admin 1.0.52 → 1.0.54

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 (141) hide show
  1. package/README.md +38 -10
  2. package/dist/bin/primitive.js +6 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/auth.js +16 -6
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/blob-buckets.js +84 -7
  7. package/dist/src/commands/blob-buckets.js.map +1 -1
  8. package/dist/src/commands/catalog.js +3 -3
  9. package/dist/src/commands/catalog.js.map +1 -1
  10. package/dist/src/commands/collections.js +12 -9
  11. package/dist/src/commands/collections.js.map +1 -1
  12. package/dist/src/commands/database-types.js +11 -1
  13. package/dist/src/commands/database-types.js.map +1 -1
  14. package/dist/src/commands/databases.js +197 -74
  15. package/dist/src/commands/databases.js.map +1 -1
  16. package/dist/src/commands/group-type-configs.js +1 -9
  17. package/dist/src/commands/group-type-configs.js.map +1 -1
  18. package/dist/src/commands/groups.js +12 -6
  19. package/dist/src/commands/groups.js.map +1 -1
  20. package/dist/src/commands/guides.d.ts +161 -0
  21. package/dist/src/commands/guides.js +502 -84
  22. package/dist/src/commands/guides.js.map +1 -1
  23. package/dist/src/commands/init.js +77 -45
  24. package/dist/src/commands/init.js.map +1 -1
  25. package/dist/src/commands/integrations.js +2 -2
  26. package/dist/src/commands/integrations.js.map +1 -1
  27. package/dist/src/commands/metadata.d.ts +2 -0
  28. package/dist/src/commands/metadata.js +344 -0
  29. package/dist/src/commands/metadata.js.map +1 -0
  30. package/dist/src/commands/prompts.js +2 -2
  31. package/dist/src/commands/prompts.js.map +1 -1
  32. package/dist/src/commands/rule-sets.d.ts +1 -0
  33. package/dist/src/commands/rule-sets.js +25 -3
  34. package/dist/src/commands/rule-sets.js.map +1 -1
  35. package/dist/src/commands/scripts.d.ts +2 -0
  36. package/dist/src/commands/scripts.js +667 -0
  37. package/dist/src/commands/scripts.js.map +1 -0
  38. package/dist/src/commands/sync.d.ts +212 -16
  39. package/dist/src/commands/sync.js +1688 -287
  40. package/dist/src/commands/sync.js.map +1 -1
  41. package/dist/src/commands/vars.d.ts +8 -0
  42. package/dist/src/commands/vars.js +110 -0
  43. package/dist/src/commands/vars.js.map +1 -0
  44. package/dist/src/commands/webhooks.js +17 -3
  45. package/dist/src/commands/webhooks.js.map +1 -1
  46. package/dist/src/commands/workflows.d.ts +30 -0
  47. package/dist/src/commands/workflows.js +651 -52
  48. package/dist/src/commands/workflows.js.map +1 -1
  49. package/dist/src/lib/api-client.d.ts +123 -19
  50. package/dist/src/lib/api-client.js +128 -4
  51. package/dist/src/lib/api-client.js.map +1 -1
  52. package/dist/src/lib/batch.d.ts +26 -0
  53. package/dist/src/lib/batch.js +32 -0
  54. package/dist/src/lib/batch.js.map +1 -0
  55. package/dist/src/lib/block-layout.d.ts +160 -0
  56. package/dist/src/lib/block-layout.js +451 -0
  57. package/dist/src/lib/block-layout.js.map +1 -0
  58. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
  59. package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
  60. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  61. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
  62. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
  63. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  64. package/dist/src/lib/config-toml.d.ts +10 -0
  65. package/dist/src/lib/config-toml.js +42 -0
  66. package/dist/src/lib/config-toml.js.map +1 -0
  67. package/dist/src/lib/config.d.ts +16 -0
  68. package/dist/src/lib/config.js +41 -0
  69. package/dist/src/lib/config.js.map +1 -1
  70. package/dist/src/lib/constants.d.ts +9 -0
  71. package/dist/src/lib/constants.js +9 -0
  72. package/dist/src/lib/constants.js.map +1 -1
  73. package/dist/src/lib/csv.d.ts +1 -2
  74. package/dist/src/lib/csv.js +3 -8
  75. package/dist/src/lib/csv.js.map +1 -1
  76. package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
  77. package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
  78. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  79. package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
  80. package/dist/src/lib/db-codegen/dbNaming.js +58 -0
  81. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  82. package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
  83. package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
  84. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  85. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
  86. package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
  87. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  88. package/dist/src/lib/generated-allowlist.js +52 -0
  89. package/dist/src/lib/generated-allowlist.js.map +1 -1
  90. package/dist/src/lib/init-config.d.ts +13 -0
  91. package/dist/src/lib/init-config.js +21 -2
  92. package/dist/src/lib/init-config.js.map +1 -1
  93. package/dist/src/lib/output.d.ts +42 -5
  94. package/dist/src/lib/output.js +70 -4
  95. package/dist/src/lib/output.js.map +1 -1
  96. package/dist/src/lib/query-operators.d.ts +43 -0
  97. package/dist/src/lib/query-operators.js +80 -0
  98. package/dist/src/lib/query-operators.js.map +1 -0
  99. package/dist/src/lib/template.d.ts +9 -5
  100. package/dist/src/lib/template.js +58 -45
  101. package/dist/src/lib/template.js.map +1 -1
  102. package/dist/src/lib/toml-database-config.d.ts +10 -19
  103. package/dist/src/lib/toml-database-config.js +28 -59
  104. package/dist/src/lib/toml-database-config.js.map +1 -1
  105. package/dist/src/lib/toml-metadata-config.d.ts +147 -0
  106. package/dist/src/lib/toml-metadata-config.js +463 -0
  107. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  108. package/dist/src/lib/toml-native-form.d.ts +46 -0
  109. package/dist/src/lib/toml-native-form.js +78 -0
  110. package/dist/src/lib/toml-native-form.js.map +1 -0
  111. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  112. package/dist/src/lib/toml-params-validator.js +118 -3
  113. package/dist/src/lib/toml-params-validator.js.map +1 -1
  114. package/dist/src/lib/workflow-apply.d.ts +86 -0
  115. package/dist/src/lib/workflow-apply.js +127 -0
  116. package/dist/src/lib/workflow-apply.js.map +1 -0
  117. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  118. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  119. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  120. package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
  121. package/dist/src/lib/workflow-codegen/generator.js +215 -0
  122. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  123. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  124. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  125. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  126. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
  127. package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
  128. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  129. package/dist/src/lib/workflow-fragments.js +3 -3
  130. package/dist/src/lib/workflow-fragments.js.map +1 -1
  131. package/dist/src/lib/workflow-payload.d.ts +68 -0
  132. package/dist/src/lib/workflow-payload.js +150 -0
  133. package/dist/src/lib/workflow-payload.js.map +1 -0
  134. package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
  135. package/dist/src/lib/workflow-toml-validator.js +37 -1
  136. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  137. package/dist/src/types/index.d.ts +11 -0
  138. package/dist/src/validators.d.ts +4 -3
  139. package/dist/src/validators.js +6 -5
  140. package/dist/src/validators.js.map +1 -1
  141. package/package.json +5 -3
@@ -1,14 +1,19 @@
1
- import { readFileSync, writeFileSync, statSync } from "fs";
1
+ import { readFileSync, writeFileSync, statSync, readdirSync, } from "fs";
2
2
  import { basename } from "path";
3
- import * as TOML from "@iarna/toml";
3
+ import * as path from "path";
4
+ import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
4
5
  import { lookup as mimeLookup } from "mime-types";
6
+ import { generateWorkflowTypes, } from "../lib/workflow-codegen/generator.js";
5
7
  import { ApiClient } from "../lib/api-client.js";
6
8
  import { resolveAppId } from "../lib/config.js";
9
+ import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
7
10
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
8
11
  import { expandWorkflow } from "../lib/workflow-fragments.js";
12
+ import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
13
+ import { applyWorkflowBody, workflowKeyMismatchError, parseCapabilitiesFlag, } from "../lib/workflow-apply.js";
9
14
  import { confirmPrompt } from "../lib/confirm-prompt.js";
10
15
  import chalk from "chalk";
11
- import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
16
+ import { success, error, printApiError, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
12
17
  /**
13
18
  * Render the `Step Results` section of `workflows runs status`. Pure
14
19
  * formatting helper — returns lines as strings so it's unit-testable
@@ -88,6 +93,75 @@ export function renderRunStatusStepResults(stepResults) {
88
93
  }
89
94
  return lines;
90
95
  }
96
+ /**
97
+ * #1367 — annotate step-run rows with the inter-step gap: the wall-clock time
98
+ * between the previous step's `endedAt` and this step's `startedAt`. Sorts by
99
+ * `startedAt` when every row has a parseable one (tie-break `stepIndex`),
100
+ * otherwise by `stepIndex` for the whole list — the ordering mode must be
101
+ * chosen once for all rows, not per pair: a per-pair timestamp-vs-index
102
+ * fallback is not transitive when some rows lack a timestamp (A<B by index,
103
+ * B<C by index, C<A by time), and a non-transitive comparator leaves
104
+ * Array.prototype.sort free to return an arbitrary order, computing gaps
105
+ * against the wrong predecessor. The first step has no predecessor, so its
106
+ * `gapMs` is `null`. A gap is only meaningful when both adjacent timestamps
107
+ * parse and the gap is positive. When the immediate predecessor lacks a valid
108
+ * `endedAt`, the gap is left `null` rather than measured from an older,
109
+ * non-adjacent step — otherwise a later step could raise a false stall
110
+ * warning against a step it does not actually follow. Exported for
111
+ * testability.
112
+ */
113
+ export function annotateStepGaps(steps) {
114
+ const rows = [...(steps || [])];
115
+ const allTimed = rows.every((r) => r?.startedAt && !Number.isNaN(new Date(r.startedAt).getTime()));
116
+ rows.sort((a, b) => {
117
+ if (allTimed) {
118
+ const sa = new Date(a.startedAt).getTime();
119
+ const sb = new Date(b.startedAt).getTime();
120
+ if (sa !== sb)
121
+ return sa - sb;
122
+ }
123
+ return (a?.stepIndex ?? 0) - (b?.stepIndex ?? 0);
124
+ });
125
+ let prevEnded = null;
126
+ for (const row of rows) {
127
+ const started = row?.startedAt ? new Date(row.startedAt).getTime() : NaN;
128
+ if (prevEnded != null && !Number.isNaN(started)) {
129
+ const gap = started - prevEnded;
130
+ row.gapMs = gap > 0 ? gap : 0;
131
+ }
132
+ else {
133
+ row.gapMs = null;
134
+ }
135
+ // Only genuinely adjacent steps yield a gap: if this step lacks a valid
136
+ // `endedAt`, clear the predecessor so the NEXT step's gap is left null
137
+ // instead of being measured from a non-adjacent earlier step.
138
+ const ended = row?.endedAt ? new Date(row.endedAt).getTime() : NaN;
139
+ prevEnded = Number.isNaN(ended) ? null : ended;
140
+ }
141
+ return rows;
142
+ }
143
+ /**
144
+ * #1367 — compute min / p50 / p95 / max over a set of latency samples (ms).
145
+ * Ignores non-finite values. Returns `null` when there are no valid samples.
146
+ * Percentiles use nearest-rank on the sorted samples. Exported for testability.
147
+ */
148
+ export function summarizeLatencyDistribution(values) {
149
+ const nums = (values || []).filter((v) => typeof v === "number" && Number.isFinite(v));
150
+ if (nums.length === 0)
151
+ return null;
152
+ nums.sort((a, b) => a - b);
153
+ const pct = (p) => {
154
+ const rank = Math.ceil((p / 100) * nums.length);
155
+ return nums[Math.min(nums.length - 1, Math.max(0, rank - 1))];
156
+ };
157
+ return {
158
+ count: nums.length,
159
+ min: nums[0],
160
+ p50: pct(50),
161
+ p95: pct(95),
162
+ max: nums[nums.length - 1],
163
+ };
164
+ }
91
165
  /**
92
166
  * Issue #687 (review feedback): render an unambiguous label for a workflow
93
167
  * config slot ("active config", "draft config", etc.) when the slot may be
@@ -177,7 +251,7 @@ Examples:
177
251
  let tomlData;
178
252
  try {
179
253
  const content = readFileSync(options.fromFile, "utf-8");
180
- tomlData = TOML.parse(content);
254
+ tomlData = parseConfigToml(content);
181
255
  }
182
256
  catch (err) {
183
257
  error(`Failed to read TOML file: ${err.message}`);
@@ -194,29 +268,15 @@ Examples:
194
268
  process.exit(1);
195
269
  }
196
270
  const workflow = tomlData.workflow || tomlData;
271
+ // #1177: build the `[workflow]`-derived fields through the shared
272
+ // builder so this create path can't drift from `sync push` again
273
+ // (`status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`, `runAs`,
274
+ // `accessRule`, `syncCallable` all flow from one field list). Structural
275
+ // fields (`workflowKey`, `steps`) are attached here.
197
276
  payload = {
198
277
  workflowKey: workflow.key || workflow.workflowKey,
199
- name: workflow.name,
200
- description: workflow.description,
201
278
  steps: tomlData.steps || [],
202
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
203
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
204
- perUserMaxRunning: workflow.perUserMaxRunning,
205
- perUserMaxQueued: workflow.perUserMaxQueued,
206
- perAppMaxRunning: workflow.perAppMaxRunning,
207
- perAppMaxQueued: workflow.perAppMaxQueued,
208
- queueTtlSeconds: workflow.queueTtlSeconds,
209
- dequeueOrder: workflow.dequeueOrder,
210
- requiresClientApply: workflow.requiresClientApply,
211
- // #807: align create --from-file with sync/update — read syncCallable
212
- // from the TOML so all three entry points behave identically.
213
- syncCallable: workflow.syncCallable,
214
- // #571: carry accessRule through so a TOML's access-control rule
215
- // lands on create instead of being silently dropped. On create the
216
- // server default is already null, so coalesce a falsy/absent rule to
217
- // undefined (omit) rather than sending "" — both clear, but undefined
218
- // is the precise "no rule declared" signal.
219
- accessRule: workflow.accessRule || undefined,
279
+ ...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
220
280
  };
221
281
  }
222
282
  else {
@@ -248,7 +308,9 @@ Examples:
248
308
  keyValue("Key", result.workflow?.workflowKey);
249
309
  }
250
310
  catch (err) {
251
- error(err.message);
311
+ // #1173: render the server's structured `details[]` (e.g. the specific
312
+ // identity-validation reason) instead of only the generic message.
313
+ printApiError(err);
252
314
  process.exit(1);
253
315
  }
254
316
  });
@@ -256,7 +318,7 @@ Examples:
256
318
  workflows
257
319
  .command("get")
258
320
  .description("Get workflow details")
259
- .argument("<workflow-id>", "Workflow ID")
321
+ .argument("<workflow-id>", "Workflow ID or key")
260
322
  .option("--app <app-id>", "App ID (uses current app if not specified)")
261
323
  .option("--json", "Output as JSON")
262
324
  .action(async (workflowId, options) => {
@@ -278,6 +340,12 @@ Examples:
278
340
  printResult("Latest Revision", wf.latestRevision || "-");
279
341
  printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
280
342
  printResult("Sync Callable", wf.syncCallable === true ? "yes" : "no");
343
+ // #1233 — runAs unset means the engine defaults to "caller"; show that
344
+ // default rather than a blank so the deployed principal mode is visible.
345
+ printResult("Run As", wf.runAs || "caller");
346
+ printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
347
+ ? wf.capabilities.join(", ")
348
+ : "-");
281
349
  if (wf.accessRule) {
282
350
  printResult("Access Rule", wf.accessRule);
283
351
  }
@@ -330,8 +398,8 @@ Examples:
330
398
  // Update workflow
331
399
  workflows
332
400
  .command("update")
333
- .description("Update workflow metadata")
334
- .argument("<workflow-id>", "Workflow ID")
401
+ .description("Update workflow metadata, or push a new body with --from-file")
402
+ .argument("<workflow-id>", "Workflow ID or key")
335
403
  .option("--app <app-id>", "App ID (uses current app if not specified)")
336
404
  .option("--name <name>", "Display name")
337
405
  .option("--description <desc>", "Description")
@@ -342,9 +410,117 @@ Examples:
342
410
  .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
343
411
  .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
344
412
  .option("--access-rule <expr>", "CEL expression for workflow access (empty string to clear)")
413
+ .option("--capabilities <list>", "Comma-separated capability grants (e.g. membership,secrets); empty string revokes all")
414
+ .option("--from-file <path>", "Push a revised body (metadata + steps) from a TOML file, keeping the same key/id. " +
415
+ "Overwrites the active config in place — live immediately (no separate publish). " +
416
+ "Explicit metadata flags above override values in the TOML.")
345
417
  .option("--json", "Output as JSON")
346
418
  .action(async (workflowId, options) => {
347
419
  const resolvedAppId = resolveAppId(undefined, options);
420
+ // --from-file: push a full body (metadata + steps) to the existing
421
+ // workflow, keeping its key/id. Symmetric with `create --from-file` and
422
+ // mirrors the `sync push` update-existing path via the shared
423
+ // `applyWorkflowBody` helper (#1249).
424
+ if (options.fromFile) {
425
+ let tomlData;
426
+ try {
427
+ // Expand top-level `include = [...]` workflow fragments before
428
+ // deriving steps, matching what `sync push` does (`parseTomlFile`
429
+ // runs `expandWorkflowTomlData`). `update --from-file` overwrites an
430
+ // existing workflow's live active config, so parsing the raw TOML
431
+ // and reading only `tomlData.steps` would drop fragment-supplied
432
+ // steps — an include-only workflow would overwrite the live body
433
+ // with an empty step list (silent data loss, #1286). `expandWorkflow`
434
+ // reads + parses + splices fragments; a TOML with no `include` key is
435
+ // returned unchanged, so the non-fragment case is unaffected.
436
+ tomlData = expandWorkflow(options.fromFile);
437
+ }
438
+ catch (err) {
439
+ error(`Failed to read TOML file: ${err.message}`);
440
+ process.exit(1);
441
+ }
442
+ // Issue #685: reject misnested headers before sending anything to the
443
+ // server — identical validation to `create --from-file`. Runs on the
444
+ // expanded data (post fragment splice), matching `sync push`.
445
+ const tomlErrors = validateWorkflowToml(tomlData);
446
+ if (tomlErrors.length > 0) {
447
+ error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
448
+ process.exit(1);
449
+ }
450
+ const tomlWorkflow = tomlData.workflow || tomlData;
451
+ const steps = tomlData.steps || [];
452
+ const client = new ApiClient();
453
+ // Fetch the existing workflow: we need its real workflowKey (Fork 2
454
+ // key-mismatch guard) and its activeConfigId (live body overwrite).
455
+ let existing;
456
+ try {
457
+ const got = await client.getWorkflow(resolvedAppId, workflowId);
458
+ existing = got?.workflow;
459
+ }
460
+ catch (err) {
461
+ printApiError(err);
462
+ process.exit(1);
463
+ }
464
+ if (!existing) {
465
+ error(`Workflow not found: ${workflowId}`);
466
+ process.exit(1);
467
+ }
468
+ // Fork 2: a TOML key that contradicts the target's key is almost
469
+ // certainly the wrong file — error rather than silently ignore it.
470
+ const mismatch = workflowKeyMismatchError(tomlWorkflow, existing.workflowKey);
471
+ if (mismatch) {
472
+ error(mismatch);
473
+ process.exit(1);
474
+ }
475
+ // Fork 1 (B): explicit `update` flags override TOML-derived metadata.
476
+ const mergedWorkflow = { ...tomlWorkflow };
477
+ if (options.name)
478
+ mergedWorkflow.name = options.name;
479
+ if (options.description)
480
+ mergedWorkflow.description = options.description;
481
+ if (options.status)
482
+ mergedWorkflow.status = options.status;
483
+ if (options.perUserMaxRunning) {
484
+ mergedWorkflow.perUserMaxRunning = parseInt(options.perUserMaxRunning);
485
+ }
486
+ if (options.perUserMaxQueued) {
487
+ mergedWorkflow.perUserMaxQueued = parseInt(options.perUserMaxQueued);
488
+ }
489
+ if (options.dequeueOrder)
490
+ mergedWorkflow.dequeueOrder = options.dequeueOrder;
491
+ if (options.requiresClientApply !== undefined) {
492
+ mergedWorkflow.requiresClientApply = options.requiresClientApply === "true";
493
+ }
494
+ if (options.syncCallable !== undefined) {
495
+ mergedWorkflow.syncCallable = options.syncCallable === "true";
496
+ }
497
+ if (options.accessRule !== undefined) {
498
+ mergedWorkflow.accessRule = options.accessRule;
499
+ }
500
+ // #1232: an explicit --capabilities flag overrides whatever the TOML
501
+ // declares (grant/revoke on top of a --from-file body push).
502
+ if (options.capabilities !== undefined) {
503
+ mergedWorkflow.capabilities = parseCapabilitiesFlag(options.capabilities);
504
+ }
505
+ try {
506
+ const { updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, mergedWorkflow, steps, { activeConfigId: existing.activeConfigId });
507
+ if (options.json) {
508
+ json(fullWorkflow);
509
+ return;
510
+ }
511
+ success(`Workflow updated from ${options.fromFile} (${updateSlotLabel}).`);
512
+ keyValue("Workflow ID", workflowId);
513
+ keyValue("Key", existing.workflowKey);
514
+ keyValue("Steps", steps.length);
515
+ }
516
+ catch (err) {
517
+ // Surface the server's structured `details[]` (e.g. syncCallable /
518
+ // identity validation reasons) like create/sync push do.
519
+ printApiError(err);
520
+ process.exit(1);
521
+ }
522
+ return;
523
+ }
348
524
  const payload = {};
349
525
  if (options.name)
350
526
  payload.name = options.name;
@@ -371,6 +547,14 @@ Examples:
371
547
  if (options.accessRule !== undefined) {
372
548
  payload.accessRule = options.accessRule;
373
549
  }
550
+ // #1232: the CLI fallback the issue asked for — grant/revoke a workflow's
551
+ // sensitive `capabilities` (e.g. `membership`) on an existing workflow.
552
+ // Presence-guarded like --access-rule: an omitted flag stays undefined so
553
+ // the server's hasOwnProperty guard leaves the existing grant untouched;
554
+ // `--capabilities ""` sends [] to revoke. The server validates the names.
555
+ if (options.capabilities !== undefined) {
556
+ payload.capabilities = parseCapabilitiesFlag(options.capabilities);
557
+ }
374
558
  if (Object.keys(payload).length === 0) {
375
559
  error("No update options specified.");
376
560
  process.exit(1);
@@ -385,7 +569,8 @@ Examples:
385
569
  success("Workflow updated.");
386
570
  }
387
571
  catch (err) {
388
- error(err.message);
572
+ // #1173: surface structured `details[]` on update too.
573
+ printApiError(err);
389
574
  process.exit(1);
390
575
  }
391
576
  });
@@ -393,7 +578,7 @@ Examples:
393
578
  workflows
394
579
  .command("delete")
395
580
  .description("Delete or archive a workflow")
396
- .argument("<workflow-id>", "Workflow ID")
581
+ .argument("<workflow-id>", "Workflow ID or key")
397
582
  .option("--app <app-id>", "App ID (uses current app if not specified)")
398
583
  .option("--hard", "Permanently delete instead of archive")
399
584
  .option("-y, --yes", "Skip confirmation prompt")
@@ -416,6 +601,10 @@ Examples:
416
601
  else {
417
602
  await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
418
603
  success("Workflow archived.");
604
+ // #1238 — archiving keeps the workflowKey reserved, so recreating
605
+ // under the same key fails with "workflowKey already exists". Point
606
+ // at the remedy so the reserved-key behavior is discoverable.
607
+ info(`The workflowKey stays reserved while archived. To free it for reuse, run: primitive workflows delete ${workflowId} --hard`);
419
608
  }
420
609
  }
421
610
  catch (err) {
@@ -439,7 +628,7 @@ Examples:
439
628
  if (options.format === "toml") {
440
629
  // Strip the `include` key (already removed by expander) and emit
441
630
  // valid TOML so the result is round-trippable.
442
- console.log(TOML.stringify(expanded));
631
+ console.log(stringifyConfigToml(expanded));
443
632
  }
444
633
  else {
445
634
  console.log(JSON.stringify(expanded, null, 2));
@@ -456,7 +645,7 @@ Examples:
456
645
  draft
457
646
  .command("update")
458
647
  .description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
459
- .argument("<workflow-id>", "Workflow ID")
648
+ .argument("<workflow-id>", "Workflow ID or key")
460
649
  .option("--app <app-id>", "App ID (uses current app if not specified)")
461
650
  .option("--from-file <path>", "Load steps from TOML file")
462
651
  .option("--json", "Output as JSON")
@@ -469,7 +658,7 @@ Examples:
469
658
  let tomlData;
470
659
  try {
471
660
  const content = readFileSync(options.fromFile, "utf-8");
472
- tomlData = TOML.parse(content);
661
+ tomlData = parseConfigToml(content);
473
662
  }
474
663
  catch (err) {
475
664
  error(`Failed to read TOML file: ${err.message}`);
@@ -525,7 +714,7 @@ Examples:
525
714
  workflows
526
715
  .command("publish")
527
716
  .description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
528
- .argument("<workflow-id>", "Workflow ID")
717
+ .argument("<workflow-id>", "Workflow ID or key")
529
718
  .option("--app <app-id>", "App ID (uses current app if not specified)")
530
719
  .option("--json", "Output as JSON")
531
720
  .action(async (workflowId, options) => {
@@ -569,7 +758,7 @@ Examples:
569
758
  workflows
570
759
  .command("preview")
571
760
  .description("Run a preview execution of the workflow")
572
- .argument("<workflow-id>", "Workflow ID")
761
+ .argument("<workflow-id>", "Workflow ID or key")
573
762
  .option("--app <app-id>", "App ID (uses current app if not specified)")
574
763
  .option("--config <config-id>", "Use specific configuration")
575
764
  .option("--draft", "Force preview of the draft version, even if active is newer")
@@ -718,13 +907,195 @@ Examples:
718
907
  process.exit(1);
719
908
  }
720
909
  });
910
+ // #1367 — hidden queue-latency measurement tool. Fires a batch of runs of a
911
+ // (trivial, no-op) workflow, polls until each has an executionStartedAt, and
912
+ // reports the observed queueDelayMs / createCallDurationMs distribution. This
913
+ // is the post-deploy verification vehicle: local miniflare does not exhibit
914
+ // real Cloudflare scheduling delay, so run it against an agents/alpha scratch
915
+ // app. Creates are paced (default 10/s) to stay clear of CF's ~100/s shared
916
+ // instance-creation limit; a create failure (e.g. 429) is recorded, not fatal.
917
+ workflows
918
+ .command("queue-latency-test", { hidden: true })
919
+ .description("(hidden) Fire N no-op runs and report the queue-delay distribution (post-deploy tool)")
920
+ .argument("<workflow-id>", "Workflow ID or key of a trivial no-op workflow")
921
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
922
+ .option("--count <n>", "Number of runs to fire", "50")
923
+ .option("--rate <r>", "Max creates per second (<=10 recommended)", "10")
924
+ .option("--input <json>", "Root input as JSON")
925
+ .option("--timeout <sec>", "Seconds to wait for all runs to start", "120")
926
+ .option("--verbose", "Print a per-run row")
927
+ .option("--json", "Output as JSON")
928
+ .action(async (workflowId, options) => {
929
+ const resolvedAppId = resolveAppId(undefined, options);
930
+ const client = new ApiClient();
931
+ const count = Math.max(1, parseInt(options.count, 10) || 50);
932
+ const rate = Math.max(1, parseInt(options.rate, 10) || 10);
933
+ const timeoutSec = Math.max(1, parseInt(options.timeout, 10) || 120);
934
+ const intervalMs = Math.ceil(1000 / rate);
935
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
936
+ let rootInput;
937
+ if (options.input) {
938
+ try {
939
+ rootInput = JSON.parse(options.input);
940
+ }
941
+ catch {
942
+ error("Invalid JSON in --input");
943
+ process.exit(1);
944
+ }
945
+ }
946
+ const fired = [];
947
+ if (!options.json) {
948
+ info(`Firing ${count} run(s) of ${workflowId} at up to ${rate}/s (interval ${intervalMs}ms)...`);
949
+ }
950
+ for (let i = 0; i < count; i++) {
951
+ try {
952
+ const res = await client.previewWorkflow(resolvedAppId, workflowId, {
953
+ rootInput,
954
+ });
955
+ fired.push({
956
+ index: i,
957
+ instanceId: res?.instanceId,
958
+ createFailed: false,
959
+ started: false,
960
+ });
961
+ }
962
+ catch (err) {
963
+ // A create failure (e.g. HTTP 500 from CF's shared-class rate limit) is
964
+ // a recorded datapoint, not a reason to abort the whole batch.
965
+ fired.push({
966
+ index: i,
967
+ createFailed: true,
968
+ createError: err?.message ? String(err.message) : String(err),
969
+ started: false,
970
+ });
971
+ }
972
+ if (i < count - 1)
973
+ await sleep(intervalMs);
974
+ }
975
+ // Poll the run list until every non-failed run has executionStartedAt set
976
+ // (queueDelayMs present) or we hit the timeout.
977
+ const byInstance = new Map();
978
+ for (const f of fired) {
979
+ if (f.instanceId)
980
+ byInstance.set(f.instanceId, f);
981
+ }
982
+ const deadline = Date.now() + timeoutSec * 1000;
983
+ const pending = () => fired.filter((f) => !f.createFailed && !f.started).length;
984
+ // The admin run-list endpoint caps each page at 200, so a single large
985
+ // request cannot cover a batch of more than 200 runs — the earliest ones
986
+ // would never be matched and would be falsely reported as not started.
987
+ // Page through with the cursor (runs come back newest-first, so our batch
988
+ // is at the front) until every pending run is matched or we run out of
989
+ // pages. Bound the scan so we never walk the whole run history.
990
+ const PAGE_LIMIT = 200;
991
+ const scanBudget = count + PAGE_LIMIT;
992
+ while (pending() > 0 && Date.now() < deadline) {
993
+ await sleep(1000);
994
+ try {
995
+ let cursor;
996
+ let scanned = 0;
997
+ do {
998
+ const { items, cursor: nextCursor } = await client.listWorkflowRuns(resolvedAppId, workflowId, { limit: PAGE_LIMIT, cursor: cursor ?? undefined });
999
+ for (const run of items || []) {
1000
+ const f = run?.instanceId ? byInstance.get(run.instanceId) : undefined;
1001
+ if (f && !f.started && run.executionStartedAt) {
1002
+ f.started = true;
1003
+ f.queueDelayMs =
1004
+ typeof run.queueDelayMs === "number" ? run.queueDelayMs : null;
1005
+ f.createCallDurationMs =
1006
+ typeof run.createCallDurationMs === "number"
1007
+ ? run.createCallDurationMs
1008
+ : null;
1009
+ }
1010
+ }
1011
+ scanned += items?.length || 0;
1012
+ cursor = nextCursor;
1013
+ } while (pending() > 0 && cursor && scanned < scanBudget);
1014
+ }
1015
+ catch {
1016
+ // Transient list error — keep polling until the deadline.
1017
+ }
1018
+ }
1019
+ const createFailures = fired.filter((f) => f.createFailed).length;
1020
+ const startedRuns = fired.filter((f) => f.started);
1021
+ const queueDelays = startedRuns.map((f) => f.queueDelayMs);
1022
+ const createDurations = startedRuns.map((f) => f.createCallDurationMs);
1023
+ const queueDist = summarizeLatencyDistribution(queueDelays);
1024
+ const createDist = summarizeLatencyDistribution(createDurations);
1025
+ const notStarted = fired.filter((f) => !f.createFailed && !f.started).length;
1026
+ if (options.json) {
1027
+ json({
1028
+ workflowId,
1029
+ requested: count,
1030
+ created: count - createFailures,
1031
+ createFailures,
1032
+ started: startedRuns.length,
1033
+ notStartedWithinTimeout: notStarted,
1034
+ queueDelayMs: queueDist,
1035
+ createCallDurationMs: createDist,
1036
+ runs: options.verbose
1037
+ ? fired.map((f) => ({
1038
+ index: f.index,
1039
+ instanceId: f.instanceId ?? null,
1040
+ createFailed: f.createFailed,
1041
+ createError: f.createError ?? null,
1042
+ started: f.started,
1043
+ queueDelayMs: f.queueDelayMs ?? null,
1044
+ createCallDurationMs: f.createCallDurationMs ?? null,
1045
+ }))
1046
+ : undefined,
1047
+ });
1048
+ return;
1049
+ }
1050
+ divider();
1051
+ keyValue("Requested", count);
1052
+ keyValue("Created", count - createFailures);
1053
+ keyValue("Create failures", createFailures);
1054
+ keyValue("Started", startedRuns.length);
1055
+ keyValue("Not started (timeout)", notStarted);
1056
+ divider();
1057
+ if (queueDist) {
1058
+ info("Queue delay (executionStartedAt - requested):");
1059
+ keyValue(" min", formatDuration(queueDist.min));
1060
+ keyValue(" p50", formatDuration(queueDist.p50));
1061
+ keyValue(" p95", formatDuration(queueDist.p95));
1062
+ keyValue(" max", formatDuration(queueDist.max));
1063
+ }
1064
+ else {
1065
+ warn("No runs started — no queue-delay samples collected.");
1066
+ }
1067
+ if (createDist) {
1068
+ divider();
1069
+ info("Create-call duration:");
1070
+ keyValue(" min", formatDuration(createDist.min));
1071
+ keyValue(" p50", formatDuration(createDist.p50));
1072
+ keyValue(" p95", formatDuration(createDist.p95));
1073
+ keyValue(" max", formatDuration(createDist.max));
1074
+ }
1075
+ if (options.verbose) {
1076
+ divider();
1077
+ console.log(formatTable(fired.map((f) => ({
1078
+ run: f.index,
1079
+ instanceId: f.instanceId ?? "-",
1080
+ status: f.createFailed ? "create-failed" : f.started ? "started" : "pending",
1081
+ queueDelayMs: f.queueDelayMs,
1082
+ createCallDurationMs: f.createCallDurationMs,
1083
+ })), [
1084
+ { header: "RUN", key: "run" },
1085
+ { header: "INSTANCE", key: "instanceId", format: formatId },
1086
+ { header: "STATE", key: "status" },
1087
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
1088
+ { header: "CREATE", key: "createCallDurationMs", format: formatDuration },
1089
+ ]));
1090
+ }
1091
+ });
721
1092
  // Runs subcommand
722
1093
  const runs = workflows.command("runs").description("Manage workflow runs");
723
1094
  // List runs
724
1095
  runs
725
1096
  .command("list")
726
1097
  .description("List workflow runs")
727
- .argument("<workflow-id>", "Workflow ID")
1098
+ .argument("<workflow-id>", "Workflow ID or key")
728
1099
  .option("--app <app-id>", "App ID (uses current app if not specified)")
729
1100
  .option("--status <status>", "Filter by status: pending, running, completed, failed")
730
1101
  .option("--limit <n>", "Number of runs to show", "20")
@@ -749,6 +1120,8 @@ Examples:
749
1120
  { header: "RUN ID", key: "runId", format: formatId },
750
1121
  { header: "STATUS", key: "status", format: formatStatus },
751
1122
  { header: "STARTED", key: "startedAt", format: formatDate },
1123
+ // #1367 — queue delay (requested → execution start).
1124
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
752
1125
  { header: "ENDED", key: "endedAt", format: formatDate },
753
1126
  { header: "PREVIEW", key: "isPreview", format: (v) => v ? "yes" : "" },
754
1127
  ]));
@@ -762,7 +1135,7 @@ Examples:
762
1135
  runs
763
1136
  .command("status")
764
1137
  .description("Get status of a workflow run")
765
- .argument("<workflow-id>", "Workflow ID")
1138
+ .argument("<workflow-id>", "Workflow ID or key")
766
1139
  .argument("<run-id>", "Run ID")
767
1140
  .option("--app <app-id>", "App ID (uses current app if not specified)")
768
1141
  .option("--json", "Output as JSON")
@@ -778,7 +1151,13 @@ Examples:
778
1151
  const run = result.run;
779
1152
  keyValue("Run ID", run.runId);
780
1153
  keyValue("Status", formatStatus(run.status));
781
- keyValue("Started", formatDate(run.startedAt));
1154
+ // #1367 — `startedAt` is the request time; `executionStartedAt` is when
1155
+ // Cloudflare actually started running the instance. Queue delay is the
1156
+ // gap. A still-queued run shows "-" for execution start / queue delay.
1157
+ keyValue("Requested", formatDate(run.startedAt));
1158
+ keyValue("Execution started", formatDate(run.executionStartedAt));
1159
+ keyValue("Queue delay", formatDuration(run.queueDelayMs));
1160
+ keyValue("Create call", formatDuration(run.createCallDurationMs));
782
1161
  keyValue("Ended", formatDate(run.endedAt));
783
1162
  keyValue("Preview", run.isPreview ? "yes" : "no");
784
1163
  if (result.instanceStatus) {
@@ -811,7 +1190,7 @@ Examples:
811
1190
  runs
812
1191
  .command("steps")
813
1192
  .description("Show step-level details for a workflow run")
814
- .argument("<workflow-id>", "Workflow ID")
1193
+ .argument("<workflow-id>", "Workflow ID or key")
815
1194
  .argument("<run-id>", "Run ID")
816
1195
  .option("--app <app-id>", "App ID")
817
1196
  .option("--json", "Output as JSON")
@@ -828,7 +1207,12 @@ Examples:
828
1207
  info("No step runs found.");
829
1208
  return;
830
1209
  }
831
- console.log(formatTable(items, [
1210
+ // #1367 — inter-step gap: idle time between one step ending and the
1211
+ // next starting. Only surfaced above a 1s threshold so normal
1212
+ // back-to-back steps stay quiet; a large gap flags a mid-run stall.
1213
+ const STEP_GAP_THRESHOLD_MS = 1000;
1214
+ const rows = annotateStepGaps(items);
1215
+ console.log(formatTable(rows, [
832
1216
  { header: "STEP", key: "stepId" },
833
1217
  { header: "KIND", key: "stepKind" },
834
1218
  {
@@ -839,6 +1223,13 @@ Examples:
839
1223
  v === "skipped" ? chalk.gray(v) :
840
1224
  v === "error_captured" ? chalk.yellow(v) : v,
841
1225
  },
1226
+ {
1227
+ header: "GAP",
1228
+ key: "gapMs",
1229
+ format: (v) => typeof v === "number" && v >= STEP_GAP_THRESHOLD_MS
1230
+ ? chalk.yellow(formatDuration(v))
1231
+ : chalk.dim("-"),
1232
+ },
842
1233
  { header: "DURATION", key: "durationMs", format: formatDuration },
843
1234
  {
844
1235
  header: "TOKENS",
@@ -861,7 +1252,7 @@ Examples:
861
1252
  runs
862
1253
  .command("step-detail")
863
1254
  .description("Show full details for a single step")
864
- .argument("<workflow-id>", "Workflow ID")
1255
+ .argument("<workflow-id>", "Workflow ID or key")
865
1256
  .argument("<run-id>", "Run ID")
866
1257
  .argument("<step-id>", "Step ID")
867
1258
  .option("--app <app-id>", "App ID")
@@ -983,7 +1374,7 @@ Examples:
983
1374
  runs
984
1375
  .command("error")
985
1376
  .description("Show error details for a failed workflow run")
986
- .argument("<workflow-id>", "Workflow ID")
1377
+ .argument("<workflow-id>", "Workflow ID or key")
987
1378
  .argument("<run-id>", "Run ID")
988
1379
  .option("--app <app-id>", "App ID")
989
1380
  .option("--json", "Output as JSON")
@@ -1052,7 +1443,7 @@ Examples:
1052
1443
  runs
1053
1444
  .command("failures")
1054
1445
  .description("List recent workflow run failures")
1055
- .argument("<workflow-id>", "Workflow ID")
1446
+ .argument("<workflow-id>", "Workflow ID or key")
1056
1447
  .option("--app <app-id>", "App ID")
1057
1448
  .option("--limit <n>", "Number of failures to show", "10")
1058
1449
  .option("--json", "Output as JSON")
@@ -1084,6 +1475,111 @@ Examples:
1084
1475
  process.exit(1);
1085
1476
  }
1086
1477
  });
1478
+ // ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
1479
+ const iterations = workflows
1480
+ .command("iterations")
1481
+ .description("Inspect and reset iterate-users iterations");
1482
+ iterations
1483
+ .command("list")
1484
+ .description("List iterate-users iterations for the app")
1485
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1486
+ .option("--json", "Output as JSON")
1487
+ .action(async (options) => {
1488
+ const resolvedAppId = resolveAppId(undefined, options);
1489
+ const client = new ApiClient();
1490
+ try {
1491
+ const { items } = await client.listIterations(resolvedAppId);
1492
+ if (options.json) {
1493
+ json(items);
1494
+ return;
1495
+ }
1496
+ if (!items || items.length === 0) {
1497
+ info("No iterations found.");
1498
+ return;
1499
+ }
1500
+ console.log(formatTable(items, [
1501
+ { header: "ITERATION", key: "iterationName" },
1502
+ { header: "STATUS", key: "status", format: formatStatus },
1503
+ { header: "ACQUIRED", key: "lastAcquireMode", format: (v) => v || "" },
1504
+ { header: "PROCESSED", key: "totalProcessed" },
1505
+ { header: "FAILED", key: "failedCount" },
1506
+ { header: "LAST RUN", key: "lastRunAt", format: formatDate },
1507
+ ]));
1508
+ }
1509
+ catch (err) {
1510
+ error(err.message);
1511
+ process.exit(1);
1512
+ }
1513
+ });
1514
+ iterations
1515
+ .command("get")
1516
+ .description("Show details for a single iterate-users iteration")
1517
+ .argument("<name>", "Iteration name")
1518
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1519
+ .option("--json", "Output as JSON")
1520
+ .action(async (name, options) => {
1521
+ const resolvedAppId = resolveAppId(undefined, options);
1522
+ const client = new ApiClient();
1523
+ try {
1524
+ const it = await client.getIteration(resolvedAppId, name);
1525
+ if (options.json) {
1526
+ json(it);
1527
+ return;
1528
+ }
1529
+ keyValue("Iteration", it.iterationName);
1530
+ keyValue("Status", formatStatus(it.status));
1531
+ keyValue("Acquired as", it.lastAcquireMode || "(unknown)");
1532
+ keyValue("Run ID", it.runId);
1533
+ keyValue("Instance ID", it.instanceId);
1534
+ keyValue("Started", formatDate(it.startedAt));
1535
+ keyValue("Last run", formatDate(it.lastRunAt));
1536
+ keyValue("Lock expires", formatDate(it.lockExpiresAt));
1537
+ keyValue("Processed", String(it.totalProcessed ?? 0));
1538
+ keyValue("Succeeded", String(it.succeededCount ?? 0));
1539
+ keyValue("Failed (total)", String(it.failedCount ?? 0));
1540
+ keyValue("Skipped", String(it.skippedCount ?? 0));
1541
+ if (it.lastError) {
1542
+ keyValue("Last error", it.lastError);
1543
+ }
1544
+ const sample = Array.isArray(it.failedUserIdsSample)
1545
+ ? it.failedUserIdsSample
1546
+ : [];
1547
+ if (sample.length > 0) {
1548
+ const label = it.failedUserIdsTruncated
1549
+ ? `Failed user IDs (sample, truncated; ${it.failedCount} total)`
1550
+ : `Failed user IDs (sample)`;
1551
+ keyValue(label, sample.join(", "));
1552
+ }
1553
+ }
1554
+ catch (err) {
1555
+ error(err.message);
1556
+ process.exit(1);
1557
+ }
1558
+ });
1559
+ iterations
1560
+ .command("reset")
1561
+ .description("Reset a terminal (completed/failed) iteration so the next trigger runs fresh")
1562
+ .argument("<name>", "Iteration name")
1563
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1564
+ .option("--json", "Output as JSON")
1565
+ .action(async (name, options) => {
1566
+ const resolvedAppId = resolveAppId(undefined, options);
1567
+ const client = new ApiClient();
1568
+ try {
1569
+ const res = await client.resetIteration(resolvedAppId, name);
1570
+ if (options.json) {
1571
+ json(res);
1572
+ return;
1573
+ }
1574
+ success(`Reset iteration "${name}" (was ${res.previousStatus}). The next trigger will run fresh.`);
1575
+ }
1576
+ catch (err) {
1577
+ // A running iteration is refused with 409 + lock metadata — surface a
1578
+ // clear message rather than a raw error.
1579
+ error(err.message);
1580
+ process.exit(1);
1581
+ }
1582
+ });
1087
1583
  // Analytics overview
1088
1584
  const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
1089
1585
  analyticsCmd
@@ -1176,7 +1672,7 @@ Examples:
1176
1672
  configs
1177
1673
  .command("list")
1178
1674
  .description("List configurations for a workflow")
1179
- .argument("<workflow-id>", "Workflow ID")
1675
+ .argument("<workflow-id>", "Workflow ID or key")
1180
1676
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1181
1677
  .option("--json", "Output as JSON")
1182
1678
  .action(async (workflowId, options) => {
@@ -1216,7 +1712,7 @@ Examples:
1216
1712
  configs
1217
1713
  .command("get")
1218
1714
  .description("Get configuration details")
1219
- .argument("<workflow-id>", "Workflow ID")
1715
+ .argument("<workflow-id>", "Workflow ID or key")
1220
1716
  .argument("<config-id>", "Configuration ID")
1221
1717
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1222
1718
  .option("--json", "Output as JSON")
@@ -1252,7 +1748,7 @@ Examples:
1252
1748
  configs
1253
1749
  .command("create")
1254
1750
  .description("Create a new configuration")
1255
- .argument("<workflow-id>", "Workflow ID")
1751
+ .argument("<workflow-id>", "Workflow ID or key")
1256
1752
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1257
1753
  .option("--name <name>", "Configuration name (required)")
1258
1754
  .option("--description <desc>", "Description")
@@ -1269,7 +1765,7 @@ Examples:
1269
1765
  let tomlData;
1270
1766
  try {
1271
1767
  const content = readFileSync(options.fromFile, "utf-8");
1272
- tomlData = TOML.parse(content);
1768
+ tomlData = parseConfigToml(content);
1273
1769
  }
1274
1770
  catch (err) {
1275
1771
  error(`Failed to read TOML file: ${err.message}`);
@@ -1306,7 +1802,7 @@ Examples:
1306
1802
  configs
1307
1803
  .command("update")
1308
1804
  .description("Update a configuration")
1309
- .argument("<workflow-id>", "Workflow ID")
1805
+ .argument("<workflow-id>", "Workflow ID or key")
1310
1806
  .argument("<config-id>", "Configuration ID")
1311
1807
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1312
1808
  .option("--name <name>", "Configuration name")
@@ -1324,7 +1820,7 @@ Examples:
1324
1820
  let tomlData;
1325
1821
  try {
1326
1822
  const content = readFileSync(options.fromFile, "utf-8");
1327
- tomlData = TOML.parse(content);
1823
+ tomlData = parseConfigToml(content);
1328
1824
  }
1329
1825
  catch (err) {
1330
1826
  error(`Failed to read TOML file: ${err.message}`);
@@ -1362,7 +1858,7 @@ Examples:
1362
1858
  configs
1363
1859
  .command("activate")
1364
1860
  .description("Set a configuration as the active (default) configuration")
1365
- .argument("<workflow-id>", "Workflow ID")
1861
+ .argument("<workflow-id>", "Workflow ID or key")
1366
1862
  .argument("<config-id>", "Configuration ID")
1367
1863
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1368
1864
  .option("--json", "Output as JSON")
@@ -1387,7 +1883,7 @@ Examples:
1387
1883
  configs
1388
1884
  .command("duplicate")
1389
1885
  .description("Duplicate a configuration")
1390
- .argument("<workflow-id>", "Workflow ID")
1886
+ .argument("<workflow-id>", "Workflow ID or key")
1391
1887
  .argument("<config-id>", "Configuration ID to duplicate")
1392
1888
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1393
1889
  .option("--name <name>", "Name for the new configuration")
@@ -1415,7 +1911,7 @@ Examples:
1415
1911
  configs
1416
1912
  .command("archive")
1417
1913
  .description("Archive a configuration")
1418
- .argument("<workflow-id>", "Workflow ID")
1914
+ .argument("<workflow-id>", "Workflow ID or key")
1419
1915
  .argument("<config-id>", "Configuration ID")
1420
1916
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1421
1917
  .option("-y, --yes", "Skip confirmation prompt")
@@ -2234,6 +2730,109 @@ Examples:
2234
2730
  process.exit(1);
2235
2731
  }
2236
2732
  });
2733
+ // ============================================
2734
+ // Codegen command (issue #1442)
2735
+ // ============================================
2736
+ workflows
2737
+ .command("codegen")
2738
+ .description("Generate typed TypeScript signatures from the local workflows/*.toml schemas. Emits one <key>.generated.ts per workflow (<Key>Input/<Key>Output types + a <key>(client) typed invoker factory returning { runSync?, start }).")
2739
+ .argument("[workflow-key]", "Generate for a single workflow (defaults to every workflows/*.toml found)")
2740
+ .option("--app <app-id>", "App ID")
2741
+ .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
2742
+ .option("-o, --output <dir>", "Output directory for the <key>.generated.ts files (defaults to <sync-dir>/workflows/generated/)")
2743
+ .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write.")
2744
+ .option("--json", "Output the result summary as JSON")
2745
+ .action(async (workflowKey, options) => {
2746
+ try {
2747
+ // 1. Resolve the single source workflows/ directory via the shared
2748
+ // active-environment resolver (issue #1510). Honors --sync-dir /
2749
+ // --app overrides, resolves the active env
2750
+ // (--env → PRIMITIVE_ENV → defaultEnvironment → single-env), and
2751
+ // preserves the legacy per-app scan fallback in bare-dir mode.
2752
+ const workflowsSourceDir = resolveCodegenSourceDir({
2753
+ subdir: "workflows",
2754
+ options: { app: options.app, syncDir: options.syncDir },
2755
+ });
2756
+ // 2. Collect the source .toml files (one per workflow), filtered to a
2757
+ // single workflow when an argument is given. Match on the file stem;
2758
+ // the generator resolves the real key from `[workflow].key`.
2759
+ const inputs = [];
2760
+ for (const fileName of readdirSync(workflowsSourceDir)) {
2761
+ if (!fileName.endsWith(".toml"))
2762
+ continue;
2763
+ const stem = fileName.slice(0, -".toml".length);
2764
+ if (workflowKey && stem !== workflowKey)
2765
+ continue;
2766
+ const tomlPath = path.join(workflowsSourceDir, fileName);
2767
+ inputs.push({
2768
+ fileStem: stem,
2769
+ tomlPath,
2770
+ tomlContent: readFileSync(tomlPath, "utf-8"),
2771
+ });
2772
+ }
2773
+ if (inputs.length === 0) {
2774
+ error(workflowKey
2775
+ ? `No workflows/${workflowKey}.toml found under .primitive/sync/.`
2776
+ : "No workflows/*.toml files found to generate from.");
2777
+ process.exit(1);
2778
+ }
2779
+ // 3. Resolve the output directory. Default: a `generated/` subdir of
2780
+ // the first source dir (keeps generated output beside the schema).
2781
+ const outputDir = options.output
2782
+ ? path.resolve(options.output)
2783
+ : path.join(path.dirname(inputs[0].tomlPath), "generated");
2784
+ // 4. Run codegen (or --check).
2785
+ const codegenResult = await generateWorkflowTypes({
2786
+ inputs,
2787
+ outputDir,
2788
+ check: !!options.check,
2789
+ singleWorkflow: !!workflowKey,
2790
+ });
2791
+ if (options.check) {
2792
+ if (codegenResult.mismatches.length > 0) {
2793
+ if (options.json) {
2794
+ json({ ok: false, mismatches: codegenResult.mismatches });
2795
+ }
2796
+ else {
2797
+ error(`Check failed: ${codegenResult.mismatches.length} file(s) out of date.`);
2798
+ for (const m of codegenResult.mismatches) {
2799
+ error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
2800
+ }
2801
+ info("Run `primitive workflows codegen` to regenerate.");
2802
+ }
2803
+ process.exit(1);
2804
+ }
2805
+ if (options.json) {
2806
+ json({ ok: true, checked: codegenResult.writtenFiles.length });
2807
+ }
2808
+ else {
2809
+ success(`Check passed: ${codegenResult.writtenFiles.length} file(s) up to date.`);
2810
+ }
2811
+ return;
2812
+ }
2813
+ if (options.json) {
2814
+ json({
2815
+ written: codegenResult.writtenFiles,
2816
+ deleted: codegenResult.deletedFiles,
2817
+ });
2818
+ return;
2819
+ }
2820
+ success(`Generated ${codegenResult.writtenFiles.length} file(s)` +
2821
+ (codegenResult.deletedFiles.length > 0
2822
+ ? `, deleted ${codegenResult.deletedFiles.length} stale file(s).`
2823
+ : "."));
2824
+ for (const f of codegenResult.writtenFiles) {
2825
+ keyValue(" wrote", path.relative(process.cwd(), f));
2826
+ }
2827
+ for (const f of codegenResult.deletedFiles) {
2828
+ keyValue(" deleted", path.relative(process.cwd(), f));
2829
+ }
2830
+ }
2831
+ catch (err) {
2832
+ error(err.message);
2833
+ process.exit(1);
2834
+ }
2835
+ });
2237
2836
  }
2238
2837
  function formatFileSize(bytes) {
2239
2838
  if (bytes < 1024)