@agentswf/engine 0.1.0-beta.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
package/src/wake.ts CHANGED
@@ -1,20 +1,27 @@
1
- import { closeSync, openSync } from "node:fs";
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync } from "node:fs";
2
3
  import { join } from "node:path";
3
- import {
4
- MAILBOX_RECORD_VERSION,
5
- type MailboxRecord,
6
- type WakeDid,
7
- type WakeWhy,
8
- } from "@agentswf/contract/records";
4
+ import type { WakeDid, WakeWhy } from "@agentswf/contract/records";
9
5
  import { WIRE_VERSION } from "@agentswf/contract/wire";
10
6
  import { coordinatorSocketPath, liveCoordinatorSocket } from "./coordinator-socket";
11
7
  import { messageOf } from "./errors";
12
8
  import { exists } from "./files";
13
9
  import { coordinatorDir } from "./machine";
14
- import { appendMailbox, foldMailbox, hasWork, readMailbox, readyFor } from "./mailbox";
10
+ import { liveHolder, runOpenFile } from "./run-lock";
11
+ import { wakeCause } from "./run-policy/waits";
15
12
  import { statusOf, stuckOf } from "./run-state";
16
- import { type Run, readAttempts, sameProcess } from "./runs";
13
+ import {
14
+ claimedOf,
15
+ generateId,
16
+ launchLog,
17
+ type Run,
18
+ readAttemptFiles,
19
+ readAttempts,
20
+ recordFailedLaunch,
21
+ sameProcess,
22
+ } from "./runs";
17
23
  import { exchange } from "./socket-exchange";
24
+ import { readWaits } from "./waits";
18
25
 
19
26
  /**
20
27
  * What `wake` did to a run: why it did nothing when it did nothing, and what was due when it
@@ -29,9 +36,10 @@ export type Woken = {
29
36
 
30
37
  /**
31
38
  * Starts or continues a run's attempt, returning once that attempt has claimed its number, so
32
- * its pid is recorded while the run's lock is held.
39
+ * its pid is recorded while the run's lock is held. `launch` names it: its output is
40
+ * `launches/{launch}.log`, and the attempt it starts records it.
33
41
  */
34
- export type Launch = (run: Run, file: string) => Promise<void>;
42
+ export type Launch = (run: Run, file: string, launch: string) => Promise<void>;
35
43
 
36
44
  export type WakeContext = {
37
45
  home: string;
@@ -39,7 +47,7 @@ export type WakeContext = {
39
47
  /** The file a sender named; absent, the run's last attempt's. */
40
48
  file?: string;
41
49
  launch?: Launch;
42
- /** Asks a live run's wait to read its mailbox again; false when it couldn't be reached. */
50
+ /** Asks a live run's wait to read its events again; false when it couldn't be reached. */
43
51
  nudge?: (run: Run) => Promise<boolean>;
44
52
  now?: () => number;
45
53
  /** False for a duplicate: a live run has the event already, and a nudge would tell it nothing. */
@@ -51,38 +59,49 @@ export type WakeContext = {
51
59
  * continue it, or nothing. The caller holds the run's lock. A limit on live runs goes here.
52
60
  */
53
61
  export async function wake(run: Run, context: WakeContext): Promise<Woken> {
54
- const attempts = await readAttempts(run);
55
- let read: MailboxRecord[] | undefined;
56
- const mailbox = async () => {
57
- read ??= await readMailbox(run.dir);
58
- return read;
59
- };
62
+ // Every attempt file, for `stuck` and the next number; what to do is decided from those that ran.
63
+ const files = await readAttemptFiles(run);
64
+ const claimed = claimedOf(files);
65
+ const now = () => new Date(context.now?.() ?? Date.now());
60
66
  const launch = async (
61
67
  did: "started" | "continued",
62
68
  cause: NonNullable<Woken["cause"]>,
63
69
  ): Promise<Woken> => {
64
- const file = context.file ?? attempts.at(-1)?.file ?? run.record.file;
70
+ // Held by `awf open --no-fork`: nothing starts; the event waits in `in.jsonl` for its exit.
71
+ if (await liveHolder(runOpenFile(run.root, run.record.workflow, run.record.id)))
72
+ return { did: "none", why: "opened" };
73
+ const file = context.file ?? claimed.at(-1)?.file ?? run.record.file;
74
+ const started = now();
75
+ const id = generateId(started, randomBytes(3).toString("hex"));
65
76
  try {
66
77
  if (file === undefined) throw new Error(`${run.record.id} has no file to run`);
67
- await (context.launch ?? detachedLaunch(context))(run, file);
78
+ await (context.launch ?? detachedLaunch(context))(run, file, id);
68
79
  return { did, cause };
69
80
  } catch (error) {
70
- // The event is in the mailbox; the next wake tries again, until Q13's limit.
81
+ // The event is in `in.jsonl`; the next wake tries again, until Q13's limit.
71
82
  const reason = messageOf(error);
72
- read = undefined;
73
- await appendMailbox(run.dir, {
74
- version: MAILBOX_RECORD_VERSION,
75
- at: new Date(context.now?.() ?? Date.now()).toISOString(),
76
- kind: "launch-failed",
77
- reason,
78
- });
83
+ const recorded = await recordFailedLaunch(
84
+ run,
85
+ {
86
+ attempt: (files.at(-1)?.attempt ?? 0) + 1,
87
+ launch: id,
88
+ file: file ?? "",
89
+ started: started.toISOString(),
90
+ reason,
91
+ },
92
+ now(),
93
+ ).catch(() => true);
94
+ // Its number is taken by an attempt that claimed it after all: the launch started it.
95
+ if (!recorded) return { did, cause };
79
96
  return { did: "none", why: "launch-failed", error: reason };
80
97
  }
81
98
  };
82
- const stuck = async () => stuckOf(attempts, await mailbox());
83
- if (attempts.length === 0)
84
- return (await stuck()) ? { did: "none", why: "stuck" } : launch("started", "event");
85
- const { state } = statusOf(run, attempts);
99
+ // A launch that never claimed its number counts towards `stuck` only: the run is woken as it was
100
+ // before it.
101
+ const stuck = () => stuckOf(files);
102
+ if (claimed.length === 0)
103
+ return stuck() ? { did: "none", why: "stuck" } : launch("started", "event");
104
+ const { state } = statusOf(run, files);
86
105
  switch (state) {
87
106
  case "running":
88
107
  if (context.nudgeLive === false) return { did: "none" };
@@ -90,18 +109,17 @@ export async function wake(run: Run, context: WakeContext): Promise<Woken> {
90
109
  ? { did: "nudged" }
91
110
  : { did: "none", why: "nudge-failed" };
92
111
  case "parked": {
93
- if (await stuck()) return { did: "none", why: "stuck" };
112
+ if (stuck()) return { did: "none", why: "stuck" };
94
113
  // Only for what the wait it parked at would take: an event it names, or its timer due.
95
- const state = foldMailbox(await mailbox());
96
- const takes = state.lastWait?.takes;
97
- if (!hasWork(state, takes, context.now?.() ?? Date.now()))
98
- return { did: "none", why: "nothing-due" };
99
- return launch("continued", readyFor(state, takes).length > 0 ? "event" : "timer");
114
+ const at = now().getTime();
115
+ const state = await readWaits(run.dir, at);
116
+ const cause = wakeCause(state, state.lastWait?.takes);
117
+ return cause ? launch("continued", cause) : { did: "none", why: "nothing-due" };
100
118
  }
101
119
  case "interrupted":
102
120
  case "failed":
103
121
  case "timed-out":
104
- return (await stuck())
122
+ return stuck()
105
123
  ? { did: "none", why: "stuck" }
106
124
  : launch("continued", state === "interrupted" ? "crashed" : "failed");
107
125
  case "stopped":
@@ -184,16 +202,17 @@ const CLAIM_MILLISECONDS = 60_000;
184
202
 
185
203
  /**
186
204
  * Runs `awf run {file} --id {id} --continue` in a session of its own (Q4), so it outlives the sender's
187
- * terminal, in the run's project with its recorded `PATH`, its output in the run's `process.log`.
188
- * Bun reads the project's `.env` there, as at every start, so a rotated token reaches it.
205
+ * terminal, in the run's project with its recorded `PATH`, its output in `launches/{launch}.log`,
206
+ * and `AWF_LAUNCH` naming it to the attempt. Bun reads the project's `.env` there, as at every
207
+ * start, so a rotated token reaches it.
189
208
  */
190
209
  function detachedLaunch(context: WakeContext): Launch {
191
- return async (run, file) => {
210
+ return async (run, file, launch) => {
192
211
  const before = (await readAttempts(run)).length;
193
- // Only what this launch writes is quoted if it fails: the log holds every attempt's output.
194
- const from = Bun.file(join(run.dir, "process.log")).size;
195
- const log = openSync(join(run.dir, "process.log"), "a", 0o600);
196
- const env = attemptEnvironment(context.environment, run);
212
+ const path = launchLog(run.dir, launch);
213
+ mkdirSync(join(run.dir, "launches"), { recursive: true, mode: 0o700 });
214
+ const log = openSync(path, "a", 0o600);
215
+ const env = { ...attemptEnvironment(context.environment, run), AWF_LAUNCH: launch };
197
216
  let child: ReturnType<typeof Bun.spawn>;
198
217
  try {
199
218
  child = Bun.spawn({
@@ -225,6 +244,8 @@ function detachedLaunch(context: WakeContext): Launch {
225
244
  // on that.
226
245
  const previous = claimed.at(-2);
227
246
  if (previous && sameProcess(previous.pid, previous.processStart)) return;
247
+ // A plain run opens its socket only when a participant connects: no sender nudges it.
248
+ if (!run.record.events) return;
228
249
  // Best effort: an event sent next nudges the run rather than waiting for its poll.
229
250
  const socket = coordinatorSocketPath(coordinatorDir(context.home, env), run.dir);
230
251
  const opened = Date.now() + SOCKET_MILLISECONDS;
@@ -238,8 +259,7 @@ function detachedLaunch(context: WakeContext): Launch {
238
259
  return;
239
260
  }
240
261
  if (child.exitCode !== null || child.signalCode !== null) {
241
- const tail = await Bun.file(join(run.dir, "process.log"))
242
- .slice(from)
262
+ const tail = await Bun.file(path)
243
263
  .text()
244
264
  .then((text) => text.trim().split("\n").slice(-3).join(" / "))
245
265
  .catch((error: unknown) => messageOf(error));
@@ -247,13 +267,10 @@ function detachedLaunch(context: WakeContext): Launch {
247
267
  }
248
268
  if (Date.now() > giveUp) {
249
269
  // One that claimed meanwhile goes on. Left running, one that did not could still claim the
250
- // run later and race the next launch.
270
+ // run later, past the failed attempt its launcher writes: it is stopped, and waited for.
271
+ if ((await readAttempts(run)).length > before) continue;
272
+ await stopGroup(child);
251
273
  if ((await readAttempts(run)).length > before) continue;
252
- try {
253
- process.kill(-child.pid, "SIGTERM");
254
- } catch {
255
- // It ended meanwhile.
256
- }
257
274
  throw new Error(
258
275
  `the attempt of ${run.record.id} did not start within ${CLAIM_MILLISECONDS / 1000} s`,
259
276
  );
@@ -262,3 +279,16 @@ function detachedLaunch(context: WakeContext): Launch {
262
279
  }
263
280
  };
264
281
  }
282
+
283
+ /**
284
+ * Kills a launched child's process group outright, and waits for it: a `SIGTERM` would be a cancel
285
+ * to `awf run`, which ends an event run for good.
286
+ */
287
+ async function stopGroup(child: ReturnType<typeof Bun.spawn>): Promise<void> {
288
+ try {
289
+ process.kill(-child.pid, "SIGKILL");
290
+ } catch {
291
+ // It ended meanwhile.
292
+ }
293
+ await child.exited;
294
+ }
@@ -1,7 +1,8 @@
1
1
  import { stat } from "node:fs/promises";
2
- import { extname, resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { dirname, extname, resolve } from "node:path";
3
4
  import { pathToFileURL } from "node:url";
4
- import * as workflowSurface from "@agentswf/contract/workflow";
5
+ import * as workflowSurface from "@agentswf/contract/author";
5
6
  import {
6
7
  type EventSchemas,
7
8
  eventWorkflowProblem,
@@ -13,7 +14,11 @@ import {
13
14
  import { plugin } from "bun";
14
15
  import * as typebox from "typebox";
15
16
  import * as typeboxValue from "typebox/value";
17
+ import * as typeboxSurface from "./author-typebox";
18
+ import { messageOf } from "./errors";
16
19
  import { realPath } from "./files";
20
+ import { checkProjectEngine, EngineRefused } from "./project-engine";
21
+ import { projectOf } from "./projects";
17
22
  import { idProblem } from "./runs";
18
23
 
19
24
  /**
@@ -23,12 +28,18 @@ import { idProblem } from "./runs";
23
28
  */
24
29
  const AUTHOR_SURFACE: Record<string, Record<string, unknown>> = {
25
30
  "agentswf/workflow": workflowSurface,
31
+ "agentswf/typebox": typeboxSurface,
26
32
  typebox,
27
33
  "typebox/value": typeboxValue,
28
34
  };
29
35
 
30
36
  let surfaceServed = false;
31
37
 
38
+ /** Whether this process serves the author surface: a program's client never does. */
39
+ export function authorSurfaceServed(): boolean {
40
+ return surfaceServed;
41
+ }
42
+
32
43
  /** Serves the author surface to whatever this process imports next. */
33
44
  export function serveAuthorSurface(): void {
34
45
  if (surfaceServed) return;
@@ -51,7 +62,33 @@ export type EventWorkflow = Workflow<JsonValue, JsonValue, EventSchemas>;
51
62
 
52
63
  export type LoadedWorkflow = { file: string; workflow: AnyWorkflow };
53
64
 
54
- export async function loadWorkflowFile(file: string, cwd: string): Promise<LoadedWorkflow> {
65
+ /**
66
+ * Imports the workflow `file` names, once its project is known to run on this engine (ADR 0015),
67
+ * and checks what `tsc` would have checked of it.
68
+ */
69
+ export async function loadWorkflowFile(
70
+ file: string,
71
+ cwd: string,
72
+ home: string = homedir(),
73
+ ): Promise<LoadedWorkflow> {
74
+ try {
75
+ return await load(file, cwd, home);
76
+ } catch (error) {
77
+ if (error instanceof EngineRefused || error instanceof WorkflowFileError) throw error;
78
+ throw new WorkflowFileError(messageOf(error), { cause: error });
79
+ }
80
+ }
81
+
82
+ /** A workflow file that isn't there, doesn't load, or isn't the workflow it should be. */
83
+ export class WorkflowFileError extends Error {
84
+ readonly code = "file";
85
+ constructor(message: string, options?: ErrorOptions) {
86
+ super(message, options);
87
+ this.name = "WorkflowFileError";
88
+ }
89
+ }
90
+
91
+ async function load(file: string, cwd: string, home: string): Promise<LoadedWorkflow> {
55
92
  if (file.includes("://") || file.startsWith("file:")) {
56
93
  throw new Error("workflow must be an explicit local file path");
57
94
  }
@@ -66,6 +103,9 @@ export async function loadWorkflowFile(file: string, cwd: string): Promise<Loade
66
103
  throw new Error(`workflow file not found: ${absolute}`);
67
104
  }
68
105
  if (!info.isFile()) throw new Error(`workflow path is not a file: ${absolute}`);
106
+ // Before its import: another engine's workflow would get this one's author surface.
107
+ const project = await projectOf(absolute, home).catch(() => dirname(absolute));
108
+ checkProjectEngine(project, { file: absolute });
69
109
 
70
110
  serveAuthorSurface();
71
111
  const namespace: unknown = await import(pathToFileURL(absolute).href);
@@ -81,11 +121,21 @@ export async function loadWorkflowFile(file: string, cwd: string): Promise<Loade
81
121
  `meta.version: ${JSON.stringify(version)} is not a semver version such as 1.2.0`,
82
122
  );
83
123
  }
124
+ if (given.events === undefined && given.file !== undefined) {
125
+ // A plain workflow's `file` says where a program finds it and its runs: it must be this one.
126
+ if (typeof given.file !== "string" || realPath(given.file) !== realPath(absolute))
127
+ throw new Error(
128
+ `file is ${JSON.stringify(given.file)}; a workflow gives file: import.meta.path, ${absolute}`,
129
+ );
130
+ }
84
131
  if (given.events !== undefined) {
85
132
  // As `tsc` would have checked them, for a file it never saw.
86
133
  const eventProblem = eventWorkflowProblem(given);
87
134
  if (eventProblem) throw new Error(eventProblem);
88
- if (realPath(given.file as string) !== realPath(absolute))
135
+ if (given.file === undefined)
136
+ // Found by the path it was loaded from, which is all a sender with that path needs.
137
+ return { file: absolute, workflow: { ...given, file: absolute } as AnyWorkflow };
138
+ if (realPath(given.file) !== realPath(absolute))
89
139
  throw new Error(
90
140
  `file is ${JSON.stringify(given.file)}; a workflow with events gives file: import.meta.path, ${absolute}`,
91
141
  );