@dna113p/machines 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,10 +176,10 @@ npm ci
176
176
  npm pack
177
177
  ```
178
178
 
179
- Then install the generated `dna113p-machines-0.3.4.tgz` into a project:
179
+ Then install the generated `dna113p-machines-0.3.5.tgz` into a project:
180
180
 
181
181
  ```bash
182
- npm install /absolute/path/to/machines/dna113p-machines-0.3.4.tgz
182
+ npm install /absolute/path/to/machines/dna113p-machines-0.3.5.tgz
183
183
  npx machine list
184
184
  ```
185
185
 
@@ -205,6 +205,7 @@ machine runs --for [<session>] [--project <dir>] [--json] [--all] # the runs o
205
205
  machine respond <run-id> <request-id> <response> # answer a waiting run
206
206
  machine watch [<run-id-or-label-start> | --dir <path>] # show other runs on this session's status line
207
207
  machine unwatch <run-id-or-label-start> | --dir <path> | --all
208
+ machine ui [--port <port>] [--project <dir>] # a live page of every run, with its Machine drawn as a graph
208
209
  ```
209
210
 
210
211
  `machine runs` shows each waiting run's prompt, request id, and choices. A run
@@ -220,6 +221,10 @@ such as a ticket a daemon runs. See
220
221
  runs one session shows, so any application can
221
222
  [draw its own widgets](docs/integrations.md#drawing-your-own-widgets); `watch`
222
223
  and `unwatch` take `--for <session>` first to manage that session's list.
224
+ `machine ui` serves a local page of every published run, drawing each run's
225
+ Machine with its current state and the path it took, answering waiting runs, and
226
+ showing a project's own panels; see
227
+ [watching runs in a browser](docs/integrations.md#watching-runs-in-a-browser).
223
228
 
224
229
  ## Execution model and limitations
225
230
 
@@ -16,6 +16,7 @@ import { pathToFileURL } from "node:url";
16
16
  const terminalVisibleMilliseconds = 30_000;
17
17
  const maximumRuns = 5;
18
18
  const defaultColumns = 120;
19
+ // oxlint-disable-next-line no-control-regex -- the escape character is the point: this strips terminal styling.
19
20
  const ansiSequence = /\x1b\[[0-9;]*m/uy;
20
21
  // Combining marks, format characters such as joiners, and Hangul medial and
21
22
  // final jamo are drawn on the character before them.
@@ -196,6 +197,7 @@ function formatElapsed(startedAt, endedAt) {
196
197
 
197
198
  /** Recorded text must not be able to move the cursor or restyle the status line. */
198
199
  function plain(text) {
200
+ // oxlint-disable-next-line no-control-regex -- control characters are what a status line must not carry.
199
201
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
200
202
  }
201
203
 
@@ -10,7 +10,7 @@ import { isJsonValue } from "../src/json.js";
10
10
  import { MachineSession as RunSession } from "../src/session.js";
11
11
  import { listAgentPresets, listMachines } from "../src/launcher.js";
12
12
  import { watchRunInbox } from "../src/run-inbox.js";
13
- import { createRunStatusPublisher } from "../src/run-status.js";
13
+ import { createRunStatusPublisher, runOutputSummary } from "../src/run-status.js";
14
14
  import { machineCoordinatorGuidelines, machineDiscoveryDescription, machineSupervisionGuidelines } from "../src/coordinator-guidance.js";
15
15
  const widgetUri = "ui://machines/run-v1.html";
16
16
  const cwd = v.pipe(v.string(), v.nonEmpty("cwd must be a non-empty absolute path"), v.check(isAbsolute, "cwd must be an absolute path"));
@@ -219,7 +219,7 @@ export class MachineSession {
219
219
  });
220
220
  return;
221
221
  }
222
- publisher.finish(run.status === "completed" ? { state: run.state } : { error: run.error ?? "Machine run failed" });
222
+ publisher.finish(run.status === "completed" ? { state: run.state, note: runOutputSummary(run.output) } : { error: run.error ?? "Machine run failed" });
223
223
  this.#published.delete(run.id);
224
224
  }
225
225
  /** A published request can also be answered, or asked about, through the run's inbox by `machine respond`. */
package/dist/src/cli.js CHANGED
@@ -10,15 +10,17 @@ import { assertJsonValue, isJsonValue } from "./json.js";
10
10
  import { discoverMachines, machinesUserHome } from "./discovery.js";
11
11
  import { listAgentPresets, listMachines, prepareMachineRun, } from "./launcher.js";
12
12
  import { inboxHuman, respondToRun } from "./run-inbox.js";
13
- import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
13
+ import { createRunStatusPublisher, isProcessAlive, readRunStatuses, runOutputSummary, runSessionId, runStatusDirectory, runStatusObserver, } from "./run-status.js";
14
14
  import { readRunWatches, runWatch, runWatchMatches, runWatchPath, sameRunWatch, writeRunWatches, } from "./run-watch.js";
15
15
  import { runsForViewer } from "./run-viewer.js";
16
+ import { startRunUi, uiProjectDir } from "./run-ui.js";
16
17
  const [command, selector, ...argumentsAfterSelector] = process.argv.slice(2);
17
18
  const runsOptions = selector === undefined ? [] : [selector, ...argumentsAfterSelector];
18
19
  const watchUsage = "machine watch [<run-id-or-label-start> | --dir <path>]";
19
20
  const unwatchUsage = "machine unwatch <run-id-or-label-start> | --dir <path> | --all";
20
21
  const watchForUsage = "machine watch --for <session> [<run-id-or-label-start> | --dir <path>]";
21
22
  const unwatchForUsage = "machine unwatch --for <session> <run-id-or-label-start> | --dir <path> | --all";
23
+ const uiUsage = "machine ui [--port <port>] [--project <dir>]";
22
24
  try {
23
25
  const listing = command === "runs" ? parseRunsOptions(runsOptions) : undefined;
24
26
  if (command === "list" && selector === undefined) {
@@ -62,6 +64,9 @@ try {
62
64
  else if (command === "unwatch" && selector !== undefined) {
63
65
  unwatch([selector, ...argumentsAfterSelector]);
64
66
  }
67
+ else if (command === "ui") {
68
+ await ui(selector === undefined ? [] : [selector, ...argumentsAfterSelector]);
69
+ }
65
70
  else {
66
71
  throw new Error([
67
72
  "Usage:",
@@ -76,6 +81,7 @@ try {
76
81
  ` ${unwatchUsage}`,
77
82
  ` ${watchForUsage}`,
78
83
  ` ${unwatchForUsage}`,
84
+ ` ${uiUsage}`,
79
85
  ].join("\n"));
80
86
  }
81
87
  }
@@ -105,7 +111,13 @@ async function runMachine(machinePath, args) {
105
111
  });
106
112
  const status = createRunStatus(machinePath);
107
113
  const runId = randomUUID();
108
- const published = createRunStatusPublisher({ id: runId, machine: prepared.name, path: prepared.path, cwd: process.cwd() });
114
+ const published = createRunStatusPublisher({
115
+ id: runId,
116
+ machine: prepared.name,
117
+ path: prepared.path,
118
+ cwd: process.cwd(),
119
+ ...(prepared.graph === undefined ? {} : { graph: prepared.graph }),
120
+ });
109
121
  const statusDirectory = published.directory;
110
122
  // Machines started by this run's Operations and Agents are its sub-runs.
111
123
  process.env.MACHINES_RUN_PARENT = runId;
@@ -140,7 +152,7 @@ async function runMachine(machinePath, args) {
140
152
  status.onState(state);
141
153
  },
142
154
  });
143
- published.finish({ state: result.value });
155
+ published.finish({ state: result.value, note: runOutputSummary(result.output) });
144
156
  status.succeed();
145
157
  console.log(`--> ${String(result.value)}`);
146
158
  if (result.output !== undefined) {
@@ -320,8 +332,12 @@ function printWatchedRuns(watched, runs) {
320
332
  const matched = runs.filter((record) => runWatchMatches(watched, record));
321
333
  if (matched.length === 0)
322
334
  console.log(" no published run matches yet");
323
- for (const record of matched)
335
+ for (const record of matched) {
324
336
  console.log(` ${runColumns(record).join("\t")}`);
337
+ const note = finishedRunNote(record);
338
+ if (note !== undefined)
339
+ console.log(` ${note}`);
340
+ }
325
341
  }
326
342
  /** The session whose watch list is read or changed, this process's own unless one is named, and where the list is kept. */
327
343
  function requireWatchList(named) {
@@ -351,6 +367,35 @@ async function respond(args) {
351
367
  });
352
368
  console.log(`Machine run ${runId} took the ${question ? "question" : "response"}.`);
353
369
  }
370
+ /** Serves the live page of published runs until interrupted. */
371
+ async function ui(args) {
372
+ let port;
373
+ let project;
374
+ for (let index = 0; index < args.length; index += 1) {
375
+ const argument = args[index];
376
+ const value = args[index + 1];
377
+ if (argument === "--port" && value !== undefined && /^\d+$/u.test(value) && Number(value) <= 65_535)
378
+ port = Number(value);
379
+ else if (argument === "--project" && value !== undefined)
380
+ project = resolve(value);
381
+ else
382
+ throw new Error(`Usage: ${uiUsage}`);
383
+ index += 1;
384
+ }
385
+ const page = await startRunUi({
386
+ directory: requireStatusDirectory(),
387
+ projectDir: project ?? uiProjectDir(process.cwd()),
388
+ ...(port === undefined ? {} : { port }),
389
+ });
390
+ console.log(`Machines UI: ${page.url}`);
391
+ console.log("Press Ctrl-C to stop.");
392
+ await new Promise((resolveStop) => {
393
+ const stop = () => resolveStop();
394
+ process.once("SIGINT", stop);
395
+ process.once("SIGTERM", stop);
396
+ });
397
+ await page.close();
398
+ }
354
399
  function requireStatusDirectory() {
355
400
  const directory = runStatusDirectory();
356
401
  if (directory === undefined) {
@@ -465,6 +510,7 @@ function printAgentListing(preset, separate) {
465
510
  }
466
511
  /** Recorded text must not be able to move the cursor or restyle the terminal. */
467
512
  function plain(text) {
513
+ // oxlint-disable-next-line no-control-regex -- control characters are what this removes.
468
514
  return text.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").trim();
469
515
  }
470
516
  /** A run as tab-separated columns: its label follows the four that were always there. */
@@ -472,6 +518,11 @@ function runColumns(record) {
472
518
  const label = record.label === undefined ? "" : plain(record.label);
473
519
  return [record.id, record.machine, runStatus(record), record.state ?? "starting"].map(plain).concat(label === "" ? [] : [label]);
474
520
  }
521
+ function finishedRunNote(record) {
522
+ if (record.status !== "completed" && record.status !== "failed")
523
+ return undefined;
524
+ return record.note?.split(/\r?\n/u).map(plain).find((line) => line !== "");
525
+ }
475
526
  function printRunListing(record, separate, watched) {
476
527
  const state = record.state ?? "starting";
477
528
  const recorded = runStatus(record);
@@ -486,6 +537,9 @@ function printRunListing(record, separate, watched) {
486
537
  ...(human.suggestions === undefined ? [] : [`suggestions: ${human.suggestions.map(plain).join(", ")}`]),
487
538
  ...(human.discussion === true ? ["discussion: questions allowed"] : []),
488
539
  ];
540
+ const note = finishedRunNote(record);
541
+ if (note !== undefined)
542
+ details.push(note);
489
543
  if (!process.stdout.isTTY) {
490
544
  const columns = runColumns(record);
491
545
  // The mark has its own column, after the label's, whether or not the run has a label.
@@ -5,6 +5,8 @@ export interface CodexAgentOptions {
5
5
  readonly model?: string;
6
6
  /** A reasoning effort supported by the selected Codex model. */
7
7
  readonly effort?: string;
8
+ /** Codex service tier, e.g. "fast" or "ultrafast"; omitted values inherit the CLI configuration. */
9
+ readonly serviceTier?: string;
8
10
  /** Defaults to read-only; writes and unrestricted access require an explicit choice. */
9
11
  readonly sandbox?: "read-only" | "workspace-write" | "danger-full-access";
10
12
  readonly skipGitRepoCheck?: boolean;
package/dist/src/codex.js CHANGED
@@ -17,6 +17,7 @@ export function codexAgent(command = "codex", args = [], options = {}) {
17
17
  const env = { ...process.env, ...options.env };
18
18
  const model = options.model ?? env.CODEX_MODEL ?? env.MACHINES_AGENT_MODEL;
19
19
  const effort = options.effort ?? env.CODEX_EFFORT ?? env.MACHINES_AGENT_EFFORT;
20
+ const serviceTier = options.serviceTier ?? env.CODEX_SERVICE_TIER;
20
21
  const outputMode = options.output ?? "stream";
21
22
  report?.({
22
23
  type: "identity",
@@ -37,6 +38,7 @@ export function codexAgent(command = "codex", args = [], options = {}) {
37
38
  ...(options.ephemeral === false ? [] : ["--ephemeral"]),
38
39
  ...(model === undefined ? [] : ["--model", model]),
39
40
  ...(effort === undefined ? [] : ["--config", `model_reasoning_effort=${JSON.stringify(effort)}`]),
41
+ ...(serviceTier === undefined ? [] : ["--config", `service_tier=${JSON.stringify(serviceTier)}`]),
40
42
  "--output-last-message", finalPath, "-",
41
43
  ], { cwd: request.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
42
44
  const exited = new Promise((resolve, reject) => {
package/dist/src/host.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import * as v from "valibot";
6
6
  import { assertJsonValue, isJsonValue } from "./json.js";
7
7
  import { prepareMachineRun, } from "./launcher.js";
8
- import { createRunStatusPublisher, runStatusDirectory, runStatusObserver, } from "./run-status.js";
8
+ import { createRunStatusPublisher, runOutputSummary, runStatusDirectory, runStatusObserver, } from "./run-status.js";
9
9
  const childArgument = "--machines-child-host";
10
10
  const humanRequestSchema = v.strictObject({
11
11
  discussion: v.optional(v.boolean()),
@@ -74,6 +74,8 @@ const childMessageSchema = v.variant("type", [
74
74
  description: v.string(),
75
75
  path: v.string(),
76
76
  agentRoles: v.record(v.string(), v.string()),
77
+ // Checked as the record is read back; here it only has to be an object.
78
+ graph: v.optional(v.custom((value) => value !== null && typeof value === "object")),
77
79
  }),
78
80
  v.strictObject({ type: v.literal("state"), state: stateValueSchema }),
79
81
  v.strictObject({ type: v.literal("agentUpdate"), update: agentUpdateSchema }),
@@ -193,6 +195,7 @@ export function startMachineHost(options) {
193
195
  machine: message.name,
194
196
  path: message.path,
195
197
  cwd: resolvePath(cwd),
198
+ ...(message.graph === undefined ? {} : { graph: message.graph }),
196
199
  ...(options.label === undefined ? {} : { label: options.label }),
197
200
  ...(options.owner === undefined ? {} : { owner: options.owner }),
198
201
  // Responses reach this run through `respond` and `ask` below, never through an inbox.
@@ -290,7 +293,7 @@ export function startMachineHost(options) {
290
293
  }
291
294
  phase = "completed";
292
295
  pendingHuman = undefined;
293
- published?.finish({ state: message.state });
296
+ published?.finish({ state: message.state, note: runOutputSummary(message.output) });
294
297
  dispose();
295
298
  resolveResult({
296
299
  state: message.state,
@@ -401,6 +404,7 @@ function runChildHost() {
401
404
  description: prepared.description,
402
405
  path: prepared.path,
403
406
  agentRoles: prepared.agentRoles,
407
+ ...(prepared.graph === undefined ? {} : { graph: prepared.graph }),
404
408
  });
405
409
  // Machines started by this run's Operations and Agents are its sub-runs.
406
410
  if (runId !== undefined)
@@ -3,6 +3,7 @@ import { type JsonValue } from "./json.ts";
3
3
  import { type LauncherLocation, type MachineSummary, type AgentPresetSummary } from "./catalog.ts";
4
4
  export type { LauncherLocation, MachineSummary, AgentPresetSummary } from "./catalog.ts";
5
5
  import { type RunOptions } from "./index.ts";
6
+ import { type RunGraph } from "./run-graph.ts";
6
7
  export interface PrepareMachineRunOptions extends LauncherLocation {
7
8
  readonly machine: string;
8
9
  readonly input?: JsonValue;
@@ -22,6 +23,8 @@ export interface PreparedMachineRun {
22
23
  readonly description: string;
23
24
  readonly path: string;
24
25
  readonly agentRoles: Readonly<Record<string, string>>;
26
+ /** The definition's states and transitions, for run status; undefined when it is too large to draw. */
27
+ readonly graph?: RunGraph;
25
28
  start(options?: Omit<RunOptions, "agents">): Promise<SnapshotFrom<AnyStateMachine>>;
26
29
  }
27
30
  export declare function listMachines(location?: LauncherLocation): Promise<readonly MachineSummary[]>;
@@ -7,6 +7,7 @@ import { queryCatalog } from "./discovery-worker.js";
7
7
  import { loadMachineModule, readAgentRoles, requireMachineDescription } from "./machine-module.js";
8
8
  import { discoverMachines, machinesUserHome } from "./discovery.js";
9
9
  import { agent, final, human, machine, operation, requiredAgentNames, run, } from "./index.js";
10
+ import { machineGraph } from "./run-graph.js";
10
11
  export async function listMachines(location = {}) {
11
12
  return queryCatalog("machines", location);
12
13
  }
@@ -34,11 +35,13 @@ export async function prepareMachineRun(options) {
34
35
  preflightAgentRoles(path, usedAgentRoles, agentRoles, agents);
35
36
  const limitAware = limitAwareAgents(requiredRoles, overrides, configured, limitPolicy(options.agentLimits));
36
37
  let started = false;
38
+ const graph = machineGraph(definition);
37
39
  return {
38
40
  name: machineName(path),
39
41
  description,
40
42
  path,
41
43
  agentRoles,
44
+ ...(graph === undefined ? {} : { graph }),
42
45
  start(runOptions = {}) {
43
46
  if (started)
44
47
  throw new Error(`Prepared Machine "${path}" has already started`);
@@ -0,0 +1,39 @@
1
+ import type { AnyStateMachine } from "xstate";
2
+ /**
3
+ * A Machine's states and transitions as data, for a surface that draws them.
4
+ * Ids are state paths joined by dots, with the root left out: `plan`, or
5
+ * `review.round` for a state nested in `review`.
6
+ */
7
+ export interface RunGraph {
8
+ readonly initial?: string;
9
+ readonly states: readonly RunGraphState[];
10
+ readonly edges: readonly RunGraphEdge[];
11
+ }
12
+ export interface RunGraphState {
13
+ readonly id: string;
14
+ /** The compound state this one is nested in. */
15
+ readonly parent?: string;
16
+ /** What runs in the state. `group` holds nested states; `state` runs nothing itself. */
17
+ readonly kind: "agent" | "human" | "operation" | "final" | "group" | "state";
18
+ /** The Agent role an Agent state runs. */
19
+ readonly role?: string;
20
+ }
21
+ export interface RunGraphEdge {
22
+ readonly from: string;
23
+ readonly to: string;
24
+ /** The event the transition takes: an outcome, `done` when a group finishes, or `always`/`after`. */
25
+ readonly event: string;
26
+ }
27
+ /** One state the run entered, and when. */
28
+ export interface RunTrailStep {
29
+ readonly state: string;
30
+ readonly at: string;
31
+ }
32
+ /**
33
+ * Reads the graph out of a Machine definition, or returns undefined for one too
34
+ * large to draw or one whose shape this cannot read. Never throws: a picture of
35
+ * the workflow is never worth the run.
36
+ */
37
+ export declare function machineGraph(definition: AnyStateMachine): RunGraph | undefined;
38
+ /** A state value as the dotted paths of its active leaves: `{"review":"round"}` is `review.round`. */
39
+ export declare function statePaths(value: unknown): string[];
@@ -0,0 +1,91 @@
1
+ /** Past this a Machine is not drawn: its record would grow past what a reader can take in at once. */
2
+ const maxStates = 300;
3
+ const maxEdges = 1_000;
4
+ /**
5
+ * Reads the graph out of a Machine definition, or returns undefined for one too
6
+ * large to draw or one whose shape this cannot read. Never throws: a picture of
7
+ * the workflow is never worth the run.
8
+ */
9
+ export function machineGraph(definition) {
10
+ try {
11
+ const root = definition.root;
12
+ const states = [];
13
+ const edges = [];
14
+ const id = (node) => node.path.join(".");
15
+ const visit = (node, parent) => {
16
+ const using = node.meta?.["machines.agent"]?.using;
17
+ const src = node.invoke[0]?.src;
18
+ const kind = node.type === "final" ? "final"
19
+ : node.type === "compound" || node.type === "parallel" ? "group"
20
+ : typeof using === "string" ? "agent"
21
+ : src === "human" ? "human"
22
+ : src !== undefined ? "operation"
23
+ : "state";
24
+ states.push({
25
+ id: id(node),
26
+ ...(parent === undefined ? {} : { parent }),
27
+ kind,
28
+ ...(kind === "agent" ? { role: using } : {}),
29
+ });
30
+ const add = (event, targets) => {
31
+ for (const target of targets ?? []) {
32
+ // A target outside the drawn states is the root itself, which nothing draws.
33
+ if (target.path.length > 0)
34
+ edges.push({ from: id(node), to: id(target), event });
35
+ }
36
+ };
37
+ for (const [event, definitions] of node.transitions) {
38
+ // The runtime's own actor events only run actions; the outcome they raise is the transition.
39
+ // A plain XState state can target from them directly, through `onDone` and `onError`.
40
+ const label = event.startsWith("xstate.done.state.") || event.startsWith("xstate.done.actor.") ? "done"
41
+ : event.startsWith("xstate.error.actor.") ? "error"
42
+ : event.startsWith("xstate.after.") ? "after"
43
+ : event;
44
+ for (const transition of definitions)
45
+ add(label, transition.target);
46
+ }
47
+ for (const transition of node.always ?? [])
48
+ add("always", transition.target);
49
+ for (const child of Object.values(node.states))
50
+ visit(child, kind === "group" ? id(node) : parent);
51
+ };
52
+ for (const child of Object.values(root.states))
53
+ visit(child, undefined);
54
+ if (states.length === 0 || states.length > maxStates || edges.length > maxEdges)
55
+ return undefined;
56
+ const initial = root.initial?.target?.[0];
57
+ return { ...(initial === undefined ? {} : { initial: id(initial) }), states, edges: dedupe(edges) };
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ /** A state value as the dotted paths of its active leaves: `{"review":"round"}` is `review.round`. */
64
+ export function statePaths(value) {
65
+ if (typeof value === "string") {
66
+ if (!value.startsWith("{"))
67
+ return [value];
68
+ try {
69
+ return statePaths(JSON.parse(value));
70
+ }
71
+ catch {
72
+ return [value];
73
+ }
74
+ }
75
+ if (value === null || typeof value !== "object")
76
+ return [];
77
+ return Object.entries(value).flatMap(([key, child]) => {
78
+ const below = statePaths(child);
79
+ return below.length === 0 ? [key] : below.map((path) => `${key}.${path}`);
80
+ });
81
+ }
82
+ function dedupe(edges) {
83
+ const seen = new Set();
84
+ return edges.filter((edge) => {
85
+ const key = `${edge.from}\0${edge.to}\0${edge.event}`;
86
+ if (seen.has(key))
87
+ return false;
88
+ seen.add(key);
89
+ return true;
90
+ });
91
+ }
@@ -1,5 +1,6 @@
1
1
  import type { StateValue } from "xstate";
2
2
  import type { AgentUpdate } from "./index.ts";
3
+ import { type RunGraph, type RunTrailStep } from "./run-graph.ts";
3
4
  /** One published run, stored as `<id>.json` in the run-status directory. */
4
5
  export interface RunStatusRecord {
5
6
  /** Version 1 records carry only `human.prompt`. */
@@ -30,6 +31,12 @@ export interface RunStatusRecord {
30
31
  /** Names the inbox responses the run took lately, so that each sender can tell; never a response. */
31
32
  readonly deliveries?: readonly string[];
32
33
  readonly error?: string;
34
+ /** The finished run's summary, at most 2000 characters including any ellipsis. */
35
+ readonly note?: string;
36
+ /** The Machine's states and transitions, when it is small enough to draw. */
37
+ readonly graph?: RunGraph;
38
+ /** The states the run entered, oldest first: the latest `trailLimit` of them. */
39
+ readonly trail?: readonly RunTrailStep[];
33
40
  readonly startedAt: string;
34
41
  readonly updatedAt: string;
35
42
  }
@@ -63,6 +70,7 @@ export interface RunStatusRun {
63
70
  /** Replaces `MACHINES_RUN_LABEL`, and is shortened as it is. */
64
71
  readonly label?: string;
65
72
  readonly answeredBy?: "host";
73
+ readonly graph?: RunGraph;
66
74
  }
67
75
  /** A present key replaces its field; a present key holding `undefined` clears it. */
68
76
  export interface RunStatusChange {
@@ -77,6 +85,7 @@ export interface RunStatusChange {
77
85
  }
78
86
  export type RunStatusOutcome = {
79
87
  readonly state?: StateValue;
88
+ readonly note?: string;
80
89
  } | {
81
90
  readonly error: string;
82
91
  };
@@ -89,6 +98,8 @@ export interface RunStatusPublisher {
89
98
  update(change: RunStatusChange): void;
90
99
  finish(outcome: RunStatusOutcome): void;
91
100
  }
101
+ /** How many entered states a record keeps: enough for a long run's repairs, small enough to rewrite at every step. */
102
+ export declare const trailLimit = 200;
92
103
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
93
104
  export declare const deliveryRetentionMilliseconds = 60000;
94
105
  /** How long a session's watch list is kept after its last change: sessions end without saying so. */
@@ -115,8 +126,9 @@ export declare function runDeliveryPath(directory: string, id: string, delivery:
115
126
  /**
116
127
  * Publishes one run for hosts that can only read files, such as a status line,
117
128
  * in `runStatusDirectory`. Publication is presentation: it never throws, and it
118
- * records neither Machine input nor output. A run whose first record cannot be
119
- * written, as in a read-only home, is not published at all.
129
+ * records no Machine input and only the supplied summary of its output.
130
+ * A run whose first record cannot be written, as in a read-only home, is not
131
+ * published at all.
120
132
  */
121
133
  export declare function createRunStatusPublisher(run: RunStatusRun, environment?: NodeJS.ProcessEnv): RunStatusPublisher;
122
134
  /**
@@ -139,3 +151,5 @@ export declare function readRunStatuses(directory: string): RunStatusRecord[];
139
151
  /** The id becomes a file name, so it must not be able to leave the directory. */
140
152
  export declare function isFileSafeId(id: string): boolean;
141
153
  export declare function isProcessAlive(pid: number): boolean;
154
+ /** Only an object's non-empty summary is published from a Machine's output. */
155
+ export declare function runOutputSummary(output: unknown): string | undefined;
@@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, isAbsolute, join, resolve } from "node:path";
4
4
  import { machinesUserHome } from "./discovery.js";
5
+ import { statePaths } from "./run-graph.js";
5
6
  const staleAfterMilliseconds = 60 * 60 * 1_000;
7
+ /** How many entered states a record keeps: enough for a long run's repairs, small enough to rewrite at every step. */
8
+ export const trailLimit = 200;
6
9
  /** How long a record goes on naming a response its run took: longer than a sender waits to hear of it. */
7
10
  export const deliveryRetentionMilliseconds = 60_000;
8
11
  /** How long a session's watch list is kept after its last change: sessions end without saying so. */
@@ -59,8 +62,9 @@ export function runDeliveryPath(directory, id, delivery) {
59
62
  /**
60
63
  * Publishes one run for hosts that can only read files, such as a status line,
61
64
  * in `runStatusDirectory`. Publication is presentation: it never throws, and it
62
- * records neither Machine input nor output. A run whose first record cannot be
63
- * written, as in a read-only home, is not published at all.
65
+ * records no Machine input and only the supplied summary of its output.
66
+ * A run whose first record cannot be written, as in a read-only home, is not
67
+ * published at all.
64
68
  */
65
69
  export function createRunStatusPublisher(run, environment = process.env) {
66
70
  const directory = runStatusDirectory(environment);
@@ -79,6 +83,14 @@ export function createRunStatusPublisher(run, environment = process.env) {
79
83
  let human;
80
84
  let deliveries = [];
81
85
  let error;
86
+ let note;
87
+ let trail = [];
88
+ const enter = (entered, at) => {
89
+ const path = entered === undefined ? "" : statePaths(entered).join(", ");
90
+ if (path === "" || trail.at(-1)?.state === path)
91
+ return;
92
+ trail = [...trail, { state: path, at }].slice(-trailLimit);
93
+ };
82
94
  const publish = () => {
83
95
  const record = {
84
96
  schemaVersion: 2,
@@ -99,6 +111,9 @@ export function createRunStatusPublisher(run, environment = process.env) {
99
111
  ...(run.answeredBy === undefined ? {} : { answeredBy: run.answeredBy }),
100
112
  ...(deliveries.length === 0 ? {} : { deliveries: deliveries.map((delivery) => delivery.id) }),
101
113
  ...(error === undefined ? {} : { error }),
114
+ ...(note === undefined ? {} : { note }),
115
+ ...(run.graph === undefined ? {} : { graph: run.graph }),
116
+ ...(trail.length === 0 ? {} : { trail }),
102
117
  startedAt,
103
118
  updatedAt: new Date().toISOString(),
104
119
  };
@@ -153,8 +168,10 @@ export function createRunStatusPublisher(run, environment = process.env) {
153
168
  // Repeated notifications for an unchanged run do not need another write.
154
169
  if (JSON.stringify(next) === JSON.stringify({ status, state, agent, limit, human, deliveries }))
155
170
  return;
156
- if (next.state !== state)
171
+ if (next.state !== state) {
157
172
  stateSince = new Date().toISOString();
173
+ enter(next.state, stateSince);
174
+ }
158
175
  ({ status, state, agent, limit, human, deliveries } = next);
159
176
  publish();
160
177
  },
@@ -168,6 +185,8 @@ export function createRunStatusPublisher(run, environment = process.env) {
168
185
  else {
169
186
  status = "completed";
170
187
  state = formatState(outcome.state) ?? state;
188
+ note = formatNote(outcome.note);
189
+ enter(state, new Date().toISOString());
171
190
  }
172
191
  stateSince = undefined;
173
192
  agent = undefined;
@@ -353,9 +372,23 @@ function isRunStatusRecord(value) {
353
372
  && (record.answeredBy === undefined || record.answeredBy === "host")
354
373
  && isOptionalStrings(record.deliveries)
355
374
  && isOptionalString(record.error)
375
+ && isOptionalString(record.note)
376
+ && (record.graph === undefined || isGraph(record.graph))
377
+ && (record.trail === undefined || (Array.isArray(record.trail) && record.trail.every((step) => step !== null && typeof step === "object" && typeof step.state === "string" && isTimestamp(step.at))))
356
378
  && isTimestamp(record.startedAt)
357
379
  && isTimestamp(record.updatedAt);
358
380
  }
381
+ function isGraph(value) {
382
+ if (value === null || typeof value !== "object")
383
+ return false;
384
+ const graph = value;
385
+ return isOptionalString(graph.initial)
386
+ && Array.isArray(graph.states) && graph.states.every((state) => state !== null && typeof state === "object"
387
+ && typeof state.id === "string" && typeof state.kind === "string"
388
+ && isOptionalString(state.parent) && isOptionalString(state.role))
389
+ && Array.isArray(graph.edges) && graph.edges.every((edge) => edge !== null && typeof edge === "object"
390
+ && typeof edge.from === "string" && typeof edge.to === "string" && typeof edge.event === "string");
391
+ }
359
392
  function isOptionalString(value) {
360
393
  return value === undefined || typeof value === "string";
361
394
  }
@@ -384,6 +417,26 @@ function formatState(state) {
384
417
  return undefined;
385
418
  return typeof state === "string" ? state : JSON.stringify(state);
386
419
  }
420
+ /** Only an object's non-empty summary is published from a Machine's output. */
421
+ export function runOutputSummary(output) {
422
+ if (output === null || typeof output !== "object" || Array.isArray(output))
423
+ return undefined;
424
+ const summary = output.summary;
425
+ return typeof summary === "string" && summary.trim() !== "" ? summary : undefined;
426
+ }
427
+ function formatNote(note) {
428
+ const text = note?.trim();
429
+ if (!text)
430
+ return undefined;
431
+ if (text.length <= 2000)
432
+ return text;
433
+ // Reserve one character for the ellipsis, keeping the last whole word that fits.
434
+ let end = 1999;
435
+ while (end > 0 && !/\s/u.test(text[end]))
436
+ end -= 1;
437
+ // A single word longer than the limit still has to fit in the record.
438
+ return `${text.slice(0, end || 1999).trimEnd()}…`;
439
+ }
387
440
  /** One short line: a label is a caption, not a place for task text. */
388
441
  function formatLabel(label) {
389
442
  const line = label?.split(/\r?\n/u).map((part) => part.trim()).find((part) => part !== "");
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The page `machine ui` serves: one self-contained document, with no outside
3
+ * scripts, styles, or fonts, so it works offline and loads nothing but its own
4
+ * data. The token lets only this page answer a run.
5
+ */
6
+ export declare function runUiPage(token: string): string;