@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 +7 -2
- package/claude/statusline.mjs +2 -0
- package/dist/mcp/server.js +2 -2
- package/dist/src/cli.js +58 -4
- package/dist/src/codex.d.ts +2 -0
- package/dist/src/codex.js +2 -0
- package/dist/src/host.js +6 -2
- package/dist/src/launcher.d.ts +3 -0
- package/dist/src/launcher.js +3 -0
- package/dist/src/run-graph.d.ts +39 -0
- package/dist/src/run-graph.js +91 -0
- package/dist/src/run-status.d.ts +16 -2
- package/dist/src/run-status.js +56 -3
- package/dist/src/run-ui-page.d.ts +6 -0
- package/dist/src/run-ui-page.js +690 -0
- package/dist/src/run-ui.d.ts +62 -0
- package/dist/src/run-ui.js +258 -0
- package/dist/src/run-viewer.d.ts +2 -0
- package/dist/src/run-viewer.js +1 -0
- package/docs/authoring.md +13 -0
- package/docs/integrations.md +56 -4
- package/package.json +88 -21
- package/skills/machine-builder/SKILL.md +31 -0
- package/skills/machine-delegation/SKILL.md +23 -0
- package/skills/machine-project-setup/SKILL.md +23 -5
- package/skills/machine-project-setup/references/project-workflows.md +4 -0
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.
|
|
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.
|
|
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
|
|
package/claude/statusline.mjs
CHANGED
|
@@ -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
|
|
package/dist/mcp/server.js
CHANGED
|
@@ -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({
|
|
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.
|
package/dist/src/codex.d.ts
CHANGED
|
@@ -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)
|
package/dist/src/launcher.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/src/launcher.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/run-status.d.ts
CHANGED
|
@@ -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
|
|
119
|
-
* written, as in a read-only home, is not
|
|
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;
|
package/dist/src/run-status.js
CHANGED
|
@@ -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
|
|
63
|
-
* written, as in a read-only home, is not
|
|
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;
|