@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
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:os";
2
- import type { StageNeed, StageRecord } from "@agentswf/contract/records";
2
+ import type { StageLine } from "@agentswf/contract/records";
3
3
  import {
4
4
  type AbsoluteDeadline,
5
5
  type AttemptOutcome,
@@ -12,9 +12,9 @@ import {
12
12
  import { PUBLISHED_PRICES } from "./accounting/prices";
13
13
  import { summarizeRun } from "./accounting/summary";
14
14
  import { messageOf } from "./errors";
15
- import { RunParked } from "./mailbox";
16
15
  import type { Paint } from "./progress-view";
17
16
  import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
17
+ import { RunParked } from "./waits";
18
18
  import {
19
19
  type SettledRun,
20
20
  WorkflowCancelledError,
@@ -36,9 +36,7 @@ export type AttemptEnd = {
36
36
  * Given when the attempt never reached its `--from-stage`, which no one command goes on from:
37
37
  * the run's stages to choose from.
38
38
  */
39
- choose?: readonly StageRecord[];
40
- /** The stages whose values `--values` gives, to start at a later one. */
41
- needs?: readonly StageNeed[];
39
+ choose?: readonly StageLine[];
42
40
  };
43
41
 
44
42
  /** What a started run came to: the result it returned, or what it threw. */
@@ -107,12 +105,12 @@ export function decideEnding(
107
105
  reason: stop?.reason ?? errorDetail(error),
108
106
  stages,
109
107
  continue: goOn(stop, stages),
108
+ ...(stop?.needs ? { needs: [...stop.needs] } : {}),
110
109
  },
111
110
  ...(settled ? { settled } : {}),
112
111
  exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
113
112
  ...(beside ? { alsoFailed: beside } : {}),
114
113
  ...(stop instanceof FromStageUnreached ? { choose: stop.recorded } : {}),
115
- ...(stop?.needs ? { needs: stop.needs } : {}),
116
114
  };
117
115
  }
118
116
 
@@ -181,7 +179,7 @@ export function runOutcome(
181
179
  const stop = failure instanceof WorkflowStopped ? failure : undefined;
182
180
  const timedOut =
183
181
  failure instanceof DeadlineExceededError &&
184
- failure.deadline.unixMilliseconds === deadline?.unixMilliseconds;
182
+ failure.deadline.getTime() === deadline?.unixMilliseconds;
185
183
  const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
186
184
  return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
187
185
  }
@@ -5,12 +5,10 @@ import {
5
5
  RUN_EVENT_VERSIONS,
6
6
  } from "@agentswf/contract/records";
7
7
  import type { Ending, JsonValue } from "@agentswf/contract/workflow";
8
- import { attemptAccounting } from "./accounting/summary";
9
8
  import type { AttemptEnd } from "./attempt-ending";
10
9
  import { messageOf } from "./errors";
11
10
  import { writeJson, writeWhole } from "./files";
12
- import { foldMailbox, readMailbox } from "./mailbox";
13
- import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
11
+ import { type Attempt, appendEvent, endAttempt, outFile, reportFile } from "./runs";
14
12
  import { withoutValue } from "./stage-ledger";
15
13
  import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
16
14
 
@@ -18,15 +16,16 @@ import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
18
16
  export type Kept = {
19
17
  /** Absent when the run never settled, so there is nothing to record but its ending. */
20
18
  json?: string;
21
- /** output.json, once written. */
19
+ /** out.json, once written. */
22
20
  output?: string;
23
21
  report?: string;
24
22
  };
25
23
 
26
24
  /**
27
- * Writes an attempt's records, each from its one ending: report.md, output.json, then the ending in
28
- * its attempt file. The ending goes last: an ended attempt lets the next start, whose report and
29
- * output this one's must not overwrite. A record that fails to write is said, and costs no other.
25
+ * Writes an attempt's records, each from its one ending: report.md and out.json, its views, then the
26
+ * ending in its attempt file, a completed one's with its value. The ending goes last: an ended
27
+ * attempt lets the next start, whose views this one's must not overwrite. A record that fails to
28
+ * write is said, and costs no other.
30
29
  */
31
30
  export async function keepRecords(
32
31
  end: AttemptEnd,
@@ -58,18 +57,19 @@ export async function keepRecords(
58
57
  outcome: ending.kind,
59
58
  reason: ending.reason,
60
59
  ...at,
61
- ...(end.needs ? { needs: [...end.needs] } : {}),
60
+ ...(ending.needs ? { needs: ending.needs } : {}),
62
61
  };
63
62
  const kept: Kept = report ? { report } : {};
64
- const output = outputFile(dir);
63
+ const output = outFile(dir);
65
64
  if (!settled) {
66
65
  // An earlier attempt's output is gone with it, as its report is.
67
66
  await rm(output, { force: true }).catch((error) =>
68
- stderr(`awf: output.json: ${messageOf(error)}`),
67
+ stderr(`awf: out.json: ${messageOf(error)}`),
69
68
  );
70
69
  } else {
71
70
  const record: OutputRecord = {
72
71
  version: OUTPUT_RECORD_VERSION,
72
+ ref: `${context.loaded.workflow.meta.name}/${settled.runId}`,
73
73
  runId: settled.runId,
74
74
  attempt: attempt.record.attempt,
75
75
  workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
@@ -89,7 +89,7 @@ export async function keepRecords(
89
89
  await writeJson(output, record);
90
90
  kept.output = output;
91
91
  } catch (error) {
92
- stderr(`awf: output.json: ${messageOf(error)}`);
92
+ stderr(`awf: out.json: ${messageOf(error)}`);
93
93
  }
94
94
  }
95
95
  // Before the ending: once the attempt has ended, the next may start appending.
@@ -101,30 +101,13 @@ export async function keepRecords(
101
101
  outcome: ending.kind,
102
102
  }).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
103
103
  // An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
104
- // the run returned and is refused, so what no wait took is counted whole. Its last batch was
105
- // handled, as the code returned after it.
104
+ // the run returned and is refused, so what no wait took is what its lines say.
106
105
  const writeEnding = async () => {
107
- const unacknowledged =
108
- ending.kind === "completed" && attempt.run.record.events
109
- ? await readMailbox(dir).then(
110
- // The event that started the run is `wf.started`'s, taken or not.
111
- (records) => {
112
- const state = foldMailbox(records);
113
- return state.unacknowledged.filter((id) => id !== state.started?.id).length;
114
- },
115
- (error: unknown) => {
116
- stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
117
- return undefined;
118
- },
119
- )
120
- : undefined;
121
106
  await endAttempt(attempt, {
122
107
  outcome: ending.kind,
123
- ...(unacknowledged === undefined ? {} : { unacknowledged }),
108
+ ...(ending.kind === "completed" ? { value: ending.value } : {}),
124
109
  ...(unfinished ? { reason: unfinished.reason } : {}),
125
110
  ...at,
126
- ...(stages ? { stages } : {}),
127
- ...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
128
111
  });
129
112
  };
130
113
  try {
@@ -145,7 +128,7 @@ async function writeReport(
145
128
  // An earlier attempt's report is gone with it: report.md is the last ended attempt's.
146
129
  const none = () => rm(file, { force: true }).then(() => undefined);
147
130
  if (!workflow.report) return none();
148
- // The result is still in output.json; a report that cannot be rendered or saved should not fail
131
+ // The result is still in out.json and the attempt file; a report that cannot be rendered or saved should not fail
149
132
  // the run, nor cost it its record.
150
133
  try {
151
134
  const markdown = workflow.report(
@@ -156,7 +139,7 @@ async function writeReport(
156
139
  await writeWhole(file, `${markdown.trimEnd()}\n`);
157
140
  return file;
158
141
  } catch (error) {
159
- stderr(`awf: report: ${messageOf(error)}; see output.json instead`);
142
+ stderr(`awf: report: ${messageOf(error)}; see out.json instead`);
160
143
  return undefined;
161
144
  }
162
145
  }
@@ -1,15 +1,17 @@
1
1
  import type {
2
2
  AgentPaneRecord,
3
- AttemptRecord,
3
+ ClaimedAttempt,
4
4
  RunAccounting,
5
+ StageLine,
5
6
  StageNeed,
6
- StageRecord,
7
7
  } from "@agentswf/contract/records";
8
8
  import type { Ending, JsonValue, StageSummary } from "@agentswf/contract/workflow";
9
9
  import { ago, describeAccounting, describeAttempts } from "./accounting/format";
10
- import { sumAttempts } from "./accounting/summary";
10
+ import type { EarlierSpent } from "./accounting/spent";
11
+ import { attemptAccounting, sumAttempts } from "./accounting/summary";
11
12
  import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
12
13
  import type { Kept } from "./attempt-output";
14
+ import { placeFlags, shellWord } from "./command-line";
13
15
  import { pathFrom } from "./display-path";
14
16
  import { messageOf } from "./errors";
15
17
  import { ANSI, type Paint, PLAIN } from "./progress-view";
@@ -32,7 +34,9 @@ export function printEnding(
32
34
  /** The run's id and folder. */
33
35
  run: { id: string; dir: string };
34
36
  n: number;
35
- earlier: readonly AttemptRecord[];
37
+ earlier: readonly ClaimedAttempt[];
38
+ /** What the attempts before this one spent, from their operations, priced now. */
39
+ spentBefore?: EarlierSpent;
36
40
  json: boolean;
37
41
  /** Whether the attempt redid from a stage, which no repeated stop then hints at. */
38
42
  fromStage: boolean;
@@ -66,12 +70,12 @@ export function printEnding(
66
70
  named: `${context.workflow.meta.name} ${context.run.id}`,
67
71
  n: context.n,
68
72
  ...(settled ? { accounting: settled.accounting } : {}),
69
- earlier: context.earlier,
73
+ ...(context.spentBefore ? { spentBefore: context.spentBefore } : {}),
70
74
  stages: context.terminal === undefined,
71
75
  records: pathFrom(shellCwd, context.run.dir, { home: context.home }),
72
76
  ...(kept.report ? { report: pathFrom(shellCwd, kept.report, { home: context.home }) } : {}),
73
77
  ...(end.choose?.length ? { choose: listRecorded(end.choose, context.now) } : {}),
74
- ...(end.needs ? { needs: end.needs } : {}),
78
+ ...("needs" in end.ending && end.ending.needs ? { needs: end.ending.needs } : {}),
75
79
  ...(settled?.panes ? { panes: settled.panes } : {}),
76
80
  // As the run's Herdr workspace is labelled.
77
81
  runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
@@ -105,7 +109,7 @@ function describeEnding(
105
109
  named: string;
106
110
  n: number;
107
111
  accounting?: RunAccounting;
108
- earlier: readonly AttemptRecord[];
112
+ spentBefore?: EarlierSpent;
109
113
  stages: boolean;
110
114
  records: string;
111
115
  report?: string;
@@ -127,7 +131,9 @@ function describeEnding(
127
131
  context.named,
128
132
  ...(n > 1 ? [`attempt ${n}`] : []),
129
133
  ...(totals === undefined ? [] : [totals]),
130
- ...(n > 1 && accounting ? [runTotal(n, accounting, context.earlier)] : []),
134
+ ...(n > 1 && accounting && context.spentBefore
135
+ ? [runTotal(n, accounting, context.spentBefore)]
136
+ : []),
131
137
  ].join(" · ");
132
138
  const word = OUTCOMES[ending.kind].word;
133
139
  if (ending.kind === "completed") {
@@ -229,14 +235,11 @@ function whereKept(
229
235
  : `${pane} in workspace "${label}" of herdr session ${session}`;
230
236
  }
231
237
 
232
- function runTotal(n: number, current: RunAccounting, earlier: readonly AttemptRecord[]): string {
233
- const before = earlier.filter((record) => record.attempt < n);
234
- const recorded = [...before.flatMap((record) => record.accounting ?? []), current];
235
- const unknown = before.filter((record) => record.accounting === undefined);
236
- const interrupted = unknown.filter((record) => record.ended === undefined).length;
237
- return describeAttempts(sumAttempts(recorded), n, {
238
- interrupted,
239
- ended: unknown.length - interrupted,
238
+ /** The run's total over its attempts: those before priced now, this one as it ran. */
239
+ function runTotal(n: number, current: RunAccounting, before: EarlierSpent): string {
240
+ return describeAttempts(sumAttempts([...before.recorded, attemptAccounting(current)]), n, {
241
+ interrupted: before.interrupted,
242
+ ended: before.ended,
240
243
  });
241
244
  }
242
245
 
@@ -261,7 +264,7 @@ function present(
261
264
  */
262
265
  function sameStop(
263
266
  ending: Ending<JsonValue>,
264
- earlier: readonly AttemptRecord[],
267
+ earlier: readonly ClaimedAttempt[],
265
268
  ): string | undefined {
266
269
  if (ending.kind !== "stopped" || ending.stage !== undefined) return undefined;
267
270
  const reused = ending.stages.at(-1)?.stage;
@@ -285,22 +288,25 @@ export function refusalMessage(id: string, refusal: AttemptRefusal, now: number)
285
288
  if (refusal.kind === "running") {
286
289
  return `attempt ${refusal.attempt.attempt} of ${id} is still running, as process ${refusal.attempt.pid}`;
287
290
  }
291
+ if (refusal.kind === "opened") {
292
+ return `${id} is open in awf open --no-fork (pid ${refusal.pid}); ${refusal.events ? "it is woken once that exits" : "continue it once that exits"}`;
293
+ }
288
294
  if (refusal.stages.length === 0) return `${id} completed; there is nothing to continue`;
289
295
  return `${id} completed; to redo from a stage, --from-stage one of:\n${listRecorded(refusal.stages, now).join("\n")}`;
290
296
  }
291
297
 
292
- /** Stage records, a line each, in the order given: summary, attempt, and how long ago. */
293
- function listRecorded(records: readonly StageRecord[], now: number): string[] {
294
- const name = Math.max(0, ...records.map((record) => record.stage.length));
295
- const summary = Math.max(0, ...records.map((record) => record.summary?.length ?? 0));
296
- return records.map((record) =>
298
+ /** Stage lines, one each, in the order given: summary, attempt, and how long ago. */
299
+ function listRecorded(lines: readonly StageLine[], now: number): string[] {
300
+ const name = Math.max(0, ...lines.map((line) => line.name.length));
301
+ const summary = Math.max(0, ...lines.map((line) => line.summary?.length ?? 0));
302
+ return lines.map((line) =>
297
303
  [
298
- ` ${record.stage.padEnd(name)}`,
299
- ...(summary > 0 ? [(record.summary ?? "").padEnd(summary)] : []),
304
+ ` ${line.name.padEnd(name)}`,
305
+ ...(summary > 0 ? [(line.summary ?? "").padEnd(summary)] : []),
300
306
  [
301
- ...(record.outcome === "succeeded" ? [] : [record.outcome]),
302
- `attempt ${record.attempt}`,
303
- ago(now - Date.parse(record.ended)),
307
+ ...(line.outcome === "succeeded" ? [] : [line.outcome]),
308
+ `attempt ${line.attempt}`,
309
+ ago(now - Date.parse(line.at)),
304
310
  ].join(" · "),
305
311
  ].join(" "),
306
312
  );
@@ -349,8 +355,7 @@ export function continueCommand(
349
355
  return [
350
356
  "awf",
351
357
  "run",
352
- ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
353
- ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
358
+ ...placeFlags(command),
354
359
  pathFrom(command.shellCwd, command.workflowFile),
355
360
  "--id",
356
361
  id,
@@ -366,7 +371,3 @@ export function continueCommand(
366
371
  const STAGE = "{stage}";
367
372
  /** The placeholder for the file of values a go-on needs and none was given. */
368
373
  const FILE = "{file}";
369
-
370
- export function shellWord(word: string): string {
371
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
372
- }
@@ -0,0 +1,13 @@
1
+ // `agentswf/typebox`: a TypeBox schema carrying its type to `run()`, a tool's `input` and an
2
+ // event's `data`. Here rather than in the contract, which imports no schema library.
3
+ import type { JsonValue, OutputSchema } from "@agentswf/contract/workflow";
4
+ import type Type from "typebox";
5
+
6
+ type Typed<Schema extends Type.TSchema> = Extract<Type.Static<Schema>, JsonValue>;
7
+
8
+ /** The schema itself, typed so awf infers its value; a schema of a non-JSON value is a type error. */
9
+ export function typed<Schema extends Type.TSchema>(
10
+ schema: Schema & (Type.Static<Schema> extends JsonValue ? unknown : never),
11
+ ): Schema & OutputSchema<Typed<Schema>> {
12
+ return schema;
13
+ }
@@ -0,0 +1,94 @@
1
+ // The client's checks of what a program gives it that need no file: the fake client runs these
2
+ // too, so a program's tests see the very `ClientError` the real client throws.
3
+ import { isAbsolute } from "node:path";
4
+ import { ClientError } from "@agentswf/contract/client";
5
+ import { isEventWorkflow, isJsonValue, type JsonValue } from "@agentswf/contract/workflow";
6
+ import { messageOf } from "../errors";
7
+ import { idProblem } from "../runs";
8
+ import { type AnyWorkflow, assertJsonValue, type EventWorkflow } from "../workflow-loader";
9
+
10
+ /** `workflow`'s `file`, by which a program finds it and its runs. */
11
+ export function fileOf(workflow: AnyWorkflow): string {
12
+ const file = (workflow as { file?: unknown })?.file;
13
+ if (typeof file !== "string" || !isAbsolute(file))
14
+ throw new ClientError(
15
+ "file",
16
+ `${workflow?.meta?.name ?? "the workflow"} gives no file, by which a program finds it and its runs: file: import.meta.path`,
17
+ );
18
+ return file;
19
+ }
20
+
21
+ /** `workflow`'s `meta.name`, checked as an id is. */
22
+ export function nameOf(workflow: AnyWorkflow): string {
23
+ const name = workflow?.meta?.name;
24
+ const problem = idProblem(name);
25
+ if (problem) throw new ClientError("event", `meta.name: ${problem}`);
26
+ return name;
27
+ }
28
+
29
+ export function assertEventWorkflow(workflow: AnyWorkflow): asserts workflow is EventWorkflow {
30
+ if (!isEventWorkflow(workflow))
31
+ throw new ClientError("event", `${workflow?.meta?.name} declares no events; start runs it`);
32
+ }
33
+
34
+ /** What the types rule out in an emit, refused as the caller's mistake when it comes anyway. */
35
+ export function checkEmit(
36
+ ref: string,
37
+ runId: string,
38
+ name: string,
39
+ data: unknown,
40
+ given: { eventId?: string; source?: string },
41
+ ): asserts data is JsonValue {
42
+ if (typeof name !== "string") throw new ClientError("event", "an event's name is a string", ref);
43
+ if (typeof runId !== "string") throw new ClientError("id", "a run's id is a string");
44
+ for (const [field, value] of [
45
+ ["eventId", given.eventId],
46
+ ["source", given.source],
47
+ ] as const)
48
+ if (value !== undefined && typeof value !== "string")
49
+ throw new ClientError(field === "eventId" ? "id" : "event", `${field} is a string`, ref);
50
+ // What tsc lets through in a value that isn't a literal: in.jsonl keeps JSON only.
51
+ if (!isJsonValue(data))
52
+ throw new ClientError("event", `${name}'s data must contain only JSON values`, ref);
53
+ }
54
+
55
+ export function checkArgs(name: string, args: unknown): asserts args is JsonValue {
56
+ try {
57
+ assertJsonValue(args, `${name} arguments`);
58
+ } catch (error) {
59
+ throw new ClientError("prepare", messageOf(error));
60
+ }
61
+ }
62
+
63
+ /** The id `id(args)` gives, checked; a workflow with `starts` and none is refused. */
64
+ export function derivedId(workflow: AnyWorkflow, args: JsonValue): string | undefined {
65
+ const name = workflow.meta.name;
66
+ if (!workflow.id) {
67
+ if (isEventWorkflow(workflow) && workflow.starts !== undefined)
68
+ throw new ClientError(
69
+ "no-id",
70
+ `a run of ${name}, which starts on events, is named by its senders: start(${name}, args, { id }), or an id(args) on the workflow`,
71
+ );
72
+ return undefined;
73
+ }
74
+ let id: unknown;
75
+ try {
76
+ id = workflow.id(args as never);
77
+ } catch (error) {
78
+ throw new ClientError("prepare", `${name}'s id(args) failed: ${messageOf(error)}`);
79
+ }
80
+ const problem = typeof id === "string" ? idProblem(id) : `it returned a ${typeof id}`;
81
+ if (problem) throw new ClientError("id", `${name}'s id(args): ${problem}`);
82
+ return id as string;
83
+ }
84
+
85
+ export function checkRunId(id: string, ref: string): void {
86
+ const problem = idProblem(id);
87
+ if (problem) throw new ClientError("id", `the run's id: ${problem}`, ref);
88
+ }
89
+
90
+ /** A cursor is an item's, never an option of the command it is read with. */
91
+ export function checkCursor(after: unknown): void {
92
+ if (after !== undefined && (typeof after !== "string" || after === "" || after.startsWith("-")))
93
+ throw new ClientError("cursor", `${JSON.stringify(after)} is no cursor an item carried`);
94
+ }
@@ -0,0 +1,17 @@
1
+ import { messageOf } from "../errors";
2
+
3
+ /**
4
+ * Why `file` isn't the module whose default export is `workflow`, as awf loads it; undefined when
5
+ * it is. The program imported it already, so this import is its module cache's.
6
+ */
7
+ export async function exportProblem(workflow: unknown, file: string): Promise<string | undefined> {
8
+ let exported: unknown;
9
+ try {
10
+ exported = ((await import(file)) as { default?: unknown }).default;
11
+ } catch (error) {
12
+ return `its file, ${file}, could not be imported: ${messageOf(error)}`;
13
+ }
14
+ return exported === workflow
15
+ ? undefined
16
+ : `its file, ${file}, doesn't export it as its default, as awf loads a workflow; file is the path of the module whose default export it is`;
17
+ }
@@ -0,0 +1,122 @@
1
+ import { homedir } from "node:os";
2
+ import { type Client, ClientError, type ClientOptions } from "@agentswf/contract/client";
3
+ import type { EmitResultJson, InboxItemJson } from "@agentswf/contract/records";
4
+ import { awfEventOf, type JsonValue } from "@agentswf/contract/workflow";
5
+ import { checkEvent, deliverReply, emitEvent } from "../emit";
6
+ import { inboxCommand } from "../inbox-command";
7
+ import { asClientError, clientCode } from "../refusals";
8
+ import type { WakeContext } from "../wake";
9
+ import type { AnyWorkflow, EventWorkflow } from "../workflow-loader";
10
+ import { assertEventWorkflow, checkCursor, checkEmit, checkRunId } from "./checks";
11
+ import { locate } from "./locate";
12
+ import { get, openExact, start } from "./runs";
13
+
14
+ /** What the client runs each call with: the operator's home and environment, and how it launches. */
15
+ export type ClientContext = Omit<WakeContext, "file"> & {
16
+ /** How often a wait reads the run's files. */
17
+ pollMs?: number;
18
+ };
19
+
20
+ /**
21
+ * A program's way to its workflows' runs, in its own process: the code `awf emit`, `awf inbox` and
22
+ * the run readers run, on the engine the workflow's project depends on (ADR 0015).
23
+ */
24
+ export function client(options: ClientOptions = {}): Client {
25
+ return clientWith(options);
26
+ }
27
+
28
+ /**
29
+ * `client`, with what a test fakes: how an attempt is launched, a live run nudged, and the time.
30
+ * Not published: a program that launched nothing would record events no run ever takes.
31
+ */
32
+ export function clientWith(
33
+ options: ClientOptions & Pick<WakeContext, "launch" | "nudge" | "now"> & { pollMs?: number },
34
+ ): Client {
35
+ const context: ClientContext = {
36
+ home: options.home ?? homedir(),
37
+ environment: options.environment ?? process.env,
38
+ ...(options.launch ? { launch: options.launch } : {}),
39
+ ...(options.nudge ? { nudge: options.nudge } : {}),
40
+ ...(options.now ? { now: options.now } : {}),
41
+ ...(options.pollMs === undefined ? {} : { pollMs: options.pollMs }),
42
+ };
43
+ return {
44
+ emit: (workflow, run, name, data, given) =>
45
+ emit(workflow as EventWorkflow, run, name, data as JsonValue, given ?? {}, context),
46
+ inbox: (workflow, given) => inbox(workflow as AnyWorkflow, given?.after, context),
47
+ start: (workflow, args, given) =>
48
+ start(workflow as AnyWorkflow, args, given ?? {}, context) as never,
49
+ get: (workflow, id) => get(workflow as AnyWorkflow, id, context) as never,
50
+ };
51
+ }
52
+
53
+ async function emit(
54
+ workflow: EventWorkflow,
55
+ runId: string,
56
+ name: string,
57
+ data: JsonValue,
58
+ given: { eventId?: string; source?: string },
59
+ context: ClientContext,
60
+ ): Promise<EmitResultJson> {
61
+ assertEventWorkflow(workflow);
62
+ const located = await locate(workflow, context.home);
63
+ const ref = `${workflow.meta.name}/${runId}`;
64
+ checkEmit(ref, runId, name, data, given);
65
+ const event = {
66
+ name,
67
+ data,
68
+ ...(given.eventId === undefined ? {} : { id: given.eventId }),
69
+ ...(given.source === undefined ? {} : { source: given.source }),
70
+ };
71
+ try {
72
+ const reply = awfEventOf(name);
73
+ if (reply?.kind !== "reply")
74
+ return await emitEvent(workflow, { run: runId, ...event }, { from: "operator", ...context });
75
+ // Checked against the value given, not the run's file: the program holds the workflow, and no
76
+ // author surface is served to it.
77
+ checkEvent(workflow, event, true);
78
+ checkRunId(runId, ref);
79
+ const run = await openExact(located.runRoot, workflow.meta.name, runId, "id");
80
+ if (!run)
81
+ throw new ClientError(
82
+ "no-run",
83
+ `no run ${ref} in ${located.project}: a reply never starts one`,
84
+ ref,
85
+ );
86
+ return await deliverReply({ run }, reply.key, event, context);
87
+ } catch (error) {
88
+ throw asClientError(error, ref);
89
+ }
90
+ }
91
+
92
+ async function inbox(
93
+ workflow: AnyWorkflow,
94
+ after: string | undefined,
95
+ context: ClientContext,
96
+ ): Promise<InboxItemJson[]> {
97
+ const located = await locate(workflow, context.home);
98
+ checkCursor(after);
99
+ const lines: string[] = [];
100
+ const errors: string[] = [];
101
+ // `awf inbox --workflow --json`'s own reader, so what a program reads is what the command lists.
102
+ const code = await inboxCommand(
103
+ [
104
+ "--workflow",
105
+ workflow.meta.name,
106
+ "--run-root",
107
+ located.runRoot,
108
+ "--json",
109
+ ...(after === undefined ? [] : ["--after", after]),
110
+ ],
111
+ located.project,
112
+ { stdout: (text) => lines.push(text), stderr: (text) => errors.push(text) },
113
+ { home: context.home },
114
+ );
115
+ const items = lines.flatMap((line) => line.split("\n")).filter((line) => line !== "");
116
+ if (code === 0) return items.map((line) => JSON.parse(line) as InboxItemJson);
117
+ const refused =
118
+ items.length > 0 ? (JSON.parse(items.at(-1)!) as { code?: string; error?: string }) : {};
119
+ const known = clientCode(refused.code);
120
+ if (known) throw new ClientError(known, refused.error ?? known);
121
+ throw new Error(refused.error ?? (errors.join("\n") || `awf inbox exited ${code}`));
122
+ }
@@ -0,0 +1,34 @@
1
+ import { ClientError } from "@agentswf/contract/client";
2
+ import { messageOf } from "../errors";
3
+ import { checkProjectEngine } from "../project-engine";
4
+ import { projectOf, projectRunRoot } from "../projects";
5
+ import { asClientError } from "../refusals";
6
+ import type { AnyWorkflow } from "../workflow-loader";
7
+ import { fileOf, nameOf } from "./checks";
8
+ import { exportProblem } from "./exported";
9
+
10
+ /** A workflow as the client finds it: its file, checked to export it, and its project's runs. */
11
+ export type Found = { workflow: AnyWorkflow; file: string; project: string; runRoot: string };
12
+
13
+ /**
14
+ * Where `workflow`'s runs are: its `file`, which must export it as its default, in a project that
15
+ * runs on this engine. `file` is imported, which the program's module cache already holds.
16
+ */
17
+ export async function locate(workflow: AnyWorkflow, home: string): Promise<Found> {
18
+ const file = fileOf(workflow);
19
+ const exported = await exportProblem(workflow, file);
20
+ if (exported) throw new ClientError("file", `${workflow?.meta?.name}: ${exported}`);
21
+ nameOf(workflow);
22
+ let project: string;
23
+ try {
24
+ project = await projectOf(file, home);
25
+ } catch (error) {
26
+ throw new ClientError("project", messageOf(error));
27
+ }
28
+ try {
29
+ checkProjectEngine(project, { file, client: true });
30
+ } catch (error) {
31
+ throw asClientError(error);
32
+ }
33
+ return { workflow, file, project, runRoot: projectRunRoot(project) };
34
+ }
@@ -0,0 +1,3 @@
1
+ // What `agentswf/client` publishes: the contract's types and `ClientError`, and `client`.
2
+ export * from "@agentswf/contract/client";
3
+ export { client } from "./index";