primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83

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 (109) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
  2. package/dist/src/commands/documents.js +455 -22
  3. package/dist/src/commands/documents.js.map +1 -1
  4. package/dist/src/commands/functions.js +889 -76
  5. package/dist/src/commands/functions.js.map +1 -1
  6. package/dist/src/commands/locks.js +15 -1
  7. package/dist/src/commands/locks.js.map +1 -1
  8. package/dist/src/commands/prompts.js +19 -2
  9. package/dist/src/commands/prompts.js.map +1 -1
  10. package/dist/src/commands/sync.d.ts +50 -0
  11. package/dist/src/commands/sync.js +587 -316
  12. package/dist/src/commands/sync.js.map +1 -1
  13. package/dist/src/commands/workflows.d.ts +1 -29
  14. package/dist/src/commands/workflows.js +8 -109
  15. package/dist/src/commands/workflows.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +191 -2
  17. package/dist/src/lib/api-client.js +283 -7
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +12 -4
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  22. package/dist/src/lib/document-ingest-artifact.js +505 -0
  23. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  24. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  25. package/dist/src/lib/document-ingest-input.js +132 -0
  26. package/dist/src/lib/document-ingest-input.js.map +1 -0
  27. package/dist/src/lib/document-ingest.d.ts +78 -0
  28. package/dist/src/lib/document-ingest.js +231 -0
  29. package/dist/src/lib/document-ingest.js.map +1 -0
  30. package/dist/src/lib/env-resolver-core.d.ts +2 -2
  31. package/dist/src/lib/env-resolver-core.js +2 -2
  32. package/dist/src/lib/function-bundle.d.ts +6 -0
  33. package/dist/src/lib/function-bundle.js +8 -0
  34. package/dist/src/lib/function-bundle.js.map +1 -1
  35. package/dist/src/lib/function-collect.d.ts +1 -1
  36. package/dist/src/lib/function-collect.js +16 -0
  37. package/dist/src/lib/function-collect.js.map +1 -1
  38. package/dist/src/lib/function-db-types.d.ts +4 -0
  39. package/dist/src/lib/function-db-types.js +46 -2
  40. package/dist/src/lib/function-db-types.js.map +1 -1
  41. package/dist/src/lib/function-document-types.d.ts +83 -0
  42. package/dist/src/lib/function-document-types.js +137 -0
  43. package/dist/src/lib/function-document-types.js.map +1 -1
  44. package/dist/src/lib/function-grants-preflight.js +5 -0
  45. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  46. package/dist/src/lib/function-log-lines.d.ts +76 -0
  47. package/dist/src/lib/function-log-lines.js +160 -0
  48. package/dist/src/lib/function-log-lines.js.map +1 -0
  49. package/dist/src/lib/function-log-row.d.ts +29 -0
  50. package/dist/src/lib/function-log-row.js +73 -0
  51. package/dist/src/lib/function-log-row.js.map +1 -0
  52. package/dist/src/lib/function-log-tail.d.ts +51 -2
  53. package/dist/src/lib/function-log-tail.js +160 -13
  54. package/dist/src/lib/function-log-tail.js.map +1 -1
  55. package/dist/src/lib/function-run.d.ts +289 -0
  56. package/dist/src/lib/function-run.js +389 -0
  57. package/dist/src/lib/function-run.js.map +1 -0
  58. package/dist/src/lib/function-schema-codegen.d.ts +17 -9
  59. package/dist/src/lib/function-schema-codegen.js +30 -29
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -30
  62. package/dist/src/lib/function-sync.js +731 -77
  63. package/dist/src/lib/function-sync.js.map +1 -1
  64. package/dist/src/lib/function-versions.d.ts +122 -0
  65. package/dist/src/lib/function-versions.js +182 -0
  66. package/dist/src/lib/function-versions.js.map +1 -0
  67. package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
  68. package/dist/src/lib/generated-config-surfaces.js +1858 -287
  69. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  70. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  71. package/dist/src/lib/generated-sdk-types.js +1 -1
  72. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  73. package/dist/src/lib/log-inspection.d.ts +2 -0
  74. package/dist/src/lib/log-inspection.js +4 -0
  75. package/dist/src/lib/log-inspection.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +15 -0
  77. package/dist/src/lib/output.js +28 -0
  78. package/dist/src/lib/output.js.map +1 -1
  79. package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
  80. package/dist/src/lib/prompt-schema-codegen.js +212 -0
  81. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  82. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  83. package/dist/src/lib/snapshot-audit-source.js +58 -0
  84. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  85. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  86. package/dist/src/lib/snapshot-audit-store.js +196 -0
  87. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  89. package/dist/src/lib/snapshot-audit.js +431 -0
  90. package/dist/src/lib/snapshot-audit.js.map +1 -0
  91. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  92. package/dist/src/lib/snapshot-build-rows.js +87 -0
  93. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  94. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  95. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  96. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  97. package/dist/src/lib/step-run-table.d.ts +43 -0
  98. package/dist/src/lib/step-run-table.js +129 -0
  99. package/dist/src/lib/step-run-table.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
  102. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  103. package/dist/src/lib/sync-selectors.d.ts +45 -2
  104. package/dist/src/lib/sync-selectors.js +66 -5
  105. package/dist/src/lib/sync-selectors.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
  107. package/dist/src/lib/workflow-toml-validator.js +18 -10
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/package.json +2 -2
@@ -21,17 +21,22 @@ import { join, relative as relativePath } from "path";
21
21
  import { ApiClient } from "../lib/api-client.js";
22
22
  import { resolveAppId } from "../lib/config.js";
23
23
  import { resolveSyncDir } from "../lib/sync-paths.js";
24
- import { activeVersionMode } from "../lib/function-sync.js";
25
24
  import { FunctionSchemaCodegenError, functionSchemaSources, } from "../lib/function-schema-codegen.js";
26
25
  import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
27
26
  import { generateFunctionSwiftTypes } from "../lib/swift-codegen/functionGenerator.js";
28
27
  import { SwiftCodegenError } from "../lib/swift-codegen/schemaToSwift.js";
29
28
  import { success, error, info, warn, keyValue, result as printResult, formatTable, formatDate, formatStatus, json, jsonLine, divider, } from "../lib/output.js";
30
29
  import { confirmPrompt } from "../lib/confirm-prompt.js";
30
+ import { FUNCTION_LOG_COLUMNS, functionLogCells, functionLogLine, } from "../lib/function-log-row.js";
31
+ import { activatedLine, activationOutput, activationPrompt, activationRefusalLine, functionVersionRows, newerVersionLine, versionLine, } from "../lib/function-versions.js";
31
32
  import { parseStatusFilter } from "../lib/object-status-filter.js";
33
+ import { renderStepRunTable } from "../lib/step-run-table.js";
34
+ import { renderRunLogs } from "../lib/function-log-lines.js";
35
+ import { buildWorkflowStepEnvelope } from "../lib/log-inspection.js";
32
36
  import { followLoop } from "../lib/watch.js";
33
37
  import { buildLogFollowSource } from "../lib/function-log-tail.js";
34
38
  import { toFunctionLogInspectionRow } from "../lib/log-inspection.js";
39
+ import { decidePageContinuation, describeIdentity, exitCodeForInvokeStatus, exitCodeForRunStatus, invokeIdLines, logsFlagConflict, refusalLines, renderIdLines, resolveRunFlags, runRuntimeCell, runSliceCells, runtimeForExecutionMode, resumeCommand, startIdLines, userTokenName, waitDelayMs, withMintedToken, EXIT_INTERRUPTED, EXIT_TIMED_OUT, USER_TOKEN_TTL, WAIT_DEFAULT_TIMEOUT_SECONDS, } from "../lib/function-run.js";
35
40
  /**
36
41
  * How far back one `--follow` tick will page to meet its high-water mark.
37
42
  *
@@ -45,25 +50,26 @@ import { toFunctionLogInspectionRow } from "../lib/log-inspection.js";
45
50
  */
46
51
  const FOLLOW_MAX_PAGES_PER_TICK = 10;
47
52
  /**
48
- * One invocation, as a human reads it in the table — #3287.
53
+ * Which cell each shared invocation column reads, and how it is rendered.
49
54
  *
50
- * The FIRST error line only: a stack is eight kilobytes and a table row is
51
- * one line, so the row says what happened and `--json` carries the rest.
55
+ * The ORDER lives in `FUNCTION_LOG_COLUMNS` (#3462); this is only the mapping
56
+ * from a header to the cell `functionLogCells` builds for it, so adding a
57
+ * column is one edit in each place rather than three copies of the order.
52
58
  */
53
- function firstErrorLine(row) {
54
- const message = String(row?.errorMessage ?? "").trim();
55
- if (message)
56
- return message.split("\n")[0];
57
- const stderr = Array.isArray(row?.stderr) ? row.stderr : [];
58
- return stderr.length > 0 ? String(stderr[0].line ?? "") : "";
59
- }
60
- /** The same row, for the follow tail's one-line-per-invocation output. */
61
- function logRow(row) {
62
- const when = row?.startedAt ? String(row.startedAt) : "";
63
- const code = row?.errorCode ? ` ${row.errorCode}` : "";
64
- const detail = firstErrorLine(row);
65
- return `${when} ${row?.status ?? ""} ${row?.trigger?.kind ?? ""} ${row?.runId || row?.invocationId || ""}${code}${detail ? ` ${detail}` : ""}`;
66
- }
59
+ const LOG_COLUMN_KEYS = {
60
+ TIME: "startedAt",
61
+ STATUS: "status",
62
+ TRIGGER: "trigger",
63
+ RUNTIME: "runtime",
64
+ VERSION: "configId",
65
+ "RUN/INVOCATION ID": "runOrInvocation",
66
+ CODE: "errorCode",
67
+ ERROR: "firstError",
68
+ };
69
+ const LOG_COLUMN_FORMATS = {
70
+ TIME: formatDate,
71
+ STATUS: formatStatus,
72
+ };
67
73
  export function registerFunctionsCommands(program) {
68
74
  const functions = program
69
75
  .command("functions")
@@ -142,18 +148,56 @@ Configuration lives in functions/<key>.toml, and the code beside it:
142
148
  // The versions are append-only and nameless; `activeConfigId` is the
143
149
  // only thing that says which one runs, so a listing without it would
144
150
  // report the newest rather than the live one.
151
+ //
152
+ // #3462 — a POINT READ, not a drained listing scanned for the id. Past
153
+ // 25 versions the listing is a page and the active version may not be
154
+ // on it at all; one read by id is both cheaper and correct.
145
155
  let active = null;
146
156
  if (record?.activeConfigId) {
147
- const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
148
- active =
149
- (items || []).find((c) => String(c.configId) === String(record.activeConfigId)) ?? null;
157
+ // #3455 — this single read is also what carries
158
+ // `documentSchemaModels`: the models this version can seed are
159
+ // derived from the schema INSIDE the envelope rather than stored on
160
+ // the row, so the LISTING (one row per version, no R2 read) cannot
161
+ // carry them and only the point read can. Best-effort — an older
162
+ // server sends no such field, and the line then reads exactly as it
163
+ // does for a version pushed without a schema.
164
+ try {
165
+ active = await client.getFunctionConfig(resolvedAppId, functionId, String(record.activeConfigId));
166
+ }
167
+ catch {
168
+ active = null;
169
+ }
170
+ }
171
+ // #3462 — one row of the listing answers "is the active version the
172
+ // newest", which is what tells an operator this function is rolled back.
173
+ let newest = null;
174
+ try {
175
+ const page = await client.listFunctionConfigs(resolvedAppId, functionId, {
176
+ limit: 1,
177
+ });
178
+ newest = (page.items || [])[0] ?? null;
150
179
  }
180
+ catch {
181
+ newest = null;
182
+ }
183
+ const newer = newerVersionLine(newest, record?.activeConfigId, formatDate);
151
184
  if (options.json) {
152
- json({ ...record, activeConfig: active });
185
+ json({
186
+ ...record,
187
+ activeConfig: active,
188
+ ...(newer ? { newerVersion: newest } : {}),
189
+ });
153
190
  return;
154
191
  }
155
192
  printResult("Function ID", record.functionId);
156
193
  printResult("Key", record.functionKey);
194
+ // #3462 — which code this function is running, beside its identity. The
195
+ // version was only reachable by reading the section below, or by asking
196
+ // the API; an operator answering "what is deployed" reads this line.
197
+ printResult("Version", versionLine({
198
+ activeConfigId: record.activeConfigId,
199
+ contentHash: active?.contentHash,
200
+ }));
157
201
  printResult("Description", record.description || "-");
158
202
  printResult("Status", formatStatus(record.status));
159
203
  printResult("Access", record.access || "-");
@@ -168,9 +212,9 @@ Configuration lives in functions/<key>.toml, and the code beside it:
168
212
  printResult(" Config ID", active.configId);
169
213
  printResult(" Entry", active.entry);
170
214
  printResult(" Content Hash", active.contentHash || "-");
171
- // #3281 — the mode words. A server that predates `mode` serializes
172
- // only the `durable` column, which reads the same way.
173
- printResult(" Mode", activeVersionMode(active));
215
+ // #3482 — no Mode line. The config says nothing about how a
216
+ // function runs: `invoke` runs it inside the request, `start` runs
217
+ // it as a task, and every function takes both.
174
218
  printResult(" Limits", active.limits || "-");
175
219
  // #3182 — what this version declared: since #3279 the egress
176
220
  // allowlist, the secrets that may enter the sandbox and the
@@ -180,6 +224,28 @@ Configuration lives in functions/<key>.toml, and the code beside it:
180
224
  ? active.capabilities
181
225
  : [];
182
226
  printResult(" Capabilities", capabilities.length > 0 ? capabilities.join(", ") : "-");
227
+ // #3462 — who put this version live and when, from either door (a
228
+ // push or `functions activate`). A header written before that change
229
+ // carries neither field and reads `unknown` rather than naming an
230
+ // actor nobody recorded.
231
+ printResult(" Activated", activatedLine(record, formatDate));
232
+ if (newer) {
233
+ printResult(" Newer version", newer);
234
+ }
235
+ // #3455 — the document models this version can SEED on a first
236
+ // write. An operator asking "why did the first write not seed
237
+ // `institution`" gets the answer here: either the model is in this
238
+ // list, or the version was pushed without a document schema at all.
239
+ // A server that predates the field sends nothing, which reads the
240
+ // same way as a version pushed without one.
241
+ const documentSchemaModels = Array.isArray(active.documentSchemaModels)
242
+ ? active.documentSchemaModels
243
+ : null;
244
+ printResult(" Document schema", documentSchemaModels === null
245
+ ? "none — this version was pushed without one"
246
+ : documentSchemaModels.length > 0
247
+ ? documentSchemaModels.join(", ")
248
+ : "declares no models");
183
249
  }
184
250
  // #3181 — the ONE surface a function's triggers are visible on. The
185
251
  // hidden `AppWebhook` and `CronTrigger` rows are omitted from
@@ -192,17 +258,11 @@ Configuration lives in functions/<key>.toml, and the code beside it:
192
258
  const cronEntries = Array.isArray(triggers.cron)
193
259
  ? triggers.cron
194
260
  : [];
195
- // #3184 — the database types this function watches. A change trigger
196
- // has no standalone surface at all (there is no `database-triggers
197
- // list`), so this is the only place an operator can see that a write
198
- // to a type runs this function. A server that predates #3184 sends no
199
- // `databases` key, which reads as none.
200
- const databaseEntries = Array.isArray(triggers.databases)
201
- ? triggers.databases
202
- : [];
203
- if (!triggers.webhook &&
204
- cronEntries.length === 0 &&
205
- databaseEntries.length === 0) {
261
+ // #3483 — an OLDER server still answers a `databases` key, and this
262
+ // command ignores it rather than branching on it: the trigger it
263
+ // described was removed, so a section about it would tell an operator
264
+ // that a write runs this function when nothing does.
265
+ if (!triggers.webhook && cronEntries.length === 0) {
206
266
  printResult(" Triggers", "none — this function is invoked over HTTP only");
207
267
  }
208
268
  if (triggers.webhook) {
@@ -220,6 +280,9 @@ Configuration lives in functions/<key>.toml, and the code beside it:
220
280
  { header: "NAME", key: "name" },
221
281
  { header: "ID", key: "triggerId" },
222
282
  { header: "CRON", key: "cron" },
283
+ // #3454 — which runner this schedule's fires use. Always one of
284
+ // the two words: an `any` function's entry names it, and a
285
+ // locked one's takes its lock.
223
286
  { header: "TZ", key: "timezone" },
224
287
  { header: "STATUS", key: "status", format: formatStatus },
225
288
  { header: "NEXT FIRE", key: "nextFireAt", format: formatDate },
@@ -227,15 +290,6 @@ Configuration lives in functions/<key>.toml, and the code beside it:
227
290
  { header: "LAST RUN", key: "lastTriggeredRunId" },
228
291
  ]));
229
292
  }
230
- if (databaseEntries.length > 0) {
231
- printResult(" Database changes", "a committed write to one of these types runs this function");
232
- // A fire carries the rows of every change of the type (the intent's
233
- // 2026-09-09 decision), so the row says which type and no more —
234
- // the READ GRANTS column retired with the grant grammar (#3279).
235
- console.log(formatTable(databaseEntries, [
236
- { header: "DATABASE TYPE", key: "databaseType" },
237
- ]));
238
- }
239
293
  // #3187 / #3279 — the ACTIVE version's manifest: what it registered
240
294
  // and what it TOUCHES, from the push-time collection. Documentation,
241
295
  // never authorization — function code acts as the system, and this
@@ -298,11 +352,129 @@ Configuration lives in functions/<key>.toml, and the code beside it:
298
352
  process.exit(1);
299
353
  }
300
354
  });
355
+ // ── #3462: a function's versions, and pointing it back at one ─────────
356
+ //
357
+ // Every changing push mints an immutable version and repoints the function at
358
+ // it. That was already true and already stored — and entirely invisible: an
359
+ // operator could not list a function's versions, and a bad push was undone by
360
+ // editing the tree and pushing again, with `disable` as the only immediate
361
+ // stop.
362
+ //
363
+ // Both are OPERATIONAL verbs on a configuration group in `docs/cli-design.md`'s
364
+ // sense: they author nothing. A function's versions are nameless, immutable
365
+ // and not in TOML — unlike a prompt's `[[configs]]` blocks, whose `active`
366
+ // marker is why `prompts configs activate` was retired — so there is no TOML
367
+ // spelling of activation to prefer to a verb.
368
+ functions
369
+ .command("configs")
370
+ .description("List a server function's versions, newest first")
371
+ .argument("<function-id>", "Function ID")
372
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
373
+ .option("--limit <n>", "Rows per page (default 25, max 100)")
374
+ .option("--cursor <cursor>", "Continue from a previous page")
375
+ .option("--json", "Output as JSON")
376
+ .action(async (functionId, options) => {
377
+ const resolvedAppId = resolveAppId(undefined, options);
378
+ const client = new ApiClient();
379
+ try {
380
+ // Always a `limit`, so this pages. The server's no-parameter form
381
+ // drains every version and exists for a CLI installed before paging
382
+ // did — this one is not it.
383
+ const { items, nextCursor } = await client.listFunctionConfigs(resolvedAppId, functionId, {
384
+ limit: options.limit ? Number(options.limit) : 25,
385
+ ...(options.cursor ? { cursor: options.cursor } : {}),
386
+ });
387
+ if (options.json) {
388
+ // Full hashes, not the table's twelve characters: a consumer
389
+ // comparing versions needs the value the server stored.
390
+ json({ items, hasMore: !!nextCursor, ...(nextCursor ? { nextCursor } : {}) });
391
+ return;
392
+ }
393
+ if (!items || items.length === 0) {
394
+ info("No versions pushed yet.");
395
+ return;
396
+ }
397
+ console.log(formatTable(functionVersionRows(items), [
398
+ { header: "CONFIG ID", key: "configId" },
399
+ { header: "PUSHED", key: "createdAt", format: formatDate },
400
+ { header: "ACTIVE", key: "active" },
401
+ { header: "CONTENT", key: "content" },
402
+ { header: "ENVELOPE", key: "envelope" },
403
+ { header: "TRIGGERS", key: "triggers" },
404
+ { header: "BY", key: "by" },
405
+ ]));
406
+ if (nextCursor) {
407
+ info(`More versions: --cursor ${nextCursor}`);
408
+ }
409
+ }
410
+ catch (err) {
411
+ error(err.message);
412
+ process.exit(1);
413
+ }
414
+ });
415
+ functions
416
+ .command("activate")
417
+ .description("Point a server function at one of its own existing versions (roll back or forward)")
418
+ .argument("<function-id>", "Function ID")
419
+ .argument("<config-id>", "The version to activate (`functions configs`)")
420
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
421
+ .option("-y, --yes", "Skip confirmation prompt")
422
+ .option("--json", "Output as JSON")
423
+ .addHelpText("after", `
424
+ What activating a version does, and does not:
425
+ Request calls and trigger fires use it from the NEXT call, under that
426
+ version's own capabilities, limits and triggers.
427
+ A task run already in flight finishes on the version it started on — the
428
+ content hash pins its code.
429
+ No version is created. \`config push\` of the tree is still what makes the
430
+ tree's code the newest version.
431
+ `)
432
+ .action(async (functionId, configId, options) => {
433
+ const resolvedAppId = resolveAppId(undefined, options);
434
+ if (!options.yes) {
435
+ let confirmed;
436
+ try {
437
+ confirmed = await confirmPrompt(activationPrompt(String(functionId), String(configId)));
438
+ }
439
+ catch (err) {
440
+ error(err.message);
441
+ process.exit(1);
442
+ }
443
+ if (!confirmed) {
444
+ info("Cancelled.");
445
+ return;
446
+ }
447
+ }
448
+ const client = new ApiClient();
449
+ try {
450
+ const answer = await client.activateFunctionConfig(resolvedAppId, functionId, configId);
451
+ if (options.json) {
452
+ json(answer);
453
+ return;
454
+ }
455
+ const rendered = activationOutput(answer, formatDate);
456
+ if (!rendered.changed) {
457
+ info(rendered.headline);
458
+ return;
459
+ }
460
+ success(rendered.headline);
461
+ for (const [label, value] of rendered.lines)
462
+ printResult(label, value);
463
+ }
464
+ catch (err) {
465
+ // The server's own sentence and code: an archived function, an archived
466
+ // version, a held delete lease and a pointer another writer moved each
467
+ // owe the operator a different next move, and only the code says which
468
+ // one without reading English.
469
+ error(activationRefusalLine(err));
470
+ process.exit(1);
471
+ }
472
+ });
301
473
  // #3181 — a trigger-fired invocation writes a run row, and a function run has
302
474
  // no `workflowId`, so `primitive workflows runs` cannot see it. This verb is
303
475
  // the only way to inspect what a webhook or a schedule actually did
304
476
  // (principle 8).
305
- functions
477
+ const runs = functions
306
478
  .command("runs")
307
479
  .description("List a server function's runs, newest first")
308
480
  .argument("<function-id>", "Function ID")
@@ -318,8 +490,17 @@ Configuration lives in functions/<key>.toml, and the code beside it:
318
490
  ...(options.limit ? { limit: Number(options.limit) } : {}),
319
491
  ...(options.cursor ? { cursor: options.cursor } : {}),
320
492
  });
493
+ // #3482, C-6 — the runtime this run used, derived BEFORE the output is
494
+ // chosen so the table and `--json` say the same thing about the same
495
+ // row. `--json` takes the word or `null`, never the table's blank: a
496
+ // reader parsing this needs "no runtime" to be a value rather than an
497
+ // empty string that sorts beside real ones.
498
+ const withRuntime = (items ?? []).map((run) => ({
499
+ ...run,
500
+ runtime: runtimeForExecutionMode(run?.executionMode),
501
+ }));
321
502
  if (options.json) {
322
- json({ items, nextCursor });
503
+ json({ items: withRuntime, nextCursor });
323
504
  return;
324
505
  }
325
506
  if (!items || items.length === 0) {
@@ -327,18 +508,29 @@ Configuration lives in functions/<key>.toml, and the code beside it:
327
508
  return;
328
509
  }
329
510
  console.log(formatTable(
330
- // #3381 — flatten the slice's refresh count onto the row for the
331
- // REFRESHES column. Blank for a run with no slice record (a run
332
- // started before this child, or a request invocation).
511
+ // #3381, #3566 — flatten the slice record's two counts onto the
512
+ // row for the REFRESHES and RESETS columns. One helper decides
513
+ // both, so a row with no record gets a blank cell in each and a
514
+ // record with no count gets `0`.
333
515
  items.map((run) => ({
334
516
  ...run,
335
- refreshes: run.slice && Number.isFinite(run.slice.refreshCount)
336
- ? String(run.slice.refreshCount)
337
- : "",
517
+ // #3482 — which RUNTIME this run used, from the row's own
518
+ // `executionMode`. One pure mapping, so the word an operator
519
+ // reads here is the word `ctx.runtime` gave the code.
520
+ runtime: runRuntimeCell(run),
521
+ ...runSliceCells(run),
338
522
  })), [
339
523
  { header: "RUN ID", key: "runId" },
340
524
  { header: "STATUS", key: "status", format: formatStatus },
341
525
  { header: "FIRED BY", key: "initiatorKind" },
526
+ // #3482 — the runtime this run used: `request` for a webhook
527
+ // delivery or a `workflow.call` leaf, `task` for everything the
528
+ // engine ran. Blank for a DSL row, which is not a function run.
529
+ { header: "RUNTIME", key: "runtime" },
530
+ // #3462 — WHICH version this run executed. The row has carried
531
+ // `configId` since #3181; nothing showed it, so an operator
532
+ // reading a failed run could not tell which code failed.
533
+ { header: "VERSION", key: "configId" },
342
534
  // #3280 — FIRED BY already said `function`; this is the half that
343
535
  // makes it actionable, so a failed page leads back to the
344
536
  // orchestrator that started it. Blank for a root.
@@ -346,6 +538,11 @@ Configuration lives in functions/<key>.toml, and the code beside it:
346
538
  // #3381 — how many times a task slice's token was refreshed
347
539
  // through the gateway. Blank for a request run.
348
540
  { header: "REFRESHES", key: "refreshes" },
541
+ // #3566 — how many of this run's slices a platform deploy (or an
542
+ // isolate eviction) tore down. The engine re-runs the interrupted
543
+ // step, so a run that reset and then COMPLETED carries no failure
544
+ // and no error code: this cell is the only place it shows.
545
+ { header: "RESETS", key: "resets" },
349
546
  { header: "STARTED", key: "startedAt", format: formatDate },
350
547
  { header: "ENDED", key: "endedAt", format: formatDate },
351
548
  { header: "CODE", key: "errorCode" },
@@ -359,6 +556,109 @@ Configuration lives in functions/<key>.toml, and the code beside it:
359
556
  process.exit(1);
360
557
  }
361
558
  });
559
+ // #3348 — the step-level view of a durable run.
560
+ //
561
+ // A durable function's handler calls named steps and the engine memoizes each
562
+ // completed one to replay it, but nothing exposed them: `workflows runs
563
+ // steps|status|step-detail` resolve their first argument to a workflow
564
+ // DEFINITION, which a function id and a function key alike miss, and
565
+ // `functions runs` above reports only the run row — status, start, end, one
566
+ // error string. For a run that did partial work there was no way to see which
567
+ // steps completed, how long each took, or where the time went.
568
+ //
569
+ // A SUBCOMMAND of `runs`, which keeps its own `<function-id>` argument form.
570
+ // Commander dispatches to a subcommand only when the first operand names one,
571
+ // and a function id is a ULID, so `functions runs <function-id>` is untouched.
572
+ runs
573
+ .command("steps")
574
+ .description("Show step-level details for a function run")
575
+ .argument("<function-id>", "Function ID")
576
+ .argument("<run-id>", "Run ID")
577
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
578
+ .option("--json", "Output as JSON")
579
+ .action(async (functionId, runId, _options, command) => {
580
+ // `optsWithGlobals`, not the action's own `options`. `runs` is a command
581
+ // with arguments AND subcommands, so commander parses ITS options out of
582
+ // the argv before it dispatches here — and `runs` declares `--app` and
583
+ // `--json` too, so both land on the parent and the subcommand's copies
584
+ // read empty. The merged view is what the flags an operator typed
585
+ // actually are.
586
+ const options = command.optsWithGlobals();
587
+ const resolvedAppId = resolveAppId(undefined, options);
588
+ const client = new ApiClient();
589
+ try {
590
+ const { items } = await client.getFunctionStepRuns(resolvedAppId, functionId, runId);
591
+ if (options.json) {
592
+ // The same envelope `workflows runs steps --json` emits: one run's
593
+ // full trace under `items`, never a bare array and never a cursor.
594
+ json(buildWorkflowStepEnvelope(items));
595
+ return;
596
+ }
597
+ if (!items || items.length === 0) {
598
+ info("No step runs found.");
599
+ return;
600
+ }
601
+ console.log(renderStepRunTable(items));
602
+ }
603
+ catch (err) {
604
+ error(err.message);
605
+ process.exit(1);
606
+ }
607
+ });
608
+ // #3348 — end a run that will not settle.
609
+ //
610
+ // `client.functions.terminate` has existed in the SDK since #3186 and nothing
611
+ // reached it from an operator's side, so a run left `running` after its
612
+ // context document was deleted stayed `running` with nothing to do about it.
613
+ runs
614
+ .command("terminate")
615
+ .description("End a function run that will not settle")
616
+ .argument("<function-id>", "Function ID")
617
+ .argument("<run-id>", "Run ID")
618
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
619
+ .option("-y, --yes", "Skip confirmation prompt")
620
+ .option("--json", "Output as JSON")
621
+ .action(async (functionId, runId, _options, command) => {
622
+ // See `runs steps` above: `runs` parses its own `--app`/`--json` out of
623
+ // the argv before dispatching here, so the merged view is the real one.
624
+ const options = command.optsWithGlobals();
625
+ const resolvedAppId = resolveAppId(undefined, options);
626
+ if (!options.yes) {
627
+ let confirm;
628
+ try {
629
+ confirm = await confirmPrompt(`Terminate run ${runId}? Its work stops where it is.`);
630
+ }
631
+ catch (err) {
632
+ error(err.message);
633
+ process.exit(1);
634
+ }
635
+ if (!confirm) {
636
+ info("Cancelled.");
637
+ return;
638
+ }
639
+ }
640
+ const client = new ApiClient();
641
+ try {
642
+ const result = await client.terminateFunctionRun(resolvedAppId, functionId, runId);
643
+ if (options.json) {
644
+ json(result);
645
+ return;
646
+ }
647
+ if (!result.terminated) {
648
+ // The run finished on its own. Reporting a kill would be a lie, and
649
+ // the status it settled at is the answer the operator wanted.
650
+ info(`Run ${result.runId} had already settled (${result.status}); nothing to terminate.`);
651
+ return;
652
+ }
653
+ success(`Run ${result.runId} terminated.`);
654
+ printResult("Status", formatStatus(result.status));
655
+ printResult("Ended", formatDate(result.endedAt));
656
+ }
657
+ catch (err) {
658
+ error(err.message);
659
+ process.exit(1);
660
+ }
661
+ });
362
662
  // #3287 — the invocation logs. `runs` above answers what HAPPENED; this
363
663
  // answers what the function PRINTED and what it threw, which is the thing
364
664
  // an operator reaches for when a function is failing and the response says
@@ -372,6 +672,8 @@ Configuration lives in functions/<key>.toml, and the code beside it:
372
672
  .option("--cursor <cursor>", "Continue from a previous page")
373
673
  .option("--follow", "Append new invocations as they are recorded (tail)")
374
674
  .option("--interval <seconds>", "Poll interval for --follow (default 2)")
675
+ .option("--run <run-id>", "Only this run's records (#3448)")
676
+ .option("--invocation <invocation-id>", "One record, by the id an invoke answered with (#3448)")
375
677
  .option("--json", "Output as JSON")
376
678
  .action(async (functionId, options) => {
377
679
  const resolvedAppId = resolveAppId(undefined, options);
@@ -383,7 +685,47 @@ Configuration lives in functions/<key>.toml, and the code beside it:
383
685
  error("--follow and --cursor cannot be combined; --follow tails from now.");
384
686
  process.exit(1);
385
687
  }
688
+ // #3448 — `--invocation` names ONE record, so every flag that shapes a
689
+ // listing contradicts it, and `--run` is a filter a tail cannot honour.
690
+ const conflict = logsFlagConflict(options);
691
+ if (conflict) {
692
+ error(conflict);
693
+ process.exit(1);
694
+ }
386
695
  try {
696
+ if (options.invocation) {
697
+ // The point read. A record that never existed, one of ANOTHER
698
+ // function, and one the seven-day TTL has expired all answer the
699
+ // same 404 — which is the listing's own filter, said out loud.
700
+ let record;
701
+ try {
702
+ record = await client.getFunctionLog(resolvedAppId, functionId, String(options.invocation));
703
+ }
704
+ catch (err) {
705
+ // An id nothing wrote, one of ANOTHER function, and one the
706
+ // seven-day TTL has expired all answer the same 404 — so the
707
+ // message names the retention rather than implying the platform
708
+ // lost something (edge 37).
709
+ if (err?.statusCode === 404) {
710
+ error(`No invocation ${options.invocation} for this function. ` +
711
+ `Invocation records are kept for seven days.`);
712
+ process.exit(1);
713
+ }
714
+ throw err;
715
+ }
716
+ if (options.json) {
717
+ json(toFunctionLogInspectionRow(record));
718
+ return;
719
+ }
720
+ console.log(formatTable([functionLogCells(record)], FUNCTION_LOG_COLUMNS.map((header) => ({
721
+ header,
722
+ key: LOG_COLUMN_KEYS[header],
723
+ ...(LOG_COLUMN_FORMATS[header]
724
+ ? { format: LOG_COLUMN_FORMATS[header] }
725
+ : {}),
726
+ }))));
727
+ return;
728
+ }
387
729
  if (options.follow) {
388
730
  const intervalSeconds = options.interval ? Number(options.interval) : 2;
389
731
  if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
@@ -424,7 +766,7 @@ Configuration lives in functions/<key>.toml, and the code beside it:
424
766
  return;
425
767
  }
426
768
  for (const row of items)
427
- console.log(logRow(row));
769
+ console.log(functionLogLine(row));
428
770
  },
429
771
  });
430
772
  }
@@ -433,10 +775,34 @@ Configuration lives in functions/<key>.toml, and the code beside it:
433
775
  }
434
776
  return;
435
777
  }
436
- const { items, nextCursor } = await client.listFunctionLogs(resolvedAppId, functionId, {
437
- ...(limit ? { limit } : {}),
438
- ...(options.cursor ? { cursor: options.cursor } : {}),
439
- });
778
+ // #3448 D3448-SO-006, the CLI half. A FILTERED page holding only
779
+ // other runs' records comes back EMPTY WITH A CURSOR, which is an
780
+ // intermediate page and not the end: returning on it — as this did —
781
+ // reports "no logs" for a run whose records are one page further down.
782
+ // The continuation is followed a bounded number of times, and if it is
783
+ // still holding nothing the empty state is printed WITH the cursor
784
+ // rather than as a bare "none".
785
+ let cursor = options.cursor;
786
+ let items = [];
787
+ let nextCursor;
788
+ let exhaustedCursor = null;
789
+ for (let followed = 0;; followed += 1) {
790
+ const page = await client.listFunctionLogs(resolvedAppId, functionId, {
791
+ ...(limit ? { limit } : {}),
792
+ ...(cursor ? { cursor } : {}),
793
+ ...(options.run ? { runId: String(options.run) } : {}),
794
+ });
795
+ items = page.items;
796
+ nextCursor = page.nextCursor;
797
+ const decision = decidePageContinuation(page, followed);
798
+ if (decision.action === "continue") {
799
+ cursor = decision.cursor;
800
+ continue;
801
+ }
802
+ if (decision.action === "exhausted")
803
+ exhaustedCursor = decision.cursor;
804
+ break;
805
+ }
440
806
  if (options.json) {
441
807
  // The shared envelope, whose items are the shared inspection shape:
442
808
  // a consumer classifies and pivots on `outcome`, `nativeStatus` and
@@ -449,26 +815,49 @@ Configuration lives in functions/<key>.toml, and the code beside it:
449
815
  return;
450
816
  }
451
817
  if (!items || items.length === 0) {
452
- info("No invocation logs found for this function.");
818
+ info(options.run
819
+ ? `No invocation logs found for run ${options.run}.`
820
+ : "No invocation logs found for this function.");
821
+ // Not the end: the scan's budget was spent with matching records
822
+ // possibly still below. Printing the cursor is what lets an operator
823
+ // continue rather than concluding there is nothing there.
824
+ if (exhaustedCursor)
825
+ info(`More logs: --cursor ${exhaustedCursor}`);
453
826
  return;
454
827
  }
455
- console.log(formatTable(items.map((row) => ({
456
- startedAt: row.startedAt,
457
- status: row.status,
458
- trigger: row.trigger?.kind ?? "",
459
- // A trigger fire and a task run name their run; a request
460
- // invocation's key is its own id.
461
- runOrInvocation: row.runId || row.invocationId,
462
- errorCode: row.errorCode ?? "",
463
- firstError: firstErrorLine(row),
464
- })), [
465
- { header: "TIME", key: "startedAt", format: formatDate },
466
- { header: "STATUS", key: "status", format: formatStatus },
467
- { header: "TRIGGER", key: "trigger" },
468
- { header: "RUN/INVOCATION ID", key: "runOrInvocation" },
469
- { header: "CODE", key: "errorCode" },
470
- { header: "ERROR", key: "firstError" },
471
- ]));
828
+ if (options.run) {
829
+ // #3453 — reading ONE run, which is a different question from
830
+ // listing a function's invocations. A table of one row per record
831
+ // answers "which invocations were there"; what an operator opened
832
+ // `--run` for is what the run PRINTED, and until now that was only
833
+ // reachable through `--json`. So: the step trace first, then each
834
+ // record oldest first with its own lines under it.
835
+ let trace = null;
836
+ let traceError = null;
837
+ try {
838
+ const read = await client.getFunctionStepRuns(resolvedAppId, functionId, String(options.run));
839
+ trace = read?.items ?? [];
840
+ }
841
+ catch (traceErr) {
842
+ traceError = traceErr?.message ?? String(traceErr);
843
+ }
844
+ const rendered = renderRunLogs({ records: items, trace, traceError });
845
+ if (rendered.warning)
846
+ warn(rendered.warning);
847
+ for (const line of rendered.lines)
848
+ console.log(line);
849
+ if (nextCursor) {
850
+ info(`More logs: --cursor ${nextCursor}`);
851
+ }
852
+ return;
853
+ }
854
+ console.log(formatTable(items.map((row) => functionLogCells(row)), FUNCTION_LOG_COLUMNS.map((header) => ({
855
+ header,
856
+ key: LOG_COLUMN_KEYS[header],
857
+ ...(LOG_COLUMN_FORMATS[header]
858
+ ? { format: LOG_COLUMN_FORMATS[header] }
859
+ : {}),
860
+ }))));
472
861
  if (nextCursor) {
473
862
  info(`More logs: --cursor ${nextCursor}`);
474
863
  }
@@ -761,5 +1150,429 @@ Reclaiming the key means hard-deleting the row:
761
1150
  process.exit(1);
762
1151
  }
763
1152
  });
1153
+ // ── #3448: running a function ───────────────────────────────────────
1154
+ //
1155
+ // `functions list|get|runs|logs` could INSPECT a function and nothing could
1156
+ // run one: invoking a request function, starting a task and waiting for a
1157
+ // run all needed a signed-in app client or a test harness, and stopping an
1158
+ // experimental run needed SDK code (Compound developer feedback SF3).
1159
+ //
1160
+ // Two identities were missing with it. Every HTTP invocation ran as a
1161
+ // caller, so a function fired by cron, a webhook or a database change —
1162
+ // `ctx.user` null, the system principal — could not be exercised from
1163
+ // outside without faking its trigger.
1164
+ //
1165
+ // Every decision these verbs make is in `cli/src/lib/function-run.ts`, where
1166
+ // it is pinned without a server; what is here is the I/O and the rendering.
1167
+ /**
1168
+ * Key → the function's id.
1169
+ *
1170
+ * `invoke` and `start` take the KEY, because that is the public route's
1171
+ * argument and what a developer wrote in their TOML — while `runs`, `logs`
1172
+ * and `steps` keep the function id they have always taken. So the key has to
1173
+ * be resolved, and the drained admin listing is what `functions list` reads.
1174
+ *
1175
+ * #3482 — the ACTIVE VERSION is no longer read. It was point-read for one
1176
+ * reason: the mode pre-check, which refused the wrong verb before a round
1177
+ * trip. There is no wrong verb any more — the caller picks the runtime and
1178
+ * every function takes both — so the read and the pre-check go together.
1179
+ */
1180
+ async function resolveFunctionByKey(client, appId, functionKey) {
1181
+ const { items } = await client.listFunctions(appId);
1182
+ const wanted = functionKey.trim().toLowerCase();
1183
+ const match = (items || []).find((item) => String(item.functionKey ?? "").toLowerCase() === wanted);
1184
+ if (!match) {
1185
+ throw new Error(`No function with key '${functionKey}' in this app. ` +
1186
+ `List them with 'primitive functions list'.`);
1187
+ }
1188
+ return { functionId: String(match.functionId) };
1189
+ }
1190
+ /** The identity a call runs under, and the bearer it presents. */
1191
+ async function planIdentity(client, appId, identity) {
1192
+ if (identity.kind === "system") {
1193
+ const me = await client.getAppProfile(appId);
1194
+ return {
1195
+ describe: describeIdentity({ kind: "system", byUserId: me.userId }),
1196
+ actingUserId: null,
1197
+ asSystem: true,
1198
+ };
1199
+ }
1200
+ if (identity.kind === "user") {
1201
+ return {
1202
+ describe: describeIdentity({ kind: "user", userId: identity.userId }),
1203
+ actingUserId: identity.userId,
1204
+ asSystem: false,
1205
+ };
1206
+ }
1207
+ const me = await client.getAppProfile(appId);
1208
+ return {
1209
+ describe: describeIdentity({
1210
+ kind: "self",
1211
+ userId: me.userId,
1212
+ appRole: me.appRole,
1213
+ }),
1214
+ actingUserId: me.userId,
1215
+ asSystem: false,
1216
+ };
1217
+ }
1218
+ /** Print an envelope's ids, each followed by the command that takes it. */
1219
+ function printIdLines(lines) {
1220
+ for (const line of renderIdLines(lines))
1221
+ console.log(line);
1222
+ }
1223
+ functions
1224
+ .command("invoke")
1225
+ .allowExcessArguments(false)
1226
+ .description("Run a REQUEST function over the public route and print its result — the same call `client.functions.invoke` makes")
1227
+ .argument("<key>", "Function key (as written in functions/<key>.toml)")
1228
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1229
+ .option("--input <json>", "The handler's input, as JSON (default {})")
1230
+ .option("--context-doc-id <id>", "Context document for the invocation")
1231
+ .option("--timeout <seconds>", "Wall-clock budget; the platform clamps at 30s")
1232
+ .option("--user <user-id>", "Run as this app user (admin/owner only)")
1233
+ .option("--as <principal>", "Run with no caller: --as system (admin/owner only)")
1234
+ .option("--json", "Output as JSON")
1235
+ .action(async (functionKey, options) => {
1236
+ const resolvedAppId = resolveAppId(undefined, options);
1237
+ const flags = resolveRunFlags(options);
1238
+ if (flags.ok === false) {
1239
+ error(flags.error);
1240
+ process.exit(1);
1241
+ }
1242
+ const client = new ApiClient();
1243
+ try {
1244
+ const target = await resolveFunctionByKey(client, resolvedAppId, functionKey);
1245
+ const identity = await planIdentity(client, resolvedAppId, flags.identity);
1246
+ const body = {
1247
+ rootInput: flags.input,
1248
+ ...(options.contextDocId ? { contextDocId: options.contextDocId } : {}),
1249
+ ...(flags.timeoutSeconds
1250
+ ? { timeoutMs: Math.round(flags.timeoutSeconds * 1000) }
1251
+ : {}),
1252
+ };
1253
+ const answer = await runAs(client, resolvedAppId, flags.identity, "invoke", functionKey, (bearer) => client.invokeFunction(resolvedAppId, functionKey, body, {
1254
+ ...(bearer ? { bearer } : {}),
1255
+ asSystem: identity.asSystem,
1256
+ // #3482 — the ROUTE says which runtime this call means. The
1257
+ // admin-only system door is one route for both, so the body still
1258
+ // names it there and only there.
1259
+ runtime: "request",
1260
+ ...(identity.asSystem ? {} : {}),
1261
+ }));
1262
+ const envelope = answer.body ?? {};
1263
+ if (options.json) {
1264
+ json({
1265
+ functionId: target.functionId,
1266
+ functionKey,
1267
+ identity: identity.describe,
1268
+ httpStatus: answer.httpStatus,
1269
+ ...(answer.retryAfterSeconds !== null
1270
+ ? { retryAfterSeconds: answer.retryAfterSeconds }
1271
+ : {}),
1272
+ ...envelope,
1273
+ });
1274
+ process.exit(exitCodeForInvokeAnswer(answer));
1275
+ }
1276
+ if (answer.httpStatus >= 400) {
1277
+ renderRefusal(answer);
1278
+ // The ids, each with the command that takes it — the same rendering
1279
+ // a success gets, because a refusal is when an operator most needs
1280
+ // the next command (CR3448-004). A REFUSAL CAN CARRY AN INVOCATION
1281
+ // ID: the two 500s the platform records answer one (edge 35), and
1282
+ // printing the function id bare beside it left the record findable
1283
+ // only by someone who already knew the flag.
1284
+ printIdLines(invokeIdLines({
1285
+ functionId: target.functionId,
1286
+ invocationId: envelope.invocationId,
1287
+ }));
1288
+ process.exit(1);
1289
+ }
1290
+ printResult("Status", formatStatus(String(envelope.status ?? "")));
1291
+ if (envelope.output !== undefined) {
1292
+ console.log("Output");
1293
+ console.log(JSON.stringify(envelope.output, null, 2));
1294
+ }
1295
+ if (envelope.error)
1296
+ printResult("Error", String(envelope.error));
1297
+ if (envelope.errorCode)
1298
+ printResult("Code", String(envelope.errorCode));
1299
+ if (envelope.limits) {
1300
+ printResult("Limits", `cpuMs ${envelope.limits.cpuMs}, subRequests ${envelope.limits.subRequests}, ratePerMinute ${envelope.limits.ratePerMinute}`);
1301
+ }
1302
+ console.log(identity.describe);
1303
+ printIdLines(invokeIdLines({
1304
+ functionId: target.functionId,
1305
+ invocationId: envelope.invocationId,
1306
+ }));
1307
+ process.exit(exitCodeForInvokeAnswer(answer));
1308
+ }
1309
+ catch (err) {
1310
+ error(err.message);
1311
+ process.exit(1);
1312
+ }
1313
+ });
1314
+ functions
1315
+ .command("start")
1316
+ .allowExcessArguments(false)
1317
+ .description("Start a TASK function and print its run id")
1318
+ .argument("<key>", "Function key (as written in functions/<key>.toml)")
1319
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1320
+ .option("--input <json>", "The handler's input, as JSON (default {})")
1321
+ .option("--context-doc-id <id>", "Context document the run is keyed under")
1322
+ .option("--run-key <key>", "Idempotency key; a repeat replays the existing run")
1323
+ .option("--user <user-id>", "Run as this app user (admin/owner only)")
1324
+ .option("--as <principal>", "Run with no caller: --as system (admin/owner only)")
1325
+ .option("--wait", "Wait for the run to settle, then print its outcome")
1326
+ .option("--timeout <seconds>", "Budget for --wait (default 900)")
1327
+ .option("--json", "Output as JSON")
1328
+ .action(async (functionKey, options) => {
1329
+ const resolvedAppId = resolveAppId(undefined, options);
1330
+ const flags = resolveRunFlags(options);
1331
+ if (flags.ok === false) {
1332
+ error(flags.error);
1333
+ process.exit(1);
1334
+ }
1335
+ const client = new ApiClient();
1336
+ try {
1337
+ const target = await resolveFunctionByKey(client, resolvedAppId, functionKey);
1338
+ const identity = await planIdentity(client, resolvedAppId, flags.identity);
1339
+ // D3448-014 — the task path defaults the context to the acting user's
1340
+ // `AppUser.rootDocId`, and the app user provisioned for an admin has
1341
+ // none, so the advertised `functions start <key> --wait` would refuse
1342
+ // `CONTEXT_DOC_REQUIRED` on a fresh app. The route that mints one is
1343
+ // idempotent, so asking for it is free on the second call.
1344
+ //
1345
+ // `--as system` keeps the synthetic `fn:<functionId>` context and does
1346
+ // NOT call the route: there is no app user to mint a document for.
1347
+ let contextDocId = options.contextDocId;
1348
+ if (!contextDocId && !identity.asSystem && identity.actingUserId) {
1349
+ const root = await client.ensureUserRootDocument(resolvedAppId, identity.actingUserId);
1350
+ contextDocId = root.rootDocId;
1351
+ }
1352
+ const body = {
1353
+ rootInput: flags.input,
1354
+ // #3482 — `--as system` is the ONE door that is a single route for
1355
+ // both runtimes, so it keeps the deprecated body selector; every
1356
+ // other call says it with the route.
1357
+ ...(identity.asSystem ? { mode: "task" } : {}),
1358
+ ...(contextDocId ? { contextDocId } : {}),
1359
+ ...(options.runKey ? { runKey: options.runKey } : {}),
1360
+ };
1361
+ const answer = await runAs(client, resolvedAppId, flags.identity, "start", functionKey, (bearer) => client.invokeFunction(resolvedAppId, functionKey, body, {
1362
+ ...(bearer ? { bearer } : {}),
1363
+ asSystem: identity.asSystem,
1364
+ runtime: "task",
1365
+ }));
1366
+ const envelope = answer.body ?? {};
1367
+ if (answer.httpStatus >= 400) {
1368
+ if (options.json) {
1369
+ json({
1370
+ functionId: target.functionId,
1371
+ functionKey,
1372
+ identity: identity.describe,
1373
+ httpStatus: answer.httpStatus,
1374
+ ...(answer.retryAfterSeconds !== null
1375
+ ? { retryAfterSeconds: answer.retryAfterSeconds }
1376
+ : {}),
1377
+ ...envelope,
1378
+ });
1379
+ }
1380
+ else {
1381
+ renderRefusal(answer);
1382
+ }
1383
+ process.exit(1);
1384
+ }
1385
+ const runId = String(envelope.runId ?? "");
1386
+ if (!options.json) {
1387
+ if (envelope.existing) {
1388
+ info(`Replayed the existing run for this run key (existing).`);
1389
+ }
1390
+ console.log(identity.describe);
1391
+ printIdLines(startIdLines({ functionId: target.functionId, runId }));
1392
+ }
1393
+ if (!options.wait) {
1394
+ if (options.json) {
1395
+ json({
1396
+ functionId: target.functionId,
1397
+ functionKey,
1398
+ identity: identity.describe,
1399
+ httpStatus: answer.httpStatus,
1400
+ ...envelope,
1401
+ });
1402
+ }
1403
+ process.exit(0);
1404
+ }
1405
+ // The run id is printed BEFORE the wait begins, so an operator who
1406
+ // interrupts still holds the thing they need to resume.
1407
+ const outcome = await waitForFunctionRun({
1408
+ client,
1409
+ appId: resolvedAppId,
1410
+ functionId: target.functionId,
1411
+ runId,
1412
+ timeoutSeconds: flags.timeoutSeconds ?? WAIT_DEFAULT_TIMEOUT_SECONDS,
1413
+ json: !!options.json,
1414
+ });
1415
+ process.exit(outcome);
1416
+ }
1417
+ catch (err) {
1418
+ error(err.message);
1419
+ process.exit(1);
1420
+ }
1421
+ });
1422
+ runs
1423
+ .command("wait")
1424
+ .allowExcessArguments(false)
1425
+ .description("Poll a function run until it settles")
1426
+ .argument("<function-id>", "Function ID")
1427
+ .argument("<run-id>", "Run ID")
1428
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1429
+ .option("--timeout <seconds>", "Give up after this many seconds (default 900)")
1430
+ .option("--json", "Output as JSON")
1431
+ .action(async (functionId, runId, _options, command) => {
1432
+ // `optsWithGlobals`, for the reason `runs steps` records: `runs` declares
1433
+ // `--app` and `--json` of its own, so both land on the parent.
1434
+ const options = command.optsWithGlobals();
1435
+ const resolvedAppId = resolveAppId(undefined, options);
1436
+ const flags = resolveRunFlags({ timeout: options.timeout });
1437
+ if (flags.ok === false) {
1438
+ error(flags.error);
1439
+ process.exit(1);
1440
+ }
1441
+ const client = new ApiClient();
1442
+ try {
1443
+ const outcome = await waitForFunctionRun({
1444
+ client,
1445
+ appId: resolvedAppId,
1446
+ functionId,
1447
+ runId,
1448
+ timeoutSeconds: flags.timeoutSeconds ?? WAIT_DEFAULT_TIMEOUT_SECONDS,
1449
+ json: !!options.json,
1450
+ });
1451
+ process.exit(outcome);
1452
+ }
1453
+ catch (err) {
1454
+ error(err.message);
1455
+ process.exit(1);
1456
+ }
1457
+ });
1458
+ }
1459
+ /**
1460
+ * A refusal, rendered from the envelope the server sent — #3448.
1461
+ *
1462
+ * `ApiClient.invokeFunction` hands the answer back rather than throwing for
1463
+ * exactly this: a 429 carries a `Retry-After` an operator needs and a 409 mode
1464
+ * mismatch carries the version's real mode, and re-deriving either from an
1465
+ * exception message would be re-deriving what the server already said.
1466
+ */
1467
+ function renderRefusal(answer) {
1468
+ const lines = refusalLines(answer);
1469
+ error(lines.message);
1470
+ for (const field of lines.fields)
1471
+ printResult(field.label, field.value);
1472
+ for (const detail of lines.details)
1473
+ info(` ${detail}`);
1474
+ }
1475
+ /** Exit 0 only for a completed invocation; every refusal and every terminal failure is 1. */
1476
+ function exitCodeForInvokeAnswer(answer) {
1477
+ if (answer.httpStatus >= 400)
1478
+ return 1;
1479
+ return exitCodeForInvokeStatus(answer.body?.status);
1480
+ }
1481
+ /**
1482
+ * Run a call under the operator's own identity, or under a ten-minute token
1483
+ * minted for `--user` — D3448-009.
1484
+ *
1485
+ * The token is a REAL credential for another user, so it is revoked on every
1486
+ * exit path including Ctrl-C (the wrapper's `finally`), and its value never
1487
+ * reaches stdout, stderr or `--json`: `use` receives it and nothing else does.
1488
+ */
1489
+ async function runAs(client, appId, identity, verb, functionKey, use) {
1490
+ if (identity.kind !== "user")
1491
+ return use(undefined);
1492
+ return withMintedToken({
1493
+ mint: async () => {
1494
+ const minted = await client.createToken(appId, {
1495
+ name: userTokenName(verb, functionKey),
1496
+ ttl: USER_TOKEN_TTL,
1497
+ userId: identity.userId,
1498
+ });
1499
+ return { token: minted.token, tokenId: minted.tokenId };
1500
+ },
1501
+ revoke: (tokenId) => client.revokeToken(appId, tokenId).then(() => undefined),
1502
+ warn: (message) => warn(message),
1503
+ }, (token) => use(token));
1504
+ }
1505
+ /**
1506
+ * Poll one run until it settles, the budget is spent, or the operator
1507
+ * interrupts — #3448 behavior 16.
1508
+ *
1509
+ * It polls the ADMIN single-run read, never the runs listing: that reports the
1510
+ * STORED status, and a durable run's row stays `running` until something asks
1511
+ * the engine, so a wait built on it would never settle.
1512
+ *
1513
+ * Ctrl-C exits 130 after printing the resume command, and a second one exits
1514
+ * at once — the failures sweep's shape, and the workflows group's convention.
1515
+ */
1516
+ async function waitForFunctionRun(args) {
1517
+ const { client, appId, functionId, runId } = args;
1518
+ const deadline = Date.now() + args.timeoutSeconds * 1000;
1519
+ let interrupted = false;
1520
+ let interruptCount = 0;
1521
+ const onSigint = () => {
1522
+ interruptCount += 1;
1523
+ if (interruptCount > 1)
1524
+ process.exit(EXIT_INTERRUPTED);
1525
+ interrupted = true;
1526
+ };
1527
+ process.on("SIGINT", onSigint);
1528
+ try {
1529
+ let attempt = 0;
1530
+ for (;;) {
1531
+ const read = await client.getFunctionRun(appId, functionId, runId);
1532
+ const status = String(read?.status?.status ?? "");
1533
+ const settled = exitCodeForRunStatus(status);
1534
+ if (settled !== null) {
1535
+ if (args.json) {
1536
+ json({ functionId, runId, ...read });
1537
+ return settled;
1538
+ }
1539
+ printResult("Status", formatStatus(status));
1540
+ if (read?.status?.output !== undefined) {
1541
+ console.log("Output");
1542
+ console.log(JSON.stringify(read.status.output, null, 2));
1543
+ }
1544
+ const failure = read?.status?.error;
1545
+ if (failure) {
1546
+ printResult("Error", typeof failure === "string" ? failure : String(failure?.message ?? ""));
1547
+ }
1548
+ if (read?.run?.errorCode)
1549
+ printResult("Code", String(read.run.errorCode));
1550
+ return settled;
1551
+ }
1552
+ if (interrupted) {
1553
+ if (!args.json) {
1554
+ info(`Stopped waiting. The run is still going; resume with:`);
1555
+ info(` ${resumeCommand(functionId, runId)}`);
1556
+ }
1557
+ return EXIT_INTERRUPTED;
1558
+ }
1559
+ if (Date.now() >= deadline) {
1560
+ if (args.json) {
1561
+ json({ functionId, runId, timedOut: true, ...read });
1562
+ }
1563
+ else {
1564
+ warn(`Gave up after ${args.timeoutSeconds}s; the run is still ${status || "in flight"}. Resume with:`);
1565
+ info(` ${resumeCommand(functionId, runId)}`);
1566
+ }
1567
+ return EXIT_TIMED_OUT;
1568
+ }
1569
+ const delay = Math.min(waitDelayMs(attempt), Math.max(0, deadline - Date.now()));
1570
+ attempt += 1;
1571
+ await new Promise((resolve) => setTimeout(resolve, delay));
1572
+ }
1573
+ }
1574
+ finally {
1575
+ process.off("SIGINT", onSigint);
1576
+ }
764
1577
  }
765
1578
  //# sourceMappingURL=functions.js.map