@cotal-ai/runtime 0.100.2 → 0.101.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine-host.d.ts +4 -0
- package/dist/engine-host.d.ts.map +1 -1
- package/dist/engine-host.js +1 -1
- package/dist/engine-host.js.map +1 -1
- package/dist/index.js +19 -19
- package/dist/index.js.map +1 -1
- package/dist/log-line.d.ts +8 -0
- package/dist/log-line.d.ts.map +1 -0
- package/dist/log-line.js +10 -0
- package/dist/log-line.js.map +1 -0
- package/dist/mesh-handler.d.ts +9 -4
- package/dist/mesh-handler.d.ts.map +1 -1
- package/dist/mesh-handler.js +38 -12
- package/dist/mesh-handler.js.map +1 -1
- package/dist/offline-command.d.ts +6 -1
- package/dist/offline-command.d.ts.map +1 -1
- package/dist/offline-command.js +117 -15
- package/dist/offline-command.js.map +1 -1
- package/dist/resolve-checkpoint.d.ts +17 -8
- package/dist/resolve-checkpoint.d.ts.map +1 -1
- package/dist/resolve-checkpoint.js +51 -11
- package/dist/resolve-checkpoint.js.map +1 -1
- package/dist/run-command.d.ts +13 -1
- package/dist/run-command.d.ts.map +1 -1
- package/dist/run-command.js +354 -78
- package/dist/run-command.js.map +1 -1
- package/dist/run-driver.d.ts +5 -0
- package/dist/run-driver.d.ts.map +1 -1
- package/dist/run-driver.js +8 -5
- package/dist/run-driver.js.map +1 -1
- package/dist/run-host.d.ts +3 -2
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-host.js +55 -11
- package/dist/run-host.js.map +1 -1
- package/dist/workflow-views.d.ts +18 -2
- package/dist/workflow-views.d.ts.map +1 -1
- package/dist/workflow-views.js +89 -19
- package/dist/workflow-views.js.map +1 -1
- package/package.json +4 -4
package/dist/run-command.js
CHANGED
|
@@ -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 {
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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 `
|
|
496
|
-
return `
|
|
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
|
-
|
|
510
|
-
|
|
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
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
824
|
-
|
|
825
|
-
if (values.
|
|
826
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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
|