@cotal-ai/runtime 0.100.2 → 0.101.0

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 (39) hide show
  1. package/dist/engine-host.d.ts +4 -0
  2. package/dist/engine-host.d.ts.map +1 -1
  3. package/dist/engine-host.js +1 -1
  4. package/dist/engine-host.js.map +1 -1
  5. package/dist/index.js +19 -19
  6. package/dist/index.js.map +1 -1
  7. package/dist/log-line.d.ts +8 -0
  8. package/dist/log-line.d.ts.map +1 -0
  9. package/dist/log-line.js +10 -0
  10. package/dist/log-line.js.map +1 -0
  11. package/dist/mesh-handler.d.ts +9 -4
  12. package/dist/mesh-handler.d.ts.map +1 -1
  13. package/dist/mesh-handler.js +38 -12
  14. package/dist/mesh-handler.js.map +1 -1
  15. package/dist/offline-command.d.ts +6 -1
  16. package/dist/offline-command.d.ts.map +1 -1
  17. package/dist/offline-command.js +117 -15
  18. package/dist/offline-command.js.map +1 -1
  19. package/dist/resolve-checkpoint.d.ts +17 -8
  20. package/dist/resolve-checkpoint.d.ts.map +1 -1
  21. package/dist/resolve-checkpoint.js +51 -11
  22. package/dist/resolve-checkpoint.js.map +1 -1
  23. package/dist/run-command.d.ts +13 -1
  24. package/dist/run-command.d.ts.map +1 -1
  25. package/dist/run-command.js +354 -78
  26. package/dist/run-command.js.map +1 -1
  27. package/dist/run-driver.d.ts +5 -0
  28. package/dist/run-driver.d.ts.map +1 -1
  29. package/dist/run-driver.js +8 -5
  30. package/dist/run-driver.js.map +1 -1
  31. package/dist/run-host.d.ts +3 -2
  32. package/dist/run-host.d.ts.map +1 -1
  33. package/dist/run-host.js +55 -11
  34. package/dist/run-host.js.map +1 -1
  35. package/dist/workflow-views.d.ts +18 -2
  36. package/dist/workflow-views.d.ts.map +1 -1
  37. package/dist/workflow-views.js +89 -19
  38. package/dist/workflow-views.js.map +1 -1
  39. package/package.json +4 -4
@@ -19,26 +19,27 @@
19
19
  import { randomBytes, randomUUID } from "node:crypto";
20
20
  import { jetstream, jetstreamManager } from "@nats-io/jetstream";
21
21
  import { Kvm } from "@nats-io/kv";
22
- import { BASELINE_LIFECYCLE_ENDPOINT, EpEnvelopeError, assertLifecycleToken, LANG_PROBLEM_DETAIL_KIND, dialerFor, freezeExpectedSet, invokeRepairingSplit, noLiveInstances, newTakeoverId, newIdentity, mintCreds, issuedUserCaller, openRecordsBucket, admissionBucket, createRunAdmission, readRunAdmission, revokeRunAdmission, chatSubject, DEV_OWNER, readRunProgram, readRunRecord, renderLifecycleBlocked, replayRunJournal, resolveService, runDriverCaller, RUN_LAUNCH_DEADLINE_MS, standaloneConnectOpts, unansweredRail, unansweredRequest, } from "@cotal-ai/core";
23
- import { RunHeld } from "@cotal-ai/lang";
24
- import { CommandFailure, presentationOf, recordOperationSpace, connectOrExit, controlCaller, endpointAuth, resolveControlTarget, resolveSeatControlTarget } from "@cotal-ai/workspace";
22
+ import { assertValidChannel, BASELINE_LIFECYCLE_ENDPOINT, chatStream, EpEnvelopeError, isStreamNotFound, hasDuplicateNames, assertIdToken, assertLifecycleToken, LANG_PROBLEM_DETAIL_KIND, dialerFor, freezeExpectedSet, invokeRepairingSplit, noLiveInstances, newTakeoverId, newIdentity, mintCreds, issuedUserCaller, openRecordsBucket, admissionBucket, admissionKey, createRunAdmission, readRunAdmission, revokeRunAdmission, chatSubject, DEV_OWNER, readRunProgram, readRunRecord, renderLifecycleBlocked, replayRunJournal, resolveService, runDriverCaller, RUN_LAUNCH_DEADLINE_MS, standaloneConnectOpts, oneLine, unansweredRail, unansweredRequest, } from "@cotal-ai/core";
23
+ import { CATALOG, CheckpointNotAmendable, CheckpointNotOpen, DurationError, isDuration, LangErrors, parseDuration, RunHeld } from "@cotal-ai/lang";
24
+ import { boundedValue, CommandFailure, commandFailureReport, failureEnvelope, presentationOf, recordOperationSpace, renderReport, terminalWidth, writeEnvelope, connectOrExit, controlCaller, endpointAuth, resolveControlTarget, resolveSeatControlTarget, localStackRoot, spaceStreamsAbsent, spaceStreamsExpected, connectOrThrow, isConnectRefusal, SHOWN_VALUE_MAX, } from "@cotal-ai/workspace";
25
25
  import { offlineWorkflow, sourceOf } from "./offline-command.js";
26
26
  import { seatTurnCommand } from "./seat-turn-command.js";
27
- import { printRunDetail, printRunList, runReceipt } from "./workflow-views.js";
27
+ import { openRequestKeys, printRunDetail, printRunList, runReceipt } from "./workflow-views.js";
28
28
  import { startRun, driveRun } from "./run-driver.js";
29
29
  import { migrateRun } from "./migrate.js";
30
30
  import { cotalLangRunHost as runHost, journalRows } from "./run-host.js";
31
31
  import { createRunEffectHost } from "./run-effect-host.js";
32
32
  import { createRunScopeAuthority } from "./run-scope-authority.js";
33
33
  import { createRunRecordHost, runRecordView } from "./run-record-host.js";
34
- import { locateOpenCheckpoint, answerOpenCheckpoint, locateAcceptedAnswer, amendAcceptedAnswer } from "./resolve-checkpoint.js";
35
- const USAGE = 'usage: cotal run <start --file <program> [--timeout <dur>] | resume <runId> [--local --file <program>] | ps [--endpoint <ep>] [--json] [--page [--limit <n>] [--cursor <cursor>]] | journal <runId> [--endpoint <ep>] [--json] | answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>] | amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>] | revoke <runId> --local --by <who> --reason <text> [--endpoint <ep>] | migrate <runId> --local --file <program> [--endpoint <ep>]> [--local [--admit-read <channels> --admit-publish <channels>]] [--on <instance>] [--space <s>] [--server <url>] [--creds <path>]';
34
+ import { logLineText } from "./log-line.js";
35
+ import { locateOpenCheckpoint, answerOpenCheckpoint, locateAcceptedAnswer, amendAcceptedAnswer, RunAnswerBlocked } from "./resolve-checkpoint.js";
36
+ const USAGE = 'usage: cotal run [--endpoint <ep>] [--json] [--page [--limit <n>] [--cursor <cursor>]] | cotal run <start --file <program> [--timeout <dur>] | resume <runId> [--local --file <program>] | show <runId> [--endpoint <ep>] [--limit <n>] [--json] | answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>] | amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>] | revoke <runId> --local --by <who> --reason <text> [--endpoint <ep>] | check <runId> <program> [--endpoint <ep>]> [--local [--admit-read <channels> --admit-publish <channels>]] [--on <instance>] [--space <s>] [--server <url>] [--creds <path>]';
36
37
  /** One raw connection to the resolved mesh under the verb's OWN profile, with the planes over it.
37
38
  * A drive rides the run's `run-driver` credential (SPEC 14.6), minted for the one run and attempt
38
39
  * it drives; a read or an answer rides a one-shot `run-operator` credential for that call. An
39
40
  * open mesh connects bare either way. */
40
41
  async function openPlanes(values, role, mint) {
41
- const conn = await connectOrExit(values, role, { mint });
42
+ const conn = await connectOrThrow(values, role, { mint });
42
43
  const nc = await dialerFor(conn.server)({
43
44
  servers: conn.server,
44
45
  ...standaloneConnectOpts({ ...endpointAuth(conn), tls: conn.tls }),
@@ -221,7 +222,7 @@ async function revoke(values, runId) {
221
222
  process.exit(1);
222
223
  }
223
224
  const endpoint = values.endpoint ?? "manager";
224
- const conn = await connectOrExit(values, "run-admitter", { mint: { runAdmitter: { endpoint, runId } } });
225
+ const conn = await connectOrThrow(values, "run-admitter", { mint: { runAdmitter: { endpoint, runId } } });
225
226
  refuseSingleCredential(conn);
226
227
  if (conn.auth === undefined) {
227
228
  console.error("run revoke: an open mesh keeps no admission store, so there is nothing to revoke there");
@@ -230,13 +231,19 @@ async function revoke(values, runId) {
230
231
  const nc = await dialerFor(conn.server)({ servers: conn.server, ...standaloneConnectOpts({ ...endpointAuth(conn), tls: conn.tls }), maxReconnectAttempts: 0 });
231
232
  try {
232
233
  const kv = await new Kvm(nc).open(admissionBucket(conn.space));
234
+ // The marker sits beside the run's admission. With no admission there is no run here to revoke,
235
+ // so refuse before anything is written.
236
+ if (await kv.get(admissionKey(endpoint, runId)) === null)
237
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", `run ${runId} has no admission on endpoint ${endpoint}, so there is nothing to revoke; nothing was written`, { target: { space: conn.space, endpoint, run: runId }, remedy: "list the recorded runs with cotal run" });
233
238
  await revokeRunAdmission(kv, endpoint, { version: 1, runId, reason: values.reason, by: values.by, revokedAt: Date.now() });
234
239
  const view = await readRunAdmission(await jetstreamManager(nc), conn.space, endpoint, runId);
235
240
  console.log(`run ${runId} on ${endpoint}: revoked by ${view.revoked.by} (${view.revoked.reason}); its hosts refuse the next channel effect, and it is not resumed or taken back`);
236
241
  // What the operator will see NEXT, because the two surfaces disagreed for as long as one of
237
242
  // them never read the marker. Saying it here means the acknowledgement and the table cannot
238
243
  // drift apart without somebody noticing at the moment of the revoke.
239
- console.log(`run ps now lists ${runId} as revoked; the status record is left as its driver last wrote it`);
244
+ console.log(`cotal run now lists ${runId} as revoked; the status record is left as its driver last wrote it`);
245
+ // The marker is read only at a channel effect, so a run that makes none keeps computing.
246
+ console.log("a revoke does not cancel the run: work that touches no channel, such as sleep or log, keeps running until the host driving it stops");
240
247
  }
241
248
  finally {
242
249
  await nc.drain().catch(() => nc.close());
@@ -308,22 +315,43 @@ async function drive(values, planes, a) {
308
315
  handler,
309
316
  ...(values.file !== undefined ? { file: values.file } : {}),
310
317
  ...(planes.resultBytes !== undefined ? { resultBytes: planes.resultBytes } : {}),
318
+ onLog: (line) => console.log(`run ${runId}: log ${logLineText(line)}`),
311
319
  };
312
320
  if (a.mode === "new")
313
321
  console.log(`starting run ${runId} on endpoint ${endpoint} in space ${planes.space}`);
314
- const out = a.mode === "new" ? await startRun(planes.js, planes.jsm, req) : await driveRun(planes.js, planes.jsm, req);
322
+ let out;
323
+ try {
324
+ out = a.mode === "new" ? await startRun(planes.js, planes.jsm, req) : await driveRun(planes.js, planes.jsm, req);
325
+ }
326
+ catch (e) {
327
+ // The driver records a program that failed on the run's status under this drive's lease, then
328
+ // rethrows its error. Only that record makes the outcome failed; any other throw stays unknown.
329
+ const status = (await readRunRecord(req.kv, endpoint, runId).catch(() => undefined))?.status?.value;
330
+ if (status?.state !== "failed" || status.holder !== who.id || status.epoch !== epoch || status.fencingToken !== fencingToken)
331
+ throw e;
332
+ // A language code is named by its catalog title. Other recorded text is untrusted, so only its
333
+ // first line is shown, and the report escapes its control characters.
334
+ const code = status.failure?.code;
335
+ const why = code !== undefined && Object.hasOwn(CATALOG, code) ? `: ${code} ${CATALOG[code]}` : `${code !== undefined ? ` (${code})` : ""}: ${(status.failure?.message ?? e.message).split("\n")[0]}`;
336
+ const show = `cotal run show ${runId} --local${endpoint === "manager" ? "" : ` --endpoint ${endpoint}`}`;
337
+ throw new CommandFailure("failed", "OPERATION_FAILED", `run ${runId} failed${why}`, { target: { space: planes.space, endpoint, run: runId }, remedy: `read its recorded failure with ${show}` });
338
+ }
315
339
  reportOutcome(runId, out);
316
340
  }
317
341
  finally {
318
342
  await mediator.nc.drain().catch(() => mediator.nc.close());
319
343
  }
320
344
  }
345
+ /** The endpoints a listing's runs are recorded under, each once. */
346
+ const recordedUnder = (rows) => [...new Set(rows.map((row) => row.endpoint))].sort();
321
347
  async function ps(values, planes) {
322
348
  // The same rows the manager answers `run-ps` with, read under this call's operator credential:
323
349
  // the record's state beside its revocation marker (SPEC 14.8), so `--local` and hosted agree
324
350
  // row for row, `--json` included.
325
351
  const rows = await runHost.list(planes, values.endpoint !== undefined ? { endpoint: values.endpoint } : {});
326
- printRunList(planes.space, rows, values);
352
+ // An endpoint no run lives under is named, with the endpoints that hold runs.
353
+ const unmatched = values.endpoint !== undefined && !rows.length ? { endpoint: values.endpoint, recordedUnder: recordedUnder(await runHost.list(planes, {})) } : undefined;
354
+ printRunList(planes.space, rows, values, unmatched);
327
355
  }
328
356
  async function journal(values, planes, endpoint, runId, takeoverId) {
329
357
  if (runId === undefined) {
@@ -346,7 +374,12 @@ async function answer(values, reader, runId, stepKey, takeoverId) {
346
374
  }
347
375
  const endpoint = values.endpoint ?? "manager";
348
376
  const parsedValue = parseAnswerValue(values);
349
- const open = await locateOpenCheckpoint({ kv: reader.kv, js: reader.js, jsm: reader.jsm, space: reader.space, endpoint }, { runId, stepKey, takeoverId });
377
+ const open = await locateOpenCheckpoint({ kv: reader.kv, js: reader.js, jsm: reader.jsm, space: reader.space, endpoint }, { runId, stepKey, takeoverId }).catch((e) => {
378
+ // Locating writes nothing, so a pause it does not find is refused as the hosted answer refuses it.
379
+ if (e instanceof CheckpointNotOpen)
380
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", e.message, { target: { space: reader.space, endpoint, run: runId }, remedy: `read the run's open requests with cotal run show ${runId} --local` });
381
+ throw e;
382
+ });
350
383
  // Resume is holder-bound (SPEC 13.10) and the CLI is not the driver: the resolver presents as
351
384
  // the ARMING holder it read off the checkpoint's own record, so a fresh invocation answers
352
385
  // exactly as the minter would have. The answerer's name rides `by`, never the presenter.
@@ -358,8 +391,12 @@ async function answer(values, reader, runId, stepKey, takeoverId) {
358
391
  ...(values.value !== undefined ? { value: parsedValue } : {}),
359
392
  ...(values.artifact !== undefined ? { artifact: values.artifact } : {}),
360
393
  now: Date.now(),
394
+ }).catch((e) => {
395
+ if (e instanceof RunAnswerBlocked)
396
+ throw new CommandFailure("refused", "LIFECYCLE_BLOCKED", e.message, { target: { space: writer.space, endpoint, run: runId }, native: { code: e.code } });
397
+ throw e;
361
398
  });
362
- console.log(JSON.stringify(result, null, 2));
399
+ runReceipt("answer", reader.space, result, values, runId, { step: stepKey, ...(values.value !== undefined ? { value: parsedValue } : {}), ...(values.artifact !== undefined ? { artifact: values.artifact } : {}) });
363
400
  }
364
401
  finally {
365
402
  await writer.close();
@@ -376,7 +413,12 @@ async function amend(values, reader, runId, stepKey, takeoverId) {
376
413
  }
377
414
  const endpoint = values.endpoint ?? "manager";
378
415
  const parsedValue = parseAnswerValue(values);
379
- const accepted = await locateAcceptedAnswer({ kv: reader.kv, js: reader.js, jsm: reader.jsm, space: reader.space, endpoint }, { runId, stepKey, takeoverId });
416
+ const accepted = await locateAcceptedAnswer({ kv: reader.kv, js: reader.js, jsm: reader.jsm, space: reader.space, endpoint }, { runId, stepKey, takeoverId }).catch((e) => {
417
+ // Locating writes nothing, so an answer it does not find is refused as the hosted amend refuses it.
418
+ if (e instanceof CheckpointNotAmendable)
419
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", e.message, { target: { space: reader.space, endpoint, run: runId }, remedy: `read the run's answers with cotal run show ${runId} --local` });
420
+ throw e;
421
+ });
380
422
  const writer = await openPlanes(values, "run-operator", { runOperator: { endpoint, takeoverId: newTakeoverId(), answers: { token: accepted.token } } });
381
423
  try {
382
424
  const result = await amendAcceptedAnswer({ kv: writer.kv, js: writer.js, jsm: writer.jsm, space: writer.space, endpoint }, {
@@ -386,7 +428,7 @@ async function amend(values, reader, runId, stepKey, takeoverId) {
386
428
  ...(values.artifact !== undefined ? { artifact: values.artifact } : {}),
387
429
  now: Date.now(),
388
430
  });
389
- console.log(JSON.stringify(result, null, 2));
431
+ runReceipt("amend", reader.space, result, values, runId, { step: stepKey, ...(values.value !== undefined ? { value: parsedValue } : {}), ...(values.artifact !== undefined ? { artifact: values.artifact } : {}) });
390
432
  }
391
433
  finally {
392
434
  await writer.close();
@@ -412,7 +454,7 @@ function parseAnswerValue(values) {
412
454
  async function migrate(values, planes, runId, takeoverId) {
413
455
  if (runId === undefined || values.file === undefined) {
414
456
  console.error(USAGE);
415
- console.error("run migrate: <runId> and --file <program> are required");
457
+ console.error("run check: <runId> and <program> are required");
416
458
  process.exit(1);
417
459
  }
418
460
  // Commit-side overrides, refused by name: each one records a person's decision on a report the
@@ -423,29 +465,40 @@ async function migrate(values, planes, runId, takeoverId) {
423
465
  ["discard-approvals", "--discard-approvals"],
424
466
  ]) {
425
467
  if (values[flag] !== undefined) {
426
- console.error(`run migrate: ${what} decides what a COMMIT does with an orphan, and this verb only checks; nothing was written`);
468
+ console.error(`run check: ${what} decides what a COMMIT does with an orphan, and this verb only checks; nothing was written`);
427
469
  process.exit(1);
428
470
  }
429
471
  }
430
472
  const endpoint = values.endpoint ?? "manager";
431
473
  const record = await readRunRecord(planes.kv, endpoint, runId);
432
- if (record === undefined) {
433
- console.error(`run ${runId}: no record on endpoint ${endpoint}; a run that was never started cannot be migrated`);
434
- process.exit(1);
435
- }
474
+ // A run ID nothing records is refused as `run show` refuses it; the check writes nothing either way.
475
+ if (record === undefined)
476
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", `run ${runId}: no record on endpoint ${endpoint}; a run that was never started cannot be checked`, { target: { space: planes.space, endpoint, run: runId }, remedy: "list the recorded runs with cotal run" });
436
477
  const replay = await replayRunJournal(planes.js, planes.jsm, planes.space, runId, takeoverId);
437
478
  const source = sourceOf(values.file);
438
- const report = await migrateRun({
439
- endpoint,
440
- runId,
441
- source,
442
- file: values.file,
443
- entries: replay.records.flatMap((r) => (r.record.kind === "step" ? [r.record.entry] : [])),
444
- pins: record.spec.value.pins,
445
- kv: planes.kv,
446
- actor: "cotal run migrate",
447
- now: () => Date.now(),
448
- });
479
+ let report;
480
+ try {
481
+ report = await migrateRun({
482
+ endpoint,
483
+ runId,
484
+ source,
485
+ file: values.file,
486
+ entries: replay.records.flatMap((r) => (r.record.kind === "step" ? [r.record.entry] : [])),
487
+ pins: record.spec.value.pins,
488
+ kv: planes.kv,
489
+ actor: "cotal run migrate",
490
+ now: () => Date.now(),
491
+ });
492
+ }
493
+ catch (e) {
494
+ if (!(e instanceof LangErrors))
495
+ throw e;
496
+ // Every problem with its position, cause and fix, as `run validate` lists them.
497
+ console.error(`run check: ${values.file} is not valid cotal-lang (${e.errors.length} problem${e.errors.length === 1 ? "" : "s"}), so it was not checked against the journal`);
498
+ for (const item of e.errors)
499
+ console.error(` ${item.code} ${item.title} (${item.span.file}:${item.span.line}:${item.span.column})\n ${item.cause}\n fix: ${item.fix}`);
500
+ process.exit(1);
501
+ }
449
502
  printMigrateReport(report);
450
503
  if (!report.admissible)
451
504
  process.exitCode = 1;
@@ -492,27 +545,67 @@ export function unansweredManagerRefusal(e) {
492
545
  const detail = `${e.code}: ${e.message}`;
493
546
  const rail = unansweredRail(e);
494
547
  if (rail === undefined || rail === "ep")
495
- return `no manager answered on the endpoint rails (${detail}); is a manager running for this mesh? A run can still be driven from this terminal with --local`;
496
- return `no manager answered on the ${rail} rail (${detail}). The ${rail} and legacy ep rails are disjoint at the broker (SPEC 13.15) and an endpoint must serve both, so this does not tell no manager running apart from one older than ${rail}, which serves ep only and cannot answer here. Check whether a manager is running, and if it is, restart it on a build that serves ${rail}`;
548
+ return `the manager registered for this space did not answer on the endpoint rails (${detail}); is a manager running for this mesh? A run can still be driven from this terminal with --local`;
549
+ return `the manager registered for this space did not answer on the ${rail} rail (${detail}). The ${rail} and legacy ep rails are disjoint at the broker (SPEC 13.15) and an endpoint must serve both, so this does not tell no manager running apart from one older than ${rail}, which serves ep only and cannot answer here. Check whether a manager is running, and if it is, restart it on a build that serves ${rail}`;
497
550
  }
498
551
  /** The manager's refusal codes that say it acted on nothing. */
499
552
  const REFUSED_BEFORE_EFFECT = {
500
553
  "permission-denied": "PERMISSION_DENIED", "not-found": "TARGET_NOT_FOUND", "failed-precondition": "LIFECYCLE_BLOCKED",
501
554
  "bad-request": "USAGE_INVALID", "unimplemented": "UNSUPPORTED", "conflict": "CONFLICT",
502
555
  };
556
+ /** The local journal read a refused hosted read names. */
557
+ const localJournal = (args) => `cotal run show ${typeof args?.runId === "string" ? args.runId : "<run-id>"} --local`;
503
558
  /** A manager refusal under the CLI's outcome table, with the manager's own sentence. Any other code
504
559
  * leaves a read failed and a start, resume or answer unconfirmed, because the manager may have
505
- * recorded it. */
506
- function managerRefusal(command, space, args, err) {
560
+ * recorded it. `on` is the caller's explicit manager pin, which a suggested command keeps. */
561
+ export function managerRefusal(command, space, args, err, on) {
507
562
  const message = renderLifecycleBlocked(err?.message ?? err?.code ?? "the manager refused", err);
508
563
  const options = { target: { space }, native: { code: err?.code ?? "unreported" } };
509
- const code = err?.code === undefined ? undefined : REFUSED_BEFORE_EFFECT[err.code];
510
- const remedy = err?.code !== "not-found" ? {} : { remedy: command === "run-answer" && typeof args?.runId === "string" ? `read the run's open requests with cotal run show ${args.runId}` : "list the recorded runs with cotal run ps" };
564
+ // A manager at its launch limit refuses `resource-exhausted` before writing anything, and says
565
+ // so with `not-executed`.
566
+ const busy = err?.code === "resource-exhausted" && err.outcome === "not-executed";
567
+ const code = err?.code === undefined ? undefined : busy ? "LIFECYCLE_BLOCKED" : REFUSED_BEFORE_EFFECT[err.code];
568
+ // A resume refused as a precondition, such as of a completed or failed run, appended nothing, so
569
+ // the run's recorded state is what to read next.
570
+ const resumeBlocked = command === "run-resume" && err?.code === "failed-precondition" && typeof args?.runId === "string";
571
+ const remedy = busy ? { remedy: `wait until one of them has activated, then run ${command === "run-resume" ? "the resume" : "the start"} again; cotal run lists a run once it has` }
572
+ : resumeBlocked ? { remedy: `read the run's recorded state with cotal run show ${args.runId}${on ? ` --on ${on}` : ""}` }
573
+ : err?.code !== "not-found" ? {} : { remedy: command === "run-answer" && typeof args?.runId === "string" ? `read the run's open requests with cotal run show ${args.runId}` : "list the recorded runs with cotal run" };
511
574
  if (code)
512
575
  return new CommandFailure("refused", err.details?.some((d) => d.kind === LANG_PROBLEM_DETAIL_KIND) ? "SCHEMA_INVALID" : code, message, { ...options, ...remedy });
576
+ // A reply too large for the broker changed nothing; the local read takes no reply.
577
+ if (command === "run-status" && err?.code === "resource-exhausted")
578
+ return new CommandFailure("refused", "UNSUPPORTED", message, { ...options, remedy: `read the run from its project folder with ${localJournal(args)}` });
513
579
  if (command === "run-status" || command === "run-ps")
514
580
  return new CommandFailure("failed", "OPERATION_FAILED", message, options);
515
- return new CommandFailure("unknown", "OPERATION_UNKNOWN", message, { ...options, remedy: "read the run with cotal run show or cotal run ps before trying again; it was not retried" });
581
+ return new CommandFailure("unknown", "OPERATION_UNKNOWN", message, { ...options, remedy: "read the run with cotal run show, or list the runs with cotal run, before trying again; it was not retried" });
582
+ }
583
+ /** nats.js refuses a frame over the broker's `max_payload` before writing any of it. */
584
+ function payloadRefused(error) {
585
+ for (let e = error; e instanceof Error; e = e.cause)
586
+ if (e.name === "InvalidArgumentError" && e.message.includes("max_payload"))
587
+ return true;
588
+ return false;
589
+ }
590
+ /** While a hosted start's request may be with the manager, a SIGINT or SIGTERM reports that the run
591
+ * may already be recorded and exits as the signal would. The manager does not stop on the caller's
592
+ * signal, so the process used to die silently with the run started, and a retry started a second
593
+ * one. Returns the release. */
594
+ function reportInterruptedStart(values, space) {
595
+ const exit = (signal) => {
596
+ const failure = new CommandFailure("unknown", "OPERATION_UNKNOWN", "interrupted after the start request was sent, so the manager may already have recorded the run", {
597
+ target: { space }, remedy: "list the recorded runs with cotal run before starting it again",
598
+ });
599
+ if (values.json)
600
+ writeEnvelope(failureEnvelope("run", failure));
601
+ process.stderr.write(`${renderReport(commandFailureReport(failure), values.plain ? "plain" : "rich", terminalWidth(process.stderr))}\n`);
602
+ process.exit(signal === "SIGINT" ? 130 : 143);
603
+ };
604
+ const onInterrupt = () => exit("SIGINT");
605
+ const onTerminate = () => exit("SIGTERM");
606
+ process.once("SIGINT", onInterrupt);
607
+ process.once("SIGTERM", onTerminate);
608
+ return () => { process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate); };
516
609
  }
517
610
  /** One command to the mesh's manager over the endpoint rails: a fresh resolve (describe, store
518
611
  * fetch, digest-verified recompile), then the invoke, re-issued after a bind split
@@ -522,18 +615,21 @@ async function askHost(values, command, args, resolved) {
522
615
  const { target: t, seat } = await resolveRunControlTarget(values);
523
616
  resolved?.(t.space);
524
617
  const who = controlCaller(t.auth);
525
- if ("refusal" in who) {
526
- console.error(who.refusal);
527
- process.exit(1);
528
- }
618
+ // A raw --creds file carries no endpoint-caller triple, and this route cannot mint one. Without
619
+ // --creds the CLI mints the one-shot caller from the space's trust or the signed-in login.
620
+ if ("refusal" in who)
621
+ throw new CommandFailure("refused", "UNSUPPORTED", "a hosted run command cannot use a raw --creds file: it carries no endpoint-caller triple (owner, actor, lifecycle uid), and this route cannot mint one; nothing was sent", {
622
+ target: { space: t.space },
623
+ remedy: "run it again without --creds: in the mesh's project folder, or with --space naming its registry entry, the CLI mints the one-shot caller itself",
624
+ });
529
625
  const auth = t.auth;
530
626
  const connectOpts = standaloneConnectOpts(auth.creds ? { creds: auth.creds, tls: auth.tls === true } : auth.bearer ? { bearer: auth.bearer, sentinelCreds: auth.sentinelCreds, tls: auth.tls === true } : { tls: auth.tls === true });
531
627
  const nc = await dialerFor(t.server)({ servers: t.server, ...connectOpts, maxReconnectAttempts: 0 });
532
628
  try {
533
629
  // Only the operator's own unpinned instrument holds the registry rows `ps` scatters on; a seat's
534
630
  // credential and a user's manager view hold none.
535
- if (!seat && auth.bearer === undefined && auth.managerInstanceId === undefined)
536
- await refuseWithoutLiveManager(nc, t.space);
631
+ if (!seat && auth.bearer === undefined && (auth.managerInstanceId === undefined || values.on !== undefined))
632
+ await refuseWithoutLiveManager(nc, t.space, values.on);
537
633
  // A logged-in user's manager view is issued at the callout (SPEC 13.15): its generation is read
538
634
  // back from this connection's accepted row, and every request rides `ep.v1` under it.
539
635
  const caller = auth.bearer !== undefined && auth.managerInstanceId !== undefined
@@ -546,10 +642,26 @@ async function askHost(values, command, args, resolved) {
546
642
  // A start or resume is answered only once the drive has activated, which the manager waits on
547
643
  // for a bounded time; the deadline here outlives that wait, so the manager's own "still
548
644
  // launching" refusal is what a slow activation reads as, never a manager that did not answer.
549
- const r = await invokeRepairingSplit(nc, t.space, service, command, args, {
550
- deadlineMs: RUN_LAUNCH_DEADLINE_MS,
551
- ...(command === "run-answer" ? { target: { mode: "self" } } : {}),
552
- });
645
+ const interrupt = command === "run-start" ? reportInterruptedStart(values, t.space) : undefined;
646
+ let r;
647
+ try {
648
+ r = await invokeRepairingSplit(nc, t.space, service, command, args, {
649
+ deadlineMs: RUN_LAUNCH_DEADLINE_MS,
650
+ ...(command === "run-answer" ? { target: { mode: "self" } } : {}),
651
+ });
652
+ }
653
+ catch (e) {
654
+ if (!payloadRefused(e))
655
+ throw e;
656
+ const source = command === "run-start" && typeof args?.source === "string" ? args.source : undefined;
657
+ const limit = `this broker's ${nc.info?.max_payload ?? "unreported"}-byte message limit`;
658
+ throw new CommandFailure("refused", "UNSUPPORTED", source !== undefined
659
+ ? `the program is ${Buffer.byteLength(source)} bytes, and the start request that carries it exceeds ${limit}; nothing was sent`
660
+ : `the ${command} request exceeds ${limit}; nothing was sent`, { target: { space: t.space }, ...(source !== undefined ? { remedy: "shorten the program; a hosted start sends it to the manager in one message" } : {}) });
661
+ }
662
+ finally {
663
+ interrupt?.();
664
+ }
553
665
  if (r.reply.ok !== true) {
554
666
  const err = r.reply.error;
555
667
  // A validation refusal carries every problem as the language's own records; print them the
@@ -557,13 +669,25 @@ async function askHost(values, command, args, resolved) {
557
669
  for (const d of err?.details ?? [])
558
670
  if (d.kind === LANG_PROBLEM_DETAIL_KIND)
559
671
  console.error(renderLangProblem(d));
560
- throw managerRefusal(command, t.space, args, err);
672
+ throw managerRefusal(command, t.space, args, err, values.on);
561
673
  }
562
674
  return r.reply.data;
563
675
  }
564
676
  catch (e) {
677
+ // A space whose streams were removed has no registry or contract store to read, so name that
678
+ // instead of the read that failed. `cotal status` says whether its stack still runs.
679
+ if (!(e instanceof CommandFailure) && spaceStreamsExpected(t.space, t.mode) && await chatStreamGone(nc, t.space))
680
+ throw spaceStreamsAbsent(t.space, localStackRoot(t.space));
681
+ // The contract of a manager from release 0.99.3 or earlier takes no journal page, so the request
682
+ // is refused here, against that contract, before it is sent.
683
+ if (e instanceof EpEnvelopeError && command === "run-status" && e.code === "bad-request" && /additional properties: "journal"/.test(e.message))
684
+ throw new CommandFailure("refused", "UNSUPPORTED", "this manager is from release 0.99.3 or earlier and reads no journal page, so this command cannot read the run through it", { target: { space: t.space }, remedy: `upgrade that manager, or read the run from its project folder with ${localJournal(args)}` });
685
+ // No answer is an unavailable target, not a usage error. Only this machine's records can name a
686
+ // manager that runs and does not answer, and `cotal status` reads them.
687
+ if (e instanceof EpEnvelopeError && unansweredRequest(e))
688
+ throw new CommandFailure("refused", "TARGET_UNAVAILABLE", unansweredManagerRefusal(e), { target: { space: t.space }, native: { code: e.code }, remedy: "cotal status shows whether this space's manager answers; a stack manager that runs but does not answer is named there with its pid and the way to end it" });
565
689
  if (e instanceof EpEnvelopeError) {
566
- console.error(unansweredRequest(e) ? unansweredManagerRefusal(e) : `${e.code}: ${e.message}`);
690
+ console.error(`${e.code}: ${e.message}`);
567
691
  process.exit(1);
568
692
  }
569
693
  throw e;
@@ -572,13 +696,25 @@ async function askHost(values, command, args, resolved) {
572
696
  await nc.drain().catch(() => nc.close());
573
697
  }
574
698
  }
699
+ /** Whether the chat stream of `space` is gone from the broker `nc` is connected to. Any other
700
+ * failure to read it answers no. */
701
+ async function chatStreamGone(nc, space) {
702
+ try {
703
+ await (await jetstreamManager(nc, { checkAPI: false })).streams.info(chatStream(space));
704
+ return false;
705
+ }
706
+ catch (e) {
707
+ return isStreamNotFound(e);
708
+ }
709
+ }
575
710
  /** A hosted verb on a space whose registry lists no live manager is refused here, as `ps` refuses
576
711
  * it, rather than after the describe has waited out its whole deadline for an answer no manager
577
- * can send. Any other registry outcome, an unreadable registry included, leaves the describe to
578
- * decide. */
579
- async function refuseWithoutLiveManager(nc, space) {
712
+ * can send. So is an `on` pin that names none of the live managers, with the ones that run. Any
713
+ * other registry outcome, an unreadable registry included, leaves the describe to decide. */
714
+ async function refuseWithoutLiveManager(nc, space, on) {
715
+ let live;
580
716
  try {
581
- await freezeExpectedSet(await jetstreamManager(nc, { checkAPI: false }), space, BASELINE_LIFECYCLE_ENDPOINT);
717
+ live = await freezeExpectedSet(await jetstreamManager(nc, { checkAPI: false }), space, BASELINE_LIFECYCLE_ENDPOINT);
582
718
  }
583
719
  catch (e) {
584
720
  if (!(e instanceof EpEnvelopeError) || !noLiveInstances(e))
@@ -589,6 +725,15 @@ async function refuseWithoutLiveManager(nc, space) {
589
725
  remedy: "start the stack's manager with bare cotal up; a stack started with --no-manager has none",
590
726
  });
591
727
  }
728
+ // A pin that names none of the running managers can only wait out its describe, so it is refused
729
+ // here, with the managers that do run.
730
+ if (on === undefined || live.some((instance) => instance.instanceId === on))
731
+ return;
732
+ const ids = live.map((instance) => instance.instanceId);
733
+ throw new CommandFailure("refused", "TARGET_NOT_FOUND", `--on ${on} names no manager running for space ${space}; ${ids.length === 1 ? `the one running is ${ids[0]}` : `the ones running are ${ids.join(", ")}`}. Nothing was sent`, {
734
+ target: { space, requestedManagerInstance: on },
735
+ remedy: ids.length === 1 ? `run it again with --on ${ids[0]}, or without --on` : "run it again with --on naming one of them",
736
+ });
592
737
  }
593
738
  /** A spawned static seat that shells out to the rendered `cotal run answer …` command must answer as
594
739
  * THAT seat ({@link resolveSeatControlTarget}). An open mesh keeps the operator caller here, which
@@ -599,7 +744,7 @@ async function refuseWithoutLiveManager(nc, space) {
599
744
  async function resolveRunControlTarget(values) {
600
745
  const on = values.on;
601
746
  const seat = values.creds === undefined ? await resolveSeatControlTarget(values, on) : undefined;
602
- const target = seat?.mode === "auth" ? seat : await resolveControlTarget(values, "control-caller-privileged", on);
747
+ const target = seat?.mode === "auth" ? seat : await resolveControlTarget(values, "control-caller-privileged", on, { onRefusal: "throw" });
603
748
  recordOperationSpace(target.space);
604
749
  const resolved = { target, seat: target === seat };
605
750
  if (on === undefined)
@@ -608,10 +753,13 @@ async function resolveRunControlTarget(values) {
608
753
  throw new CommandFailure("refused", "CONFLICT", "the requested manager instance differs from this credential's instance authority; no other manager was selected");
609
754
  return { ...resolved, target: { ...target, auth: { ...target.auth, managerInstanceId: on } } };
610
755
  }
611
- function renderLangProblem(d) {
756
+ /** A validation problem the manager returned, as `run validate` prints one. Each value can quote the
757
+ * program, such as a step name, so its control characters are escaped before the terminal sees them. */
758
+ export function renderLangProblem(d) {
612
759
  const where = d.where;
613
760
  const at = where ? `${where.file ?? "<program>"}:${where.line ?? "?"}:${where.column ?? "?"}` : "<program>";
614
- return ` ${String(d.code ?? "L????")} ${String(d.title ?? "")} (${at})\n ${String(d.cause ?? "")}\n fix: ${String(d.fix ?? "")}`;
761
+ const text = (value) => oneLine(String(value ?? ""));
762
+ return ` ${text(d.code ?? "L????")} ${text(d.title)} (${text(at)})\n ${text(d.cause)}\n fix: ${text(d.fix)}`;
615
763
  }
616
764
  async function hosted(values, verb, a, b) {
617
765
  let space;
@@ -630,7 +778,7 @@ async function hosted(values, verb, a, b) {
630
778
  // The manager serves no `run-migrate` command, so there is nothing to route to: the check reads
631
779
  // the run's record, journal and program itself, which is what `--local` does.
632
780
  if (verb === "migrate") {
633
- console.error("run migrate: the manager serves no run-migrate command; the check reads the run from this terminal. Run `cotal run migrate <runId> --local --file <program>`");
781
+ console.error("run check: the manager serves no run-migrate command; the check reads the run from this terminal. Run `cotal run check <runId> <program>`");
634
782
  process.exit(1);
635
783
  }
636
784
  if (verb === "start") {
@@ -672,7 +820,8 @@ async function hosted(values, verb, a, b) {
672
820
  return;
673
821
  }
674
822
  const rows = await ask("run-ps", Object.keys(endpoint).length ? endpoint : undefined);
675
- printRunList(space, rows, values);
823
+ const unmatched = values.endpoint !== undefined && !rows.length ? { endpoint: values.endpoint, recordedUnder: recordedUnder(await ask("run-ps", undefined)) } : undefined;
824
+ printRunList(space, rows, values, unmatched);
676
825
  return;
677
826
  }
678
827
  if (verb === "journal") {
@@ -680,10 +829,22 @@ async function hosted(values, verb, a, b) {
680
829
  console.error(USAGE);
681
830
  process.exit(1);
682
831
  }
683
- const view = await ask("run-status", { runId: a, ...endpoint });
832
+ // The journal is read in pages, each one reply, so one larger than the broker's payload limit
833
+ // stays readable.
834
+ const rows = [];
835
+ let view;
836
+ do {
837
+ view = await ask("run-status", { runId: a, ...endpoint, journal: { offset: rows.length } });
838
+ if (view.journalTotal === undefined)
839
+ throw new CommandFailure("failed", "OPERATION_FAILED", "the manager answered the journal read without a page; the journal was not read", { target: { space: space ?? null, run: a } });
840
+ if (view.journal.length === 0 && rows.length < view.journalTotal)
841
+ throw new CommandFailure("failed", "OPERATION_FAILED", `the manager returned no journal rows at row ${rows.length} of ${view.journalTotal}; the journal was not read completely`, { target: { space: space ?? null, run: a } });
842
+ rows.push(...view.journal);
843
+ } while (rows.length < view.journalTotal);
684
844
  if (space === undefined)
685
845
  throw new Error("the native run target was not reported");
686
- printRunDetail(space, view, values);
846
+ const { journalTotal: _total, ...record } = view;
847
+ printRunDetail(space, { ...record, journal: rows }, values);
687
848
  return;
688
849
  }
689
850
  // answer and amend: the manager records the caller as the answerer (SPEC 14.5), so no `--by`
@@ -701,12 +862,48 @@ async function hosted(values, verb, a, b) {
701
862
  ...(values.value !== undefined ? { value } : {}),
702
863
  ...(values.artifact !== undefined ? { artifact: values.artifact } : {}),
703
864
  ...(verb === "amend" ? { amend: true } : {}),
865
+ }).catch(async (error) => {
866
+ throw verb === "answer" ? await withMatchingKeys(error, a, b, values, () => ask("run-status", { runId: a, ...endpoint })) : error;
704
867
  });
705
- runReceipt(verb, space, result, values, a);
868
+ runReceipt(verb, space, result, values, a, { step: b, ...(values.value !== undefined ? { value } : {}), ...(values.artifact !== undefined ? { artifact: values.artifact } : {}) });
869
+ }
870
+ /** A step key the run has no open request at, answered with the open keys whose step name it matches,
871
+ * as `frage` matches `/checkpoint:frage#0`. A run that cannot be read leaves the refusal as it was. */
872
+ export async function withMatchingKeys(error, runId, stepKey, values, read) {
873
+ if (!(error instanceof CommandFailure) || error.code !== "TARGET_NOT_FOUND")
874
+ return error;
875
+ let open;
876
+ try {
877
+ open = openRequestKeys(await read());
878
+ }
879
+ catch {
880
+ return error;
881
+ }
882
+ const stepName = (key) => /:([^:/#]*)#\d+$/.exec(key)?.[1] ?? key;
883
+ const word = stepName(stepKey);
884
+ const matches = open.filter((key) => key !== stepKey && (stepName(key) === word || (word !== "" && (stepName(key).includes(word) || word.includes(stepName(key))))));
885
+ if (!matches.length)
886
+ return error;
887
+ // A step name comes from the program, so it is escaped and quoted before the terminal shows it.
888
+ const quoted = (key) => `'${oneLine(key).replaceAll("'", `'\\''`)}'`;
889
+ const remedy = matches.length === 1
890
+ ? `the open request ${quoted(matches[0])} matches; answer it with \`cotal run answer ${runId} ${quoted(matches[0])} --value '<json>'${values.on ? ` --on ${values.on}` : ""}\``
891
+ : `the open requests that match are ${matches.map(quoted).join(", ")}; pass one of them as the step`;
892
+ return new CommandFailure(error.outcome, error.code, error.message, { remedy, target: error.target, ...(error.native ? { native: error.native } : {}) });
706
893
  }
707
894
  /** `cotal run <start|resume|ps|journal|answer|amend>` — dispatch. The manager hosts by default;
708
895
  * `--local` drives in this process over one connection per invocation. */
709
896
  export async function runWorkflow(args) {
897
+ // A mesh the verb cannot resolve or connect to is refused under the refusal's own code, before
898
+ // any request.
899
+ try {
900
+ await workflow(args);
901
+ }
902
+ catch (e) {
903
+ throw isConnectRefusal(e) ? e.failure() : e;
904
+ }
905
+ }
906
+ async function workflow(args) {
710
907
  const { values, verb, a, b } = workflowArguments(args);
711
908
  if (verb === "validate" || verb === "plan" || verb === "simulate")
712
909
  return offlineWorkflow(verb, a, values);
@@ -753,15 +950,58 @@ export async function runWorkflow(args) {
753
950
  await planes.close();
754
951
  }
755
952
  }
953
+ /** How deep an answer value may nest. Serializing it recurses, and a host runs out of stack a few
954
+ * thousand levels down, at a depth that depends on the host, so the bound sits well below that. */
955
+ const ANSWER_VALUE_DEPTH = 1000;
956
+ /** `--value` as the answer record carries it, refused before anything is sent when the record
957
+ * would differ from what was typed: JSON keeps only the last of two equal names, writes a number
958
+ * past the double range as null, and cannot send a value nested past the serializer's stack. */
959
+ function checkAnswerValue(text, usage) {
960
+ let value;
961
+ try {
962
+ value = JSON.parse(text);
963
+ }
964
+ catch {
965
+ usage("--value must be valid JSON");
966
+ }
967
+ const refuse = (message) => { throw new CommandFailure("refused", "SCHEMA_INVALID", `${message}; nothing was sent`); };
968
+ const duplicate = hasDuplicateNames(text);
969
+ if (duplicate.duplicate)
970
+ refuse(`--value names "${duplicate.name}" twice in one object, and JSON keeps only the last`);
971
+ // Iterative, because the value can nest deeper than the stack a recursive walk has.
972
+ const pending = [{ v: value, depth: 1 }];
973
+ while (pending.length > 0) {
974
+ const { v, depth } = pending.pop();
975
+ if (typeof v === "number" && !Number.isFinite(v))
976
+ refuse("--value holds a number outside the range JSON carries, which would be recorded as null");
977
+ if (v === null || typeof v !== "object")
978
+ continue;
979
+ if (depth > ANSWER_VALUE_DEPTH)
980
+ refuse(`--value nests more than ${ANSWER_VALUE_DEPTH} levels deep, deeper than an answer can be sent`);
981
+ for (const child of Object.values(v))
982
+ pending.push({ v: child, depth: depth + 1 });
983
+ }
984
+ }
756
985
  /** Public workflow grammar, before catalog preparation or native requests. */
757
986
  export function workflowArguments(args) {
758
987
  const values = { ...args.values };
759
988
  presentationOf(values);
760
989
  const [raw = "list", first, second, ...extra] = args.positionals;
761
990
  const verb = { list: "ps", show: "journal", check: "migrate" }[raw] ?? raw;
762
- const usage = (message) => { throw new CommandFailure("refused", "USAGE_INVALID", message); };
991
+ // A refusal that names its own fix prints that fix and no help; any other prints the help.
992
+ const usage = (message, remedy) => { throw new CommandFailure("refused", "USAGE_INVALID", message, remedy === undefined ? {} : { remedy, selfContained: true }); };
763
993
  if (extra.length || !["start", "resume", "ps", "journal", "answer", "amend", "revoke", "migrate", "validate", "plan", "simulate", "turns", "yield"].includes(verb))
764
994
  usage("choose a declared run command; run it bare to list workflows");
995
+ // A run id is an id token, so a malformed one is refused here, before any contact, rather than
996
+ // by whichever plane reads it first.
997
+ if (["journal", "resume", "revoke", "answer", "amend", "migrate"].includes(verb) && first !== undefined) {
998
+ try {
999
+ assertIdToken(first, "run id");
1000
+ }
1001
+ catch {
1002
+ usage(`run ${raw}: ${JSON.stringify(boundedValue(first, SHOWN_VALUE_MAX))} is not a run id, which is 1 to 64 letters, digits, "_" or "-"; cotal run lists them`);
1003
+ }
1004
+ }
765
1005
  let a = first;
766
1006
  let b = second;
767
1007
  if (verb === "start") {
@@ -796,7 +1036,7 @@ export function workflowArguments(args) {
796
1036
  usage(`run ${verb} requires a run id and step key`);
797
1037
  }
798
1038
  else if (!first || second !== undefined)
799
- usage(`run ${raw} takes one identifier`);
1039
+ usage(`run ${raw} takes one identifier`, verb === "yield" ? "cotal run turns lists your turns; run cotal run yield <turn-id>" : `cotal run lists the runs; run cotal run ${raw} <run-id>`);
800
1040
  const scopes = {
801
1041
  timeout: ["start", "resume"], by: ["answer", "amend", "revoke"], reason: ["revoke"], value: ["answer", "amend"], artifact: ["answer", "amend"],
802
1042
  "admit-read": ["start"], "admit-publish": ["start"], adopt: ["migrate"], release: ["migrate"], "discard-approvals": ["migrate"],
@@ -804,7 +1044,16 @@ export function workflowArguments(args) {
804
1044
  for (const [flag, modes] of Object.entries(scopes))
805
1045
  if (args.values[flag] !== undefined && !modes.includes(verb))
806
1046
  usage(`--${flag} is not taken by this run command`);
807
- if (values.page !== undefined || values.limit !== undefined || values.cursor !== undefined) {
1047
+ // The limit of every checkpoint, ask and turn that sets none of its own, so a zero expires each at once.
1048
+ if (values.timeout !== undefined && !(isDuration(values.timeout) && parseDuration(values.timeout) > 0))
1049
+ usage(isDuration(values.timeout) ? "--timeout must be longer than zero, or every checkpoint, ask and turn that sets no limit of its own expires at once" : `--timeout ${new DurationError(values.timeout).message}`);
1050
+ if (verb === "journal" && values.limit !== undefined) {
1051
+ if (!/^[1-9][0-9]*$/.test(values.limit) || Number(values.limit) > 1000)
1052
+ usage(`run ${raw}: --limit must be between 1 and 1000`);
1053
+ if (values.json)
1054
+ usage(`run ${raw}: --limit bounds the text view, and --json returns the whole journal`);
1055
+ }
1056
+ else if (values.page !== undefined || values.limit !== undefined || values.cursor !== undefined) {
808
1057
  if (verb !== "ps" || values.local === true || values.page !== true || values.on === undefined)
809
1058
  usage("diagnostic paging requires run --page --on <instance> without --local");
810
1059
  if (values.limit !== undefined && (!/^[1-9][0-9]*$/.test(values.limit) || Number(values.limit) > 16))
@@ -816,20 +1065,41 @@ export function workflowArguments(args) {
816
1065
  }
817
1066
  if ((values["admit-read"] !== undefined || values["admit-publish"] !== undefined) && values.local !== true)
818
1067
  usage("admission ceilings belong to the explicit local start");
1068
+ // A ceiling entry is a channel or a channel pattern. One the wire layer would rewrite, such as
1069
+ // `a b`, would admit a channel nobody named.
1070
+ for (const flag of ["admit-read", "admit-publish"]) {
1071
+ const entries = (values[flag] ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1072
+ if (entries.length === 1 && entries[0] === "none")
1073
+ continue;
1074
+ for (const entry of entries) {
1075
+ try {
1076
+ assertValidChannel(entry);
1077
+ }
1078
+ catch (error) {
1079
+ usage(`--${flag}: ${oneLine(error.message)}`);
1080
+ }
1081
+ }
1082
+ }
1083
+ // Revoke is local only and needs --by, so its own requirement is named before the hosted --by refusal.
1084
+ // An empty --by or --reason on an otherwise whole revoke names that flag and prints no help.
1085
+ if (verb === "revoke" && values.local === true) {
1086
+ for (const [flag, what] of [["by", "who revokes the run"], ["reason", "why it is revoked"]]) {
1087
+ if (values[flag] !== undefined && !values[flag].trim())
1088
+ throw new CommandFailure("refused", "USAGE_INVALID", `--${flag} is empty; nothing was written`, { remedy: `name ${what}: cotal run revoke ${a} --local --by <who> --reason <text>`, selfContained: true });
1089
+ }
1090
+ }
1091
+ if (verb === "revoke" && (values.local !== true || !values.by?.trim() || !values.reason?.trim()))
1092
+ usage("run revoke requires --local --by <actor> --reason <text> under the existing trust checks");
1093
+ if ((verb === "answer" || verb === "amend") && values.local === true && !values.by)
1094
+ usage(`run ${verb} --local requires --by <who>; nothing was written`, "add --by <who> to name the local answerer");
819
1095
  if (values.by !== undefined && values.local !== true)
820
1096
  usage("the hosted manager records the authenticated caller; --by is only for the local path");
821
1097
  if ((verb === "start" || verb === "resume") && values.endpoint !== undefined && values.local !== true)
822
1098
  usage("a hosted run uses the manager's endpoint; --endpoint is only for the local path");
823
- if (verb === "revoke" && (values.local !== true || !values.by?.trim() || !values.reason?.trim()))
824
- usage("run revoke requires --local --by <actor> --reason <text> under the existing trust checks");
825
- if (values.value !== undefined) {
826
- try {
827
- JSON.parse(values.value);
828
- }
829
- catch {
830
- usage("--value must be valid JSON");
831
- }
832
- }
1099
+ if (values.value !== undefined)
1100
+ checkAnswerValue(values.value, usage);
1101
+ if (values.artifact !== undefined && !values.artifact.trim())
1102
+ usage("--artifact names the artifact the answer cites, so it cannot be empty");
833
1103
  if (values.handler !== undefined && verb !== "simulate")
834
1104
  usage("--handler belongs to run simulate");
835
1105
  if ((values.status !== undefined || values.to !== undefined || values.note !== undefined) && verb !== "yield")
@@ -856,8 +1126,14 @@ export function workflowArguments(args) {
856
1126
  usage("--on requires the full manager instance id as cotal ps prints it");
857
1127
  }
858
1128
  }
859
- if ((values.json || values.plain) && values.local && !["ps", "journal"].includes(verb))
860
- usage("the local drive does not implement structured or plain receipts; use the hosted form");
1129
+ // A local drive, a revoke and a check print text only. Name the manager's form where one takes the
1130
+ // flag, and the same command without it where none does.
1131
+ if ((values.json || values.plain) && values.local && !["ps", "journal", "answer", "amend"].includes(verb)) {
1132
+ const flag = values.json ? "--json" : "--plain";
1133
+ if (verb === "start" || verb === "resume")
1134
+ usage(`run ${raw} --local prints no ${flag} output`, `drop --local to ${raw} it on the manager, which takes ${flag}: cotal run ${verb === "start" ? "start <file>" : `resume ${a}`} ${flag}`);
1135
+ usage(`run ${raw} prints no ${flag} output`, `run it without ${flag}: ${verb === "revoke" ? `cotal run revoke ${a} --local --by <who> --reason <text>` : `cotal run check ${a} <program>`}`);
1136
+ }
861
1137
  return { values, verb, ...(a === undefined ? {} : { a }), ...(b === undefined ? {} : { b }) };
862
1138
  }
863
1139
  //# sourceMappingURL=run-command.js.map