primitive-admin 1.0.53 → 1.0.55

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