@agentswf/engine 0.0.0-stage → 0.1.0-beta.1

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 (82) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,88 @@
1
+ import type { JsonSchema } from "@agentswf/contract/schema";
2
+ import type { JsonValue } from "@agentswf/contract/workflow";
3
+
4
+ /**
5
+ * A schema as a person reads a type: `{iid: integer, url: string}`. A top-level `anyOf` is a line
6
+ * per branch, the rest one line.
7
+ */
8
+ export function schemaLines(schema: JsonSchema): string[] {
9
+ return "anyOf" in schema ? schema.anyOf.map(typeText) : [typeText(schema)];
10
+ }
11
+
12
+ function typeText(schema: JsonSchema): string {
13
+ if ("anyOf" in schema) return schema.anyOf.map(typeText).join(" | ");
14
+ if (!("type" in schema)) return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
15
+ switch (schema.type) {
16
+ case "string":
17
+ if (schema.const !== undefined) return JSON.stringify(schema.const);
18
+ return schema.enum ? schema.enum.map((value) => JSON.stringify(value)).join(" | ") : "string";
19
+ case "number":
20
+ case "integer":
21
+ if (schema.const !== undefined) return String(schema.const);
22
+ return [
23
+ schema.type,
24
+ ...(schema.minimum === undefined ? [] : [`≥ ${schema.minimum}`]),
25
+ ...(schema.maximum === undefined ? [] : [`≤ ${schema.maximum}`]),
26
+ ].join(" ");
27
+ case "boolean":
28
+ return schema.const === undefined ? "boolean" : String(schema.const);
29
+ case "null":
30
+ return "null";
31
+ case "array": {
32
+ const items = typeText(schema.items);
33
+ const list = `${/^[\w"]+$/.test(items) || items.startsWith("{") ? items : `(${items})`}[]`;
34
+ return schema.minItems ? `${list} (${schema.minItems}+)` : list;
35
+ }
36
+ case "object": {
37
+ const required = new Set(schema.required ?? []);
38
+ const fields = Object.entries(schema.properties).map(
39
+ ([key, property]) => `${key}${required.has(key) ? "" : "?"}: ${typeText(property)}`,
40
+ );
41
+ return `{${fields.join(", ")}}`;
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * A value its schema accepts, standing in for one not given while an attempt looks on for the
48
+ * others it needs: the first branch, the least of each bound, required fields only.
49
+ */
50
+ export function placeholderOf(schema: JsonSchema): JsonValue {
51
+ if ("anyOf" in schema) return placeholderOf(schema.anyOf[0]!);
52
+ if (!("type" in schema)) return schema.enum[0]!;
53
+ switch (schema.type) {
54
+ case "string": {
55
+ const least = schema.minLength ?? 0;
56
+ return (
57
+ schema.const ??
58
+ schema.enum?.find((value) => value.length >= least) ??
59
+ schema.enum?.[0] ??
60
+ "x".repeat(least)
61
+ );
62
+ }
63
+ case "number":
64
+ return schema.const ?? schema.minimum ?? schema.maximum ?? 0;
65
+ case "integer":
66
+ return (
67
+ schema.const ??
68
+ (schema.minimum !== undefined
69
+ ? Math.ceil(schema.minimum)
70
+ : schema.maximum !== undefined
71
+ ? Math.floor(schema.maximum)
72
+ : 0)
73
+ );
74
+ case "boolean":
75
+ return schema.const ?? false;
76
+ case "null":
77
+ return null;
78
+ case "array":
79
+ return Array.from({ length: schema.minItems ?? 0 }, () => placeholderOf(schema.items));
80
+ case "object":
81
+ return Object.fromEntries(
82
+ (schema.required ?? []).flatMap((key) => {
83
+ const property = schema.properties[key];
84
+ return [[key, property ? placeholderOf(property) : null]];
85
+ }),
86
+ );
87
+ }
88
+ }
package/src/stopped.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { StageNeed, StageRecord } from "@agentswf/contract/records";
2
+
3
+ /**
4
+ * Ends an attempt `stopped`, apart from `failed`: the workflow's `stop`, or a continue that can't
5
+ * go on as asked. A continue picks it up from there.
6
+ */
7
+ export class WorkflowStopped extends Error {
8
+ constructor(
9
+ readonly reason: string,
10
+ /** The stage it stopped in; absent between stages. */
11
+ readonly stage?: string,
12
+ /** Whether going on takes `--from-stage {stage}`: a record a continue can't reuse. */
13
+ readonly redo = false,
14
+ /** The stages whose values, given with `--values`, let the attempt start at a later one. */
15
+ readonly needs?: readonly StageNeed[],
16
+ ) {
17
+ super(reason);
18
+ this.name = "WorkflowStopped";
19
+ }
20
+ }
21
+
22
+ /** A `--from-stage` the attempt never reached: a typo, or a branch not taken. */
23
+ export class FromStageUnreached extends WorkflowStopped {
24
+ constructor(
25
+ readonly fromStage: string,
26
+ /** The run's stage records, in the order they started: the stages to choose from. */
27
+ readonly recorded: readonly StageRecord[],
28
+ ) {
29
+ super(`never reached ${fromStage}`);
30
+ }
31
+ }
32
+
33
+ /** The failure that ended an attempt: an aggregate's first, ahead of what cleanup added to it. */
34
+ export function primaryFailure(failure: unknown): unknown {
35
+ return failure instanceof AggregateError ? failure.errors[0] : failure;
36
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync } from "node:fs";
2
+ import { constants } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseDuration } from "./duration";
6
+
7
+ const PRELOAD = fileURLToPath(new URL("./workflow-test-preload.ts", import.meta.url));
8
+
9
+ export const testUsage = [
10
+ "usage: awf test [paths...] [-t <pattern>] [--watch] [--timeout <duration>]",
11
+ "",
12
+ "Runs a workflow's tests (*.test.ts and *.spec.ts files) under the current directory, or under",
13
+ "each file or directory given, with agentswf/workflow, agentswf/testing and typebox served as",
14
+ "awf run serves them: nothing is installed. -t runs the tests whose names match the pattern;",
15
+ "--watch reruns them on a change; --timeout bounds each test, 5s by default.",
16
+ "",
17
+ "Exits 0 when every test passed, 1 when one failed or none was found, 2 on a usage error. What",
18
+ "it prints is for reading, not parsing.",
19
+ ].join("\n");
20
+
21
+ export type TestCommand = { paths: string[]; bunArgs: string[] };
22
+
23
+ /**
24
+ * Only these flags pass through, so the published command is awf's, not the whole of `bun test`'s,
25
+ * and a later runner can take its place. A path must exist; Bun would take it as a filter.
26
+ */
27
+ export function parseTestCommand(argv: readonly string[], cwd: string): TestCommand | "help" {
28
+ const paths: string[] = [];
29
+ const bunArgs: string[] = [];
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const option = argv[index]!;
32
+ const value = argv[index + 1];
33
+ if (option === "-h" || option === "--help") return "help";
34
+ if (option === "--watch") {
35
+ bunArgs.push("--watch");
36
+ } else if (option === "-t") {
37
+ if (value === undefined) throw new Error("-t needs a pattern");
38
+ bunArgs.push("-t", value);
39
+ index += 1;
40
+ } else if (option === "--timeout") {
41
+ if (value === undefined) throw new Error("--timeout needs a duration such as 10s");
42
+ bunArgs.push("--timeout", String(parseDuration(value)));
43
+ index += 1;
44
+ } else if (option.startsWith("-")) {
45
+ throw new Error(`unknown option: ${option}`);
46
+ } else {
47
+ const path = resolve(cwd, option);
48
+ if (!existsSync(path)) throw new Error(`no such file or directory: ${option}`);
49
+ paths.push(path);
50
+ }
51
+ }
52
+ return { paths, bunArgs };
53
+ }
54
+
55
+ /**
56
+ * Runs `bun test` in `cwd` with the author surface preloaded, and resolves to its exit code. When
57
+ * `signal` aborts, as on Ctrl-C to awf alone, the tests are stopped with the same signal.
58
+ */
59
+ export async function runWorkflowTests(
60
+ command: TestCommand,
61
+ cwd: string,
62
+ options: {
63
+ signal?: AbortSignal;
64
+ output?: { stdout(text: string): void; stderr(text: string): void };
65
+ } = {},
66
+ ): Promise<number> {
67
+ const { signal, output } = options;
68
+ const child = Bun.spawn(
69
+ [
70
+ process.execPath,
71
+ // As `awf run`: a `.env` can hold a token that changes how every agent logs in.
72
+ "--no-env-file",
73
+ "test",
74
+ "--preload",
75
+ PRELOAD,
76
+ ...command.bunArgs,
77
+ ...command.paths,
78
+ ],
79
+ {
80
+ cwd,
81
+ stdin: "inherit",
82
+ stdout: output ? "pipe" : "inherit",
83
+ stderr: output ? "pipe" : "inherit",
84
+ },
85
+ );
86
+ const stop = () =>
87
+ child.kill(
88
+ typeof signal?.reason === "string" && signal.reason in constants.signals
89
+ ? (signal.reason as NodeJS.Signals)
90
+ : "SIGTERM",
91
+ );
92
+ if (signal?.aborted) stop();
93
+ signal?.addEventListener("abort", stop, { once: true });
94
+ try {
95
+ if (output) {
96
+ const [out, err] = await Promise.all([
97
+ new Response(child.stdout as ReadableStream).text(),
98
+ new Response(child.stderr as ReadableStream).text(),
99
+ ]);
100
+ if (out) output.stdout(out.trimEnd());
101
+ if (err) output.stderr(err.trimEnd());
102
+ }
103
+ const code = await child.exited;
104
+ // Ended by a signal, it exits as a shell reports one: 128 and the signal's number.
105
+ const ended = child.signalCode as keyof typeof constants.signals | null;
106
+ return ended ? 128 + constants.signals[ended] : code;
107
+ } finally {
108
+ signal?.removeEventListener("abort", stop);
109
+ }
110
+ }
@@ -0,0 +1,89 @@
1
+ import { open } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ OPERATION_LIVENESS_KINDS,
5
+ OPERATION_LIVENESS_RECORD_VERSION,
6
+ type OperationLivenessRecord,
7
+ } from "@agentswf/contract/records";
8
+
9
+ type OperationEvent = Pick<
10
+ OperationLivenessRecord,
11
+ "kind" | "at" | "sequence" | "reason" | "until" | "peer"
12
+ >;
13
+ const MAX_BYTES = 1024 * 1024;
14
+ function eventFile(runDir: string, operationId: string): string {
15
+ return join(runDir, "calls", operationId, "liveness.jsonl");
16
+ }
17
+
18
+ export async function readOperationEvents(
19
+ runDir: string,
20
+ operationId: string,
21
+ ): Promise<{
22
+ records: OperationLivenessRecord[];
23
+ incomplete: boolean;
24
+ }> {
25
+ let text: string;
26
+ let clipped = false;
27
+ try {
28
+ const file = await open(eventFile(runDir, operationId), "r");
29
+ try {
30
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
31
+ let size = 0;
32
+ while (size < buffer.length) {
33
+ const read = await file.read(buffer, size, buffer.length - size, null);
34
+ if (!read.bytesRead) break;
35
+ size += read.bytesRead;
36
+ }
37
+ clipped = size > MAX_BYTES;
38
+ text = buffer.subarray(0, Math.min(size, MAX_BYTES)).toString("utf8");
39
+ } finally {
40
+ await file.close();
41
+ }
42
+ } catch {
43
+ return { records: [], incomplete: true };
44
+ }
45
+ let incomplete = clipped || !text.endsWith("\n");
46
+ const records: OperationLivenessRecord[] = [];
47
+ const rows = text.split("\n");
48
+ rows.pop(); // An unterminated row is not a committed diagnostic frame.
49
+ for (const row of rows) {
50
+ try {
51
+ const value: unknown = JSON.parse(row);
52
+ if (!isRecord(value, operationId)) {
53
+ incomplete = true;
54
+ continue;
55
+ }
56
+ records.push(value);
57
+ if (value.dropped || value.truncated) incomplete = true;
58
+ } catch {
59
+ incomplete = true;
60
+ }
61
+ }
62
+ if (records.at(-1)?.kind !== "terminal") incomplete = true;
63
+ return { records, incomplete };
64
+ }
65
+
66
+ function validEvent(value: OperationEvent): boolean {
67
+ return (
68
+ typeof value.kind === "string" &&
69
+ (OPERATION_LIVENESS_KINDS as readonly string[]).includes(value.kind) &&
70
+ Number.isSafeInteger(value.at) &&
71
+ value.at >= 0 &&
72
+ Number.isSafeInteger(value.sequence) &&
73
+ value.sequence >= 0 &&
74
+ (value.reason === undefined || typeof value.reason === "string") &&
75
+ (value.until === undefined || (Number.isSafeInteger(value.until) && value.until >= 0)) &&
76
+ (value.peer === undefined || (typeof value.peer === "string" && value.peer.trim() !== ""))
77
+ );
78
+ }
79
+ function isRecord(value: unknown, operationId: string): value is OperationLivenessRecord {
80
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
81
+ const item = value as OperationLivenessRecord;
82
+ return (
83
+ item.version === OPERATION_LIVENESS_RECORD_VERSION &&
84
+ item.operationId === operationId &&
85
+ validEvent(item) &&
86
+ (item.dropped === undefined || (Number.isSafeInteger(item.dropped) && item.dropped >= 0)) &&
87
+ (item.truncated === undefined || item.truncated === true)
88
+ );
89
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { createConnection } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type ResultSubmitResponse, WIRE_VERSION } from "@agentswf/contract/wire";
6
+ import type { JsonValue, WorkflowContext, WorkflowDefinition } from "@agentswf/contract/workflow";
7
+ import { createSingleSessionHostFactory } from "@agentswf/harness";
8
+ import type { AgentSessionAdapter } from "@agentswf/harness/adapter";
9
+ import { createFakeAdapter } from "@agentswf/harness/testing";
10
+ import Type from "typebox";
11
+ import { OPERATOR_ALIASES } from "./operator-aliases";
12
+ import { createRun, openRun } from "./runs";
13
+ import { type RunWorkflowOptions, runWorkflow, startWorkflow } from "./workflow-runner";
14
+
15
+ export function createTempRunDirs(): {
16
+ tempRunDir(): string;
17
+ cleanup(): void;
18
+ } {
19
+ const paths = new Set<string>();
20
+ return {
21
+ tempRunDir() {
22
+ const path = mkdtempSync(join(tmpdir(), "wf-"));
23
+ paths.add(path);
24
+ return path;
25
+ },
26
+ cleanup() {
27
+ for (const path of paths) rmSync(path, { recursive: true, force: true });
28
+ paths.clear();
29
+ },
30
+ };
31
+ }
32
+
33
+ export { COUNT_SCHEMA } from "@agentswf/contract/testing";
34
+
35
+ export function future(milliseconds = 60_000): { unixMilliseconds: number } {
36
+ return { unixMilliseconds: Date.now() + milliseconds };
37
+ }
38
+
39
+ /** Answers a call the way an agent's `wf` does: one framed request over its own socket. */
40
+ export async function submit(
41
+ binding: { endpoint: string; operationId: string },
42
+ value: unknown,
43
+ session?: string,
44
+ ): Promise<ResultSubmitResponse> {
45
+ const response = await exchange(
46
+ binding.endpoint,
47
+ `${JSON.stringify({
48
+ version: WIRE_VERSION,
49
+ command: "result",
50
+ operationId: binding.operationId,
51
+ raw: JSON.stringify(value),
52
+ ...(session ? { session } : {}),
53
+ })}\n`,
54
+ );
55
+ return JSON.parse(response) as ResultSubmitResponse;
56
+ }
57
+
58
+ export function exchange(endpoint: string, frame: string): Promise<string> {
59
+ return new Promise((resolve, reject) => {
60
+ const socket = createConnection(endpoint);
61
+ const chunks: Buffer[] = [];
62
+ socket.once("connect", () => socket.end(frame));
63
+ socket.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
64
+ socket.once("end", () => resolve(Buffer.concat(chunks).toString("utf8").trim()));
65
+ socket.once("error", reject);
66
+ });
67
+ }
68
+
69
+ export const DOC = Type.Object({ path: Type.String() }, { additionalProperties: false });
70
+
71
+ /** A workflow of no arguments, named `staged` unless `meta` names it. */
72
+ export function workflowOf<Result extends JsonValue>(
73
+ run: (workflow: WorkflowContext) => Promise<Result>,
74
+ meta: { name?: string; version?: string } = {},
75
+ ): WorkflowDefinition<null, Result> {
76
+ const { name = "staged", version } = meta;
77
+ return {
78
+ meta: { name, description: name, ...(version === undefined ? {} : { version }) },
79
+ run: (workflow) => run(workflow),
80
+ };
81
+ }
82
+
83
+ /** Answers every turn with a `DOC`, and every compaction with a summary. */
84
+ export function answering(): AgentSessionAdapter {
85
+ return createFakeAdapter({
86
+ harnesses: ["codex"],
87
+ script: (context) =>
88
+ context.kind === "compact"
89
+ ? { summary: "kept" }
90
+ : {
91
+ act: async () => {
92
+ await submit(context.binding!, { path: "x" });
93
+ },
94
+ },
95
+ });
96
+ }
97
+
98
+ /**
99
+ * One attempt of run r1 of `workflow` under `runRoot`, created by the first, its agents answered by
100
+ * `answering()`. Its folder is `{runRoot}/{workflow}/r1`.
101
+ */
102
+ export async function runAttempt<Result extends JsonValue>(
103
+ workflow: WorkflowDefinition<null, Result>,
104
+ options: {
105
+ runRoot: string;
106
+ attempt?: number;
107
+ fromStage?: string;
108
+ values?: Record<string, JsonValue>;
109
+ adapter?: AgentSessionAdapter;
110
+ signal?: AbortSignal;
111
+ },
112
+ ) {
113
+ const { runRoot, attempt = 1, fromStage, values, adapter = answering(), signal } = options;
114
+ const name = workflow.meta.name;
115
+ const run = existsSync(join(runRoot, name, "r1"))
116
+ ? await openRun(runRoot, name, "r1")
117
+ : await createRun(runRoot, {
118
+ id: "r1",
119
+ workflow: name,
120
+ argv: [],
121
+ cwd: process.cwd(),
122
+ sandbox: null,
123
+ });
124
+ return runWorkflow(workflow, null, {
125
+ runRoot,
126
+ run: {
127
+ dir: run.dir,
128
+ id: run.record.id,
129
+ attempt,
130
+ ...(fromStage === undefined ? {} : { fromStage }),
131
+ ...(values === undefined ? {} : { values: new Map(Object.entries(values)) }),
132
+ },
133
+ runtime: { aliases: OPERATOR_ALIASES, host: createSingleSessionHostFactory(adapter) },
134
+ deadline: future(),
135
+ ...(signal === undefined ? {} : { signal }),
136
+ });
137
+ }
138
+
139
+ /** `startWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
140
+ export async function startNew<Args extends JsonValue, Result extends JsonValue>(
141
+ definition: WorkflowDefinition<Args, Result>,
142
+ args: Args,
143
+ options: Omit<RunWorkflowOptions, "run">,
144
+ ) {
145
+ const run = await createRun(options.runRoot, {
146
+ workflow: definition.meta.name,
147
+ argv: [],
148
+ cwd: options.cwd ?? process.cwd(),
149
+ sandbox: null,
150
+ });
151
+ return startWorkflow(definition, args, {
152
+ ...options,
153
+ run: { dir: run.dir, id: run.record.id, attempt: 1 },
154
+ });
155
+ }
156
+
157
+ /** `runWorkflow` as the first attempt of a new run of the workflow under `options.runRoot`. */
158
+ export async function runNew<Args extends JsonValue, Result extends JsonValue>(
159
+ definition: WorkflowDefinition<Args, Result>,
160
+ args: Args,
161
+ options: Omit<RunWorkflowOptions, "run">,
162
+ ) {
163
+ return (await startNew(definition, args, options)).result;
164
+ }
@@ -0,0 +1,113 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import * as workflowSurface from "@agentswf/contract/workflow";
5
+ import {
6
+ EXECUTABLE_WORKFLOW_KIND,
7
+ type ExecutableWorkflow,
8
+ isJsonValue,
9
+ type JsonValue,
10
+ } from "@agentswf/contract/workflow";
11
+ import { plugin } from "bun";
12
+ import * as typebox from "typebox";
13
+ import * as typeboxValue from "typebox/value";
14
+ import { idProblem } from "./runs";
15
+
16
+ /**
17
+ * What a workflow in any folder imports without installing it, served from the engine's own
18
+ * copies, so its schemas are the ones results are checked with. Part of the author surface:
19
+ * adding a name is cheap, removing one breaks workflows.
20
+ */
21
+ const AUTHOR_SURFACE: Record<string, Record<string, unknown>> = {
22
+ "agentswf/workflow": workflowSurface,
23
+ typebox,
24
+ "typebox/value": typeboxValue,
25
+ };
26
+
27
+ let surfaceServed = false;
28
+
29
+ /** Serves the author surface to whatever this process imports next. */
30
+ export function serveAuthorSurface(): void {
31
+ if (surfaceServed) return;
32
+ surfaceServed = true;
33
+ plugin({
34
+ name: "agentswf author surface",
35
+ setup(build) {
36
+ for (const [name, exports] of Object.entries(AUTHOR_SURFACE)) {
37
+ build.module(name, () => ({ exports, loader: "object" }));
38
+ }
39
+ },
40
+ });
41
+ }
42
+
43
+ const WORKFLOW_EXTENSIONS = new Set([".ts", ".mts", ".js", ".mjs"]);
44
+
45
+ export type LoadedWorkflow = {
46
+ file: string;
47
+ executable: ExecutableWorkflow<JsonValue, JsonValue>;
48
+ };
49
+
50
+ export async function loadWorkflowFile(file: string, cwd: string): Promise<LoadedWorkflow> {
51
+ if (file.includes("://") || file.startsWith("file:")) {
52
+ throw new Error("workflow must be an explicit local file path");
53
+ }
54
+ const absolute = resolve(cwd, file);
55
+ if (!WORKFLOW_EXTENSIONS.has(extname(absolute))) {
56
+ throw new Error("workflow file must end in .ts, .mts, .js, or .mjs");
57
+ }
58
+ let info: Awaited<ReturnType<typeof stat>>;
59
+ try {
60
+ info = await stat(absolute);
61
+ } catch {
62
+ throw new Error(`workflow file not found: ${absolute}`);
63
+ }
64
+ if (!info.isFile()) throw new Error(`workflow path is not a file: ${absolute}`);
65
+
66
+ serveAuthorSurface();
67
+ const namespace: unknown = await import(pathToFileURL(absolute).href);
68
+ const exported = record(namespace)?.default;
69
+ if (!isExecutableWorkflow(exported)) {
70
+ throw new Error(`default export must be an ${EXECUTABLE_WORKFLOW_KIND} executable workflow`);
71
+ }
72
+ const { name, version } = exported.definition.meta;
73
+ // The name is a folder its runs are kept in, and the version's major decides which records a
74
+ // continue may reuse.
75
+ const problem = idProblem(name);
76
+ if (problem) throw new Error(`meta.name: ${problem}`);
77
+ if (version !== undefined && (typeof version !== "string" || !SEMVER.test(version))) {
78
+ throw new Error(
79
+ `meta.version: ${JSON.stringify(version)} is not a semver version such as 1.2.0`,
80
+ );
81
+ }
82
+ return { file: absolute, executable: exported };
83
+ }
84
+
85
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
86
+
87
+ export function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
88
+ if (!isJsonValue(value)) throw new Error(`${label} must contain only JSON values`);
89
+ }
90
+
91
+ function isExecutableWorkflow(value: unknown): value is ExecutableWorkflow<JsonValue, JsonValue> {
92
+ const executable = record(value);
93
+ const definition = record(executable?.definition);
94
+ const meta = record(definition?.meta);
95
+ return (
96
+ executable?.kind === EXECUTABLE_WORKFLOW_KIND &&
97
+ typeof executable.prepare === "function" &&
98
+ (executable.present === undefined || typeof executable.present === "function") &&
99
+ (executable.report === undefined || typeof executable.report === "function") &&
100
+ (executable.id === undefined || typeof executable.id === "function") &&
101
+ typeof definition?.run === "function" &&
102
+ typeof meta?.name === "string" &&
103
+ meta.name.trim() !== "" &&
104
+ typeof meta.description === "string" &&
105
+ meta.description.trim() !== ""
106
+ );
107
+ }
108
+
109
+ function record(value: unknown): Record<string, unknown> | undefined {
110
+ return value !== null && typeof value === "object"
111
+ ? (value as Record<string, unknown>)
112
+ : undefined;
113
+ }