@dna113p/machines 0.3.4 → 0.3.6

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.
@@ -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;