primitive-admin 1.0.53 → 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.
- package/README.md +38 -10
- package/dist/bin/primitive.js +6 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/auth.js +16 -6
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +84 -7
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +3 -3
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collections.js +12 -9
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/databases.js +191 -74
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +1 -9
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +12 -6
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +161 -0
- package/dist/src/commands/guides.js +502 -84
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/integrations.js +2 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +344 -0
- package/dist/src/commands/metadata.js.map +1 -0
- package/dist/src/commands/prompts.js +2 -2
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -0
- package/dist/src/commands/rule-sets.js +25 -3
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +2 -0
- package/dist/src/commands/scripts.js +667 -0
- package/dist/src/commands/scripts.js.map +1 -0
- package/dist/src/commands/sync.d.ts +189 -9
- package/dist/src/commands/sync.js +1418 -256
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/vars.d.ts +8 -0
- package/dist/src/commands/vars.js +110 -0
- package/dist/src/commands/vars.js.map +1 -0
- package/dist/src/commands/webhooks.js +17 -3
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +30 -0
- package/dist/src/commands/workflows.js +645 -59
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +120 -18
- package/dist/src/lib/api-client.js +125 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/batch.d.ts +26 -0
- package/dist/src/lib/batch.js +32 -0
- package/dist/src/lib/batch.js.map +1 -0
- package/dist/src/lib/block-layout.d.ts +160 -0
- package/dist/src/lib/block-layout.js +451 -0
- package/dist/src/lib/block-layout.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +61 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
- package/dist/src/lib/config-toml.d.ts +10 -0
- package/dist/src/lib/config-toml.js +42 -0
- package/dist/src/lib/config-toml.js.map +1 -0
- package/dist/src/lib/config.d.ts +16 -0
- package/dist/src/lib/config.js +41 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/constants.d.ts +9 -0
- package/dist/src/lib/constants.js +9 -0
- package/dist/src/lib/constants.js.map +1 -1
- package/dist/src/lib/csv.d.ts +1 -2
- package/dist/src/lib/csv.js +3 -8
- package/dist/src/lib/csv.js.map +1 -1
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
- package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
- package/dist/src/lib/db-codegen/dbNaming.js +58 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
- package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
- package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +43 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/init-config.js +2 -2
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/output.d.ts +25 -5
- package/dist/src/lib/output.js +32 -4
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +10 -19
- package/dist/src/lib/toml-database-config.js +28 -59
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.d.ts +147 -0
- package/dist/src/lib/toml-metadata-config.js +463 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-native-form.d.ts +46 -0
- package/dist/src/lib/toml-native-form.js +78 -0
- package/dist/src/lib/toml-native-form.js.map +1 -0
- package/dist/src/lib/toml-params-validator.d.ts +34 -0
- package/dist/src/lib/toml-params-validator.js +118 -3
- package/dist/src/lib/toml-params-validator.js.map +1 -1
- package/dist/src/lib/workflow-apply.d.ts +86 -0
- package/dist/src/lib/workflow-apply.js +127 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
- package/dist/src/lib/workflow-codegen/generator.js +215 -0
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
- package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
- package/dist/src/lib/workflow-codegen/naming.js +81 -0
- package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
- package/dist/src/lib/workflow-fragments.js +3 -3
- package/dist/src/lib/workflow-fragments.js.map +1 -1
- package/dist/src/lib/workflow-payload.d.ts +68 -0
- package/dist/src/lib/workflow-payload.js +150 -0
- package/dist/src/lib/workflow-payload.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
- package/dist/src/lib/workflow-toml-validator.js +37 -1
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +11 -0
- package/dist/src/validators.d.ts +4 -3
- package/dist/src/validators.js +6 -5
- package/dist/src/validators.js.map +1 -1
- package/package.json +5 -3
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, statSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, statSync, readdirSync, } from "fs";
|
|
2
2
|
import { basename } from "path";
|
|
3
|
-
import * as
|
|
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
16
|
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 +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 =
|
|
254
|
+
tomlData = parseConfigToml(content);
|
|
181
255
|
}
|
|
182
256
|
catch (err) {
|
|
183
257
|
error(`Failed to read TOML file: ${err.message}`);
|
|
@@ -194,39 +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
|
-
|
|
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,
|
|
279
|
+
...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
|
|
230
280
|
};
|
|
231
281
|
}
|
|
232
282
|
else {
|
|
@@ -268,7 +318,7 @@ Examples:
|
|
|
268
318
|
workflows
|
|
269
319
|
.command("get")
|
|
270
320
|
.description("Get workflow details")
|
|
271
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
321
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
272
322
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
273
323
|
.option("--json", "Output as JSON")
|
|
274
324
|
.action(async (workflowId, options) => {
|
|
@@ -290,6 +340,12 @@ Examples:
|
|
|
290
340
|
printResult("Latest Revision", wf.latestRevision || "-");
|
|
291
341
|
printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
|
|
292
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
|
+
: "-");
|
|
293
349
|
if (wf.accessRule) {
|
|
294
350
|
printResult("Access Rule", wf.accessRule);
|
|
295
351
|
}
|
|
@@ -342,8 +398,8 @@ Examples:
|
|
|
342
398
|
// Update workflow
|
|
343
399
|
workflows
|
|
344
400
|
.command("update")
|
|
345
|
-
.description("Update workflow metadata")
|
|
346
|
-
.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")
|
|
347
403
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
348
404
|
.option("--name <name>", "Display name")
|
|
349
405
|
.option("--description <desc>", "Description")
|
|
@@ -354,9 +410,117 @@ Examples:
|
|
|
354
410
|
.option("--requires-client-apply <bool>", "Require client-side apply: true or false")
|
|
355
411
|
.option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
|
|
356
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.")
|
|
357
417
|
.option("--json", "Output as JSON")
|
|
358
418
|
.action(async (workflowId, options) => {
|
|
359
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
|
+
}
|
|
360
524
|
const payload = {};
|
|
361
525
|
if (options.name)
|
|
362
526
|
payload.name = options.name;
|
|
@@ -383,6 +547,14 @@ Examples:
|
|
|
383
547
|
if (options.accessRule !== undefined) {
|
|
384
548
|
payload.accessRule = options.accessRule;
|
|
385
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
|
+
}
|
|
386
558
|
if (Object.keys(payload).length === 0) {
|
|
387
559
|
error("No update options specified.");
|
|
388
560
|
process.exit(1);
|
|
@@ -406,7 +578,7 @@ Examples:
|
|
|
406
578
|
workflows
|
|
407
579
|
.command("delete")
|
|
408
580
|
.description("Delete or archive a workflow")
|
|
409
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
581
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
410
582
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
411
583
|
.option("--hard", "Permanently delete instead of archive")
|
|
412
584
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
@@ -429,6 +601,10 @@ Examples:
|
|
|
429
601
|
else {
|
|
430
602
|
await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
|
|
431
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`);
|
|
432
608
|
}
|
|
433
609
|
}
|
|
434
610
|
catch (err) {
|
|
@@ -452,7 +628,7 @@ Examples:
|
|
|
452
628
|
if (options.format === "toml") {
|
|
453
629
|
// Strip the `include` key (already removed by expander) and emit
|
|
454
630
|
// valid TOML so the result is round-trippable.
|
|
455
|
-
console.log(
|
|
631
|
+
console.log(stringifyConfigToml(expanded));
|
|
456
632
|
}
|
|
457
633
|
else {
|
|
458
634
|
console.log(JSON.stringify(expanded, null, 2));
|
|
@@ -469,7 +645,7 @@ Examples:
|
|
|
469
645
|
draft
|
|
470
646
|
.command("update")
|
|
471
647
|
.description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
|
|
472
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
648
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
473
649
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
474
650
|
.option("--from-file <path>", "Load steps from TOML file")
|
|
475
651
|
.option("--json", "Output as JSON")
|
|
@@ -482,7 +658,7 @@ Examples:
|
|
|
482
658
|
let tomlData;
|
|
483
659
|
try {
|
|
484
660
|
const content = readFileSync(options.fromFile, "utf-8");
|
|
485
|
-
tomlData =
|
|
661
|
+
tomlData = parseConfigToml(content);
|
|
486
662
|
}
|
|
487
663
|
catch (err) {
|
|
488
664
|
error(`Failed to read TOML file: ${err.message}`);
|
|
@@ -538,7 +714,7 @@ Examples:
|
|
|
538
714
|
workflows
|
|
539
715
|
.command("publish")
|
|
540
716
|
.description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
|
|
541
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
717
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
542
718
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
543
719
|
.option("--json", "Output as JSON")
|
|
544
720
|
.action(async (workflowId, options) => {
|
|
@@ -582,7 +758,7 @@ Examples:
|
|
|
582
758
|
workflows
|
|
583
759
|
.command("preview")
|
|
584
760
|
.description("Run a preview execution of the workflow")
|
|
585
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
761
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
586
762
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
587
763
|
.option("--config <config-id>", "Use specific configuration")
|
|
588
764
|
.option("--draft", "Force preview of the draft version, even if active is newer")
|
|
@@ -731,13 +907,195 @@ Examples:
|
|
|
731
907
|
process.exit(1);
|
|
732
908
|
}
|
|
733
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
|
+
});
|
|
734
1092
|
// Runs subcommand
|
|
735
1093
|
const runs = workflows.command("runs").description("Manage workflow runs");
|
|
736
1094
|
// List runs
|
|
737
1095
|
runs
|
|
738
1096
|
.command("list")
|
|
739
1097
|
.description("List workflow runs")
|
|
740
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1098
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
741
1099
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
742
1100
|
.option("--status <status>", "Filter by status: pending, running, completed, failed")
|
|
743
1101
|
.option("--limit <n>", "Number of runs to show", "20")
|
|
@@ -762,6 +1120,8 @@ Examples:
|
|
|
762
1120
|
{ header: "RUN ID", key: "runId", format: formatId },
|
|
763
1121
|
{ header: "STATUS", key: "status", format: formatStatus },
|
|
764
1122
|
{ header: "STARTED", key: "startedAt", format: formatDate },
|
|
1123
|
+
// #1367 — queue delay (requested → execution start).
|
|
1124
|
+
{ header: "DELAY", key: "queueDelayMs", format: formatDuration },
|
|
765
1125
|
{ header: "ENDED", key: "endedAt", format: formatDate },
|
|
766
1126
|
{ header: "PREVIEW", key: "isPreview", format: (v) => v ? "yes" : "" },
|
|
767
1127
|
]));
|
|
@@ -775,7 +1135,7 @@ Examples:
|
|
|
775
1135
|
runs
|
|
776
1136
|
.command("status")
|
|
777
1137
|
.description("Get status of a workflow run")
|
|
778
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1138
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
779
1139
|
.argument("<run-id>", "Run ID")
|
|
780
1140
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
781
1141
|
.option("--json", "Output as JSON")
|
|
@@ -791,7 +1151,13 @@ Examples:
|
|
|
791
1151
|
const run = result.run;
|
|
792
1152
|
keyValue("Run ID", run.runId);
|
|
793
1153
|
keyValue("Status", formatStatus(run.status));
|
|
794
|
-
|
|
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));
|
|
795
1161
|
keyValue("Ended", formatDate(run.endedAt));
|
|
796
1162
|
keyValue("Preview", run.isPreview ? "yes" : "no");
|
|
797
1163
|
if (result.instanceStatus) {
|
|
@@ -824,7 +1190,7 @@ Examples:
|
|
|
824
1190
|
runs
|
|
825
1191
|
.command("steps")
|
|
826
1192
|
.description("Show step-level details for a workflow run")
|
|
827
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1193
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
828
1194
|
.argument("<run-id>", "Run ID")
|
|
829
1195
|
.option("--app <app-id>", "App ID")
|
|
830
1196
|
.option("--json", "Output as JSON")
|
|
@@ -841,7 +1207,12 @@ Examples:
|
|
|
841
1207
|
info("No step runs found.");
|
|
842
1208
|
return;
|
|
843
1209
|
}
|
|
844
|
-
|
|
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, [
|
|
845
1216
|
{ header: "STEP", key: "stepId" },
|
|
846
1217
|
{ header: "KIND", key: "stepKind" },
|
|
847
1218
|
{
|
|
@@ -852,6 +1223,13 @@ Examples:
|
|
|
852
1223
|
v === "skipped" ? chalk.gray(v) :
|
|
853
1224
|
v === "error_captured" ? chalk.yellow(v) : v,
|
|
854
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
|
+
},
|
|
855
1233
|
{ header: "DURATION", key: "durationMs", format: formatDuration },
|
|
856
1234
|
{
|
|
857
1235
|
header: "TOKENS",
|
|
@@ -874,7 +1252,7 @@ Examples:
|
|
|
874
1252
|
runs
|
|
875
1253
|
.command("step-detail")
|
|
876
1254
|
.description("Show full details for a single step")
|
|
877
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1255
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
878
1256
|
.argument("<run-id>", "Run ID")
|
|
879
1257
|
.argument("<step-id>", "Step ID")
|
|
880
1258
|
.option("--app <app-id>", "App ID")
|
|
@@ -996,7 +1374,7 @@ Examples:
|
|
|
996
1374
|
runs
|
|
997
1375
|
.command("error")
|
|
998
1376
|
.description("Show error details for a failed workflow run")
|
|
999
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1377
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1000
1378
|
.argument("<run-id>", "Run ID")
|
|
1001
1379
|
.option("--app <app-id>", "App ID")
|
|
1002
1380
|
.option("--json", "Output as JSON")
|
|
@@ -1065,7 +1443,7 @@ Examples:
|
|
|
1065
1443
|
runs
|
|
1066
1444
|
.command("failures")
|
|
1067
1445
|
.description("List recent workflow run failures")
|
|
1068
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1446
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1069
1447
|
.option("--app <app-id>", "App ID")
|
|
1070
1448
|
.option("--limit <n>", "Number of failures to show", "10")
|
|
1071
1449
|
.option("--json", "Output as JSON")
|
|
@@ -1097,6 +1475,111 @@ Examples:
|
|
|
1097
1475
|
process.exit(1);
|
|
1098
1476
|
}
|
|
1099
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
|
+
});
|
|
1100
1583
|
// Analytics overview
|
|
1101
1584
|
const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
|
|
1102
1585
|
analyticsCmd
|
|
@@ -1189,7 +1672,7 @@ Examples:
|
|
|
1189
1672
|
configs
|
|
1190
1673
|
.command("list")
|
|
1191
1674
|
.description("List configurations for a workflow")
|
|
1192
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1675
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1193
1676
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1194
1677
|
.option("--json", "Output as JSON")
|
|
1195
1678
|
.action(async (workflowId, options) => {
|
|
@@ -1229,7 +1712,7 @@ Examples:
|
|
|
1229
1712
|
configs
|
|
1230
1713
|
.command("get")
|
|
1231
1714
|
.description("Get configuration details")
|
|
1232
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1715
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1233
1716
|
.argument("<config-id>", "Configuration ID")
|
|
1234
1717
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1235
1718
|
.option("--json", "Output as JSON")
|
|
@@ -1265,7 +1748,7 @@ Examples:
|
|
|
1265
1748
|
configs
|
|
1266
1749
|
.command("create")
|
|
1267
1750
|
.description("Create a new configuration")
|
|
1268
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1751
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1269
1752
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1270
1753
|
.option("--name <name>", "Configuration name (required)")
|
|
1271
1754
|
.option("--description <desc>", "Description")
|
|
@@ -1282,7 +1765,7 @@ Examples:
|
|
|
1282
1765
|
let tomlData;
|
|
1283
1766
|
try {
|
|
1284
1767
|
const content = readFileSync(options.fromFile, "utf-8");
|
|
1285
|
-
tomlData =
|
|
1768
|
+
tomlData = parseConfigToml(content);
|
|
1286
1769
|
}
|
|
1287
1770
|
catch (err) {
|
|
1288
1771
|
error(`Failed to read TOML file: ${err.message}`);
|
|
@@ -1319,7 +1802,7 @@ Examples:
|
|
|
1319
1802
|
configs
|
|
1320
1803
|
.command("update")
|
|
1321
1804
|
.description("Update a configuration")
|
|
1322
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1805
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1323
1806
|
.argument("<config-id>", "Configuration ID")
|
|
1324
1807
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1325
1808
|
.option("--name <name>", "Configuration name")
|
|
@@ -1337,7 +1820,7 @@ Examples:
|
|
|
1337
1820
|
let tomlData;
|
|
1338
1821
|
try {
|
|
1339
1822
|
const content = readFileSync(options.fromFile, "utf-8");
|
|
1340
|
-
tomlData =
|
|
1823
|
+
tomlData = parseConfigToml(content);
|
|
1341
1824
|
}
|
|
1342
1825
|
catch (err) {
|
|
1343
1826
|
error(`Failed to read TOML file: ${err.message}`);
|
|
@@ -1375,7 +1858,7 @@ Examples:
|
|
|
1375
1858
|
configs
|
|
1376
1859
|
.command("activate")
|
|
1377
1860
|
.description("Set a configuration as the active (default) configuration")
|
|
1378
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1861
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1379
1862
|
.argument("<config-id>", "Configuration ID")
|
|
1380
1863
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1381
1864
|
.option("--json", "Output as JSON")
|
|
@@ -1400,7 +1883,7 @@ Examples:
|
|
|
1400
1883
|
configs
|
|
1401
1884
|
.command("duplicate")
|
|
1402
1885
|
.description("Duplicate a configuration")
|
|
1403
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1886
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1404
1887
|
.argument("<config-id>", "Configuration ID to duplicate")
|
|
1405
1888
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1406
1889
|
.option("--name <name>", "Name for the new configuration")
|
|
@@ -1428,7 +1911,7 @@ Examples:
|
|
|
1428
1911
|
configs
|
|
1429
1912
|
.command("archive")
|
|
1430
1913
|
.description("Archive a configuration")
|
|
1431
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
1914
|
+
.argument("<workflow-id>", "Workflow ID or key")
|
|
1432
1915
|
.argument("<config-id>", "Configuration ID")
|
|
1433
1916
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1434
1917
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
@@ -2247,6 +2730,109 @@ Examples:
|
|
|
2247
2730
|
process.exit(1);
|
|
2248
2731
|
}
|
|
2249
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
|
+
});
|
|
2250
2836
|
}
|
|
2251
2837
|
function formatFileSize(bytes) {
|
|
2252
2838
|
if (bytes < 1024)
|