programmatic subagents
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(s6): Guardrail + ast-grep Boundary Enforcement

+2987 -210
+5 -1
.ast-grep/rules/no-any.yml
··· 4 4 message: Avoid `any`; use precise types. 5 5 rule: 6 6 any: 7 - - pattern: "$A: any" 7 + - pattern: "const $A: any = $B" 8 + - pattern: "let $A: any = $B" 9 + - pattern: "var $A: any = $B" 10 + - pattern: "type $A = any" 11 + - pattern: "$A as any" 8 12 - pattern: "<any>$A"
+9 -4
.ast-grep/rules/no-node-imports.yml
··· 4 4 message: "Direct node: imports are disallowed in application source modules." 5 5 files: 6 6 - "**/src/**/*.ts" 7 + ignores: 8 + - "**/*.test.ts" 9 + - "**/*.spec.ts" 7 10 rule: 8 11 any: 9 - - pattern: "import $A from 'node:$B'" 10 - - pattern: 'import $A from "node:$B"' 11 - - pattern: "import { $$$A } from 'node:$B'" 12 - - pattern: 'import { $$$A } from "node:$B"' 12 + - pattern: 'import $$$A from "$MODULE"' 13 + - pattern: "import '$MODULE'" 14 + - pattern: 'import "$MODULE"' 15 + constraints: 16 + MODULE: 17 + regex: "^node:"
+1
.ast-grep/rules/no-process-env-outside-config.yml
··· 9 9 any: 10 10 - pattern: "process.env.$A" 11 11 - pattern: "process.env[$A]" 12 + - pattern: "const { $$$A } = process.env"
+4
.ast-grep/rules/no-promise-outside-public.yml
··· 9 9 rule: 10 10 any: 11 11 - pattern: "Promise<$T>" 12 + - pattern: "$A: Promise<$T>" 13 + - pattern: "type $NAME = ($$$ARGS) => Promise<$T>" 14 + - pattern: "($$$ARGS) => Promise<$T>" 15 + - pattern: "new Promise<$T>($A)" 12 16 - pattern: "new Promise($A)"
+11 -6
.ast-grep/rules/no-public-import-internal.yml
··· 4 4 message: Public modules must not import private internal/runtime/domain modules directly. 5 5 files: 6 6 - "**/src/public/**/*.ts" 7 + ignores: 8 + - "**/*.test.ts" 7 9 rule: 8 10 any: 9 - - pattern: "import $A from '../internal/$B'" 10 - - pattern: "import $A from '../runtime/$B'" 11 - - pattern: "import $A from '../domain/$B'" 12 - - pattern: "import { $$$A } from '../internal/$B'" 13 - - pattern: "import { $$$A } from '../runtime/$B'" 14 - - pattern: "import { $$$A } from '../domain/$B'" 11 + - pattern: 'import $$$A from "$MODULE"' 12 + - pattern: "import '$MODULE'" 13 + - pattern: 'import "$MODULE"' 14 + - pattern: 'export * from "$MODULE"' 15 + - pattern: 'export { $$$A } from "$MODULE"' 16 + - pattern: 'export type { $$$A } from "$MODULE"' 17 + constraints: 18 + MODULE: 19 + regex: "(^|/)(internal|runtime|domain)/"
+3 -1
.ast-grep/rules/no-raw-promise.yml
··· 3 3 severity: error 4 4 message: Raw Promise construction is disallowed; use Effect abstractions. 5 5 rule: 6 - pattern: "new Promise($A)" 6 + any: 7 + - pattern: "new Promise<$T>($A)" 8 + - pattern: "new Promise($A)"
+4
.ast-grep/rules/no-shell-string-command.yml
··· 6 6 any: 7 7 - pattern: "Command.make('sh', '-lc', $CMD)" 8 8 - pattern: 'Command.make("sh", "-lc", $CMD)' 9 + - pattern: "Command.make('sh', '-c', $CMD)" 10 + - pattern: 'Command.make("sh", "-c", $CMD)' 9 11 - pattern: "Command.make('bash', '-lc', $CMD)" 10 12 - pattern: 'Command.make("bash", "-lc", $CMD)' 13 + - pattern: "Command.make('bash', '-c', $CMD)" 14 + - pattern: 'Command.make("bash", "-c", $CMD)'
+9
.ast-grep/tests/no-any.test.yml
··· 1 + id: no-any 2 + valid: 3 + - | 4 + const count: number = 1; 5 + invalid: 6 + - | 7 + const payload: any = input; 8 + - | 9 + const value = <any>source;
+7
.ast-grep/tests/no-as-unknown-as.test.yml
··· 1 + id: no-as-unknown-as 2 + valid: 3 + - | 4 + const value = source as string; 5 + invalid: 6 + - | 7 + const forced = source as unknown as number;
+7
.ast-grep/tests/no-bun-globals.test.yml
··· 1 + id: no-bun-globals 2 + valid: 3 + - | 4 + const runtime = platformRuntime; 5 + invalid: 6 + - | 7 + const processRef = Bun.spawn(["echo", "ok"]);
+7
.ast-grep/tests/no-date-now-outside-clock.test.yml
··· 1 + id: no-date-now-outside-clock 2 + valid: 3 + - | 4 + const now = clockNow; 5 + invalid: 6 + - | 7 + const now = Date.now();
+7
.ast-grep/tests/no-dot-then.test.yml
··· 1 + id: no-dot-then 2 + valid: 3 + - | 4 + const value = await Promise.resolve(1); 5 + invalid: 6 + - | 7 + const next = Promise.resolve(1).then((value) => value + 1);
+7
.ast-grep/tests/no-dynamic-import.test.yml
··· 1 + id: no-dynamic-import 2 + valid: 3 + - | 4 + import { run } from "./run"; 5 + invalid: 6 + - | 7 + const runtime = await import("./runtime");
+9
.ast-grep/tests/no-effect-runpromise.test.yml
··· 1 + id: no-effect-runpromise 2 + valid: 3 + - | 4 + const run = Runtime.runPromise(runtime)(effect); 5 + invalid: 6 + - | 7 + const output = await Effect.runPromise(effect); 8 + - | 9 + const exit = await Effect.runPromiseExit(effect);
+11
.ast-grep/tests/no-interface-for-domain-models.test.yml
··· 1 + id: no-interface-for-domain-models 2 + valid: 3 + - | 4 + type RunRecord = { 5 + id: string; 6 + }; 7 + invalid: 8 + - | 9 + interface RunRecord { 10 + id: string; 11 + }
+11
.ast-grep/tests/no-interface-outside-public.test.yml
··· 1 + id: no-interface-outside-public 2 + valid: 3 + - | 4 + type Capability = { 5 + run: () => number; 6 + }; 7 + invalid: 8 + - | 9 + interface Capability { 10 + run(): number; 11 + }
+7
.ast-grep/tests/no-json-parse-outside-codec.test.yml
··· 1 + id: no-json-parse-outside-codec 2 + valid: 3 + - | 4 + const payload = decodedPayload; 5 + invalid: 6 + - | 7 + const payload = JSON.parse(raw);
+7
.ast-grep/tests/no-math-random-outside-random.test.yml
··· 1 + id: no-math-random-outside-random 2 + valid: 3 + - | 4 + const seed = randomValue; 5 + invalid: 6 + - | 7 + const seed = Math.random();
+7
.ast-grep/tests/no-node-imports.test.yml
··· 1 + id: no-node-imports 2 + valid: 3 + - | 4 + import { Effect } from "effect"; 5 + invalid: 6 + - | 7 + import { readFile } from "node:fs/promises";
+9
.ast-grep/tests/no-process-env-outside-config.test.yml
··· 1 + id: no-process-env-outside-config 2 + valid: 3 + - | 4 + const homeDirectory = resolved.homeDirectory; 5 + invalid: 6 + - | 7 + const homeDirectory = process.env.HOME; 8 + - | 9 + const { HOME } = process.env;
+11
.ast-grep/tests/no-promise-outside-public.test.yml
··· 1 + id: no-promise-outside-public 2 + valid: 3 + - | 4 + const load = () => Effect.succeed("ok"); 5 + invalid: 6 + - | 7 + type Loader = () => Promise<string>; 8 + - | 9 + const deferred = new Promise<string>((resolve) => { 10 + resolve("ok"); 11 + });
+13
.ast-grep/tests/no-public-import-internal.test.yml
··· 1 + id: no-public-import-internal 2 + valid: 3 + - | 4 + import { createMill } from "../public/mill.api"; 5 + invalid: 6 + - | 7 + import { makeEngine } from "../../internal/engine.effect"; 8 + - | 9 + import type { RunId } from "../domain/run.schema"; 10 + - | 11 + export { makeEngine } from "../internal/engine.effect"; 12 + - | 13 + export * from "../runtime/worker.effect";
+9
.ast-grep/tests/no-raw-promise.test.yml
··· 1 + id: no-raw-promise 2 + valid: 3 + - | 4 + const result = Effect.succeed("ok"); 5 + invalid: 6 + - | 7 + const deferred = new Promise<number>((resolve) => { 8 + resolve(1); 9 + });
+9
.ast-grep/tests/no-runtime-runpromise-outside-boundary.test.yml
··· 1 + id: no-runtime-runpromise-outside-boundary 2 + valid: 3 + - | 4 + const output = effect; 5 + invalid: 6 + - | 7 + const output = Runtime.runPromise(runtime)(effect); 8 + - | 9 + const output = Runtime.runPromiseExit(runtime)(effect);
+9
.ast-grep/tests/no-shell-string-command.test.yml
··· 1 + id: no-shell-string-command 2 + valid: 3 + - | 4 + const command = Command.make("pi", "-p", payload); 5 + invalid: 6 + - | 7 + const command = Command.make("bash", "-c", "echo unsafe"); 8 + - | 9 + const command = Command.make("bash", "-lc", "echo unsafe");
+7
.ast-grep/tests/no-stub-functions.test.yml
··· 1 + id: no-stub-functions 2 + valid: 3 + - | 4 + export const ready = true; 5 + invalid: 6 + - | 7 + throw new Error("Not implemented");
+11
.ast-grep/tests/no-try-catch.test.yml
··· 1 + id: no-try-catch 2 + valid: 3 + - | 4 + const value = Effect.match(effect, { onFailure: () => 0, onSuccess: (v) => v }); 5 + invalid: 6 + - | 7 + try { 8 + run(); 9 + } catch (error) { 10 + report(error); 11 + }
+2
bun.lock
··· 25 25 }, 26 26 "dependencies": { 27 27 "@mill/core": "workspace:*", 28 + "@mill/driver-claude": "workspace:*", 29 + "@mill/driver-codex": "workspace:*", 28 30 "@mill/driver-pi": "workspace:*", 29 31 }, 30 32 },
+10
docs/exec-plans/active/vertical-slices.md
··· 247 247 - `bun test scripts` 248 248 - `bun test` 249 249 250 + **Status (2026-02-23)** 251 + 252 + - ✅ Expanded `.ast-grep/tests/` with rule-level positive/negative fixtures for the full SPEC §19 rule set. 253 + - ✅ Hardened boundary and runtime-safety rule coverage (`no-public-import-internal`, `no-node-imports`, `no-shell-string-command`, Promise/`any` guards) to satisfy missing fixture cases. 254 + - ✅ Added Bun-test guardrail integration harness (`scripts/guardrail-harness.ts` + `scripts/guardrail-harness.test.ts`) that executes repository guardrail checks and validates failing fixture scans for boundary/runtime violations. 255 + - ✅ Refactored `scripts/check-exports.ts` into a testable workspace-wide checker (`collectWorkspacePackageJsonPaths`, `checkExportBoundaries`, `isInternalExportPath`, CLI `runCheck`). 256 + - ✅ Added `scripts/check-exports.test.ts` coverage for workspace glob discovery and internal export-path detection across conditional/array `exports` forms. 257 + - ✅ Re-ran `bun run lint:ast-grep:test`, `bun test scripts`, and full `bun test` with green results. 258 + - ✅ Follow-up hardening pass: `no-public-import-internal` now catches re-export forms (`export ... from`), `no-process-env-outside-config` now catches destructured `process.env`, and export-boundary checks now reject internal/runtime/domain **export keys** in addition to export target values. 259 + 250 260 --- 251 261 252 262 ## S7 — Final Hardening: inspect/session/cancel/watch Semantics (Dedicated Final Slice)
+2
packages/cli/package.json
··· 10 10 }, 11 11 "dependencies": { 12 12 "@mill/core": "workspace:*", 13 + "@mill/driver-claude": "workspace:*", 14 + "@mill/driver-codex": "workspace:*", 13 15 "@mill/driver-pi": "workspace:*" 14 16 } 15 17 }
+233 -15
packages/cli/src/public/index.api.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 - import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; 2 + import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; 3 3 import { tmpdir } from "node:os"; 4 4 import { join } from "node:path"; 5 5 import * as Schema from "@effect/schema/Schema"; ··· 37 37 run: Schema.Struct({ 38 38 id: Schema.String, 39 39 status: Schema.String, 40 + driver: Schema.String, 41 + executor: Schema.String, 40 42 paths: Schema.Struct({ 41 43 runDir: Schema.String, 42 44 runFile: Schema.String, ··· 61 63 }), 62 64 ); 63 65 66 + const RunSubmitEnvelope = Schema.parseJson( 67 + Schema.Struct({ 68 + runId: Schema.String, 69 + status: Schema.Union(Schema.Literal("pending"), Schema.Literal("running")), 70 + paths: Schema.Struct({ 71 + runDir: Schema.String, 72 + runFile: Schema.String, 73 + eventsFile: Schema.String, 74 + resultFile: Schema.String, 75 + }), 76 + }), 77 + ); 78 + 64 79 const StatusEnvelope = Schema.parseJson( 65 80 Schema.Struct({ 66 81 id: Schema.String, 67 82 status: Schema.String, 83 + driver: Schema.String, 84 + executor: Schema.String, 68 85 paths: Schema.Struct({ 69 86 runDir: Schema.String, 70 87 runFile: Schema.String, ··· 116 133 "anthropic/claude-sonnet-4-6", 117 134 ]); 118 135 expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 136 + expect(payload.drivers.codex?.models).toEqual(["openai/gpt-5.3-codex"]); 119 137 }); 120 138 121 139 it("routes human help text to stdout in non-json mode", async () => { ··· 184 202 185 203 const runPayload = Schema.decodeUnknownSync(RunSyncEnvelope)(runStdout[0]); 186 204 expect(runPayload.run.status).toBe("complete"); 205 + expect(runPayload.run.driver).toBe("default"); 206 + expect(runPayload.run.executor).toBe("direct"); 187 207 expect(runPayload.result.status).toBe("complete"); 188 208 expect(runPayload.result.spawns).toHaveLength(1); 189 209 ··· 211 231 const statusPayload = Schema.decodeUnknownSync(StatusEnvelope)(statusStdout[0]); 212 232 expect(statusPayload.id).toBe(runPayload.run.id); 213 233 expect(statusPayload.status).toBe("complete"); 234 + expect(statusPayload.driver).toBe("default"); 235 + expect(statusPayload.executor).toBe("direct"); 236 + } finally { 237 + await rm(tempDirectory, { recursive: true, force: true }); 238 + } 239 + }); 240 + 241 + it("honors explicit --driver and --executor overrides for run --sync", async () => { 242 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-override-run-")); 243 + const homeDirectory = join(tempDirectory, "home"); 244 + const programPath = join(tempDirectory, "program.ts"); 245 + 246 + await writeFile( 247 + programPath, 248 + [ 249 + "const scan = await mill.spawn({", 250 + ' agent: "scout",', 251 + ' systemPrompt: "You are concise.",', 252 + ' prompt: "Say hello",', 253 + "});", 254 + "return scan.text;", 255 + ].join("\n"), 256 + "utf-8", 257 + ); 258 + 259 + try { 260 + const runStdout: Array<string> = []; 261 + const runCode = await runCli( 262 + ["run", programPath, "--sync", "--json", "--driver", "codex", "--executor", "vm"], 263 + { 264 + cwd: tempDirectory, 265 + homeDirectory, 266 + pathExists: async () => false, 267 + io: { 268 + stdout: (line) => { 269 + runStdout.push(line); 270 + }, 271 + stderr: () => undefined, 272 + }, 273 + }, 274 + ); 275 + 276 + expect(runCode).toBe(0); 277 + 278 + const payload = Schema.decodeUnknownSync(RunSyncEnvelope)(runStdout[0]); 279 + expect(payload.run.driver).toBe("codex"); 280 + expect(payload.run.executor).toBe("vm"); 281 + expect(payload.result.spawns[0]?.driver).toBe("codex"); 282 + } finally { 283 + await rm(tempDirectory, { recursive: true, force: true }); 284 + } 285 + }); 286 + 287 + it("uses resolved config defaults for driver/executor when flags are omitted", async () => { 288 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-config-defaults-")); 289 + const homeDirectory = join(tempDirectory, "home"); 290 + const programPath = join(tempDirectory, "program.ts"); 291 + 292 + await writeFile( 293 + programPath, 294 + [ 295 + "const scan = await mill.spawn({", 296 + ' agent: "scout",', 297 + ' systemPrompt: "You are concise.",', 298 + ' prompt: "Say hello",', 299 + "});", 300 + "return scan.text;", 301 + ].join("\n"), 302 + "utf-8", 303 + ); 304 + 305 + try { 306 + const runStdout: Array<string> = []; 307 + const runCode = await runCli(["run", programPath, "--sync", "--json"], { 308 + cwd: tempDirectory, 309 + homeDirectory, 310 + pathExists: async (path) => path === join(tempDirectory, "mill.config.ts"), 311 + loadConfigOverrides: async () => ({ 312 + defaultDriver: "claude", 313 + defaultExecutor: "vm", 314 + }), 315 + io: { 316 + stdout: (line) => { 317 + runStdout.push(line); 318 + }, 319 + stderr: () => undefined, 320 + }, 321 + }); 322 + 323 + expect(runCode).toBe(0); 324 + 325 + const payload = Schema.decodeUnknownSync(RunSyncEnvelope)(runStdout[0]); 326 + expect(payload.run.driver).toBe("claude"); 327 + expect(payload.run.executor).toBe("vm"); 328 + expect(payload.result.spawns[0]?.driver).toBe("claude"); 329 + } finally { 330 + await rm(tempDirectory, { recursive: true, force: true }); 331 + } 332 + }); 333 + 334 + it("submits run asynchronously by default and writes worker artifacts", async () => { 335 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-async-run-")); 336 + const homeDirectory = join(tempDirectory, "home"); 337 + const programPath = join(tempDirectory, "program.ts"); 338 + 339 + await writeFile( 340 + programPath, 341 + [ 342 + "const scan = await mill.spawn({", 343 + ' agent: "scout",', 344 + ' systemPrompt: "You are concise.",', 345 + ' prompt: "Say hello",', 346 + "});", 347 + "globalThis.__millAsyncText = scan.text;", 348 + ].join("\n"), 349 + "utf-8", 350 + ); 351 + 352 + try { 353 + const runStdout: Array<string> = []; 354 + const runStderr: Array<string> = []; 355 + 356 + const runCode = await runCli(["run", programPath, "--json"], { 357 + cwd: tempDirectory, 358 + homeDirectory, 359 + pathExists: async () => false, 360 + io: { 361 + stdout: (line) => { 362 + runStdout.push(line); 363 + }, 364 + stderr: (line) => { 365 + runStderr.push(line); 366 + }, 367 + }, 368 + }); 369 + 370 + expect(runCode).toBe(0); 371 + expect(runStderr).toHaveLength(0); 372 + expect(runStdout).toHaveLength(1); 373 + 374 + const submittedRun = Schema.decodeUnknownSync(RunSubmitEnvelope)(runStdout[0]); 375 + expect(submittedRun.runId.length).toBeGreaterThan(0); 376 + 377 + const waitStdout: Array<string> = []; 378 + const waitCode = await runCli(["wait", submittedRun.runId, "--timeout", "5", "--json"], { 379 + cwd: tempDirectory, 380 + homeDirectory, 381 + pathExists: async () => false, 382 + io: { 383 + stdout: (line) => { 384 + waitStdout.push(line); 385 + }, 386 + stderr: () => undefined, 387 + }, 388 + }); 389 + 390 + expect(waitCode).toBe(0); 391 + const waitedRun = Schema.decodeUnknownSync(StatusEnvelope)(waitStdout[0]); 392 + expect(waitedRun.status).toBe("complete"); 393 + 394 + const copiedProgram = await readFile(join(submittedRun.paths.runDir, "program.ts"), "utf-8"); 395 + const workerLog = await readFile( 396 + join(submittedRun.paths.runDir, "logs", "worker.log"), 397 + "utf-8", 398 + ); 399 + 400 + expect(copiedProgram).toContain("mill.spawn"); 401 + expect(workerLog.length).toBeGreaterThan(0); 214 402 } finally { 215 403 await rm(tempDirectory, { recursive: true, force: true }); 216 404 } ··· 253 441 254 442 const waitJsonStdout: Array<string> = []; 255 443 const waitJsonStderr: Array<string> = []; 256 - const waitJsonCode = await runCli( 257 - ["wait", runPayload.run.id, "--timeout", "2", "--json"], 258 - { 259 - cwd: tempDirectory, 260 - homeDirectory, 261 - pathExists: async () => false, 262 - io: { 263 - stdout: (line) => { 264 - waitJsonStdout.push(line); 265 - }, 266 - stderr: (line) => { 267 - waitJsonStderr.push(line); 268 - }, 444 + const waitJsonCode = await runCli(["wait", runPayload.run.id, "--timeout", "2", "--json"], { 445 + cwd: tempDirectory, 446 + homeDirectory, 447 + pathExists: async () => false, 448 + io: { 449 + stdout: (line) => { 450 + waitJsonStdout.push(line); 451 + }, 452 + stderr: (line) => { 453 + waitJsonStderr.push(line); 269 454 }, 270 455 }, 271 - ); 456 + }); 272 457 273 458 expect(waitJsonCode).toBe(0); 274 459 expect(waitJsonStderr).toHaveLength(0); ··· 301 486 } 302 487 }); 303 488 489 + it("creates init skeleton in cwd", async () => { 490 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-init-")); 491 + const stdout: Array<string> = []; 492 + const stderr: Array<string> = []; 493 + 494 + try { 495 + const code = await runCli(["init"], { 496 + cwd: tempDirectory, 497 + homeDirectory: join(tempDirectory, "home"), 498 + io: { 499 + stdout: (line) => { 500 + stdout.push(line); 501 + }, 502 + stderr: (line) => { 503 + stderr.push(line); 504 + }, 505 + }, 506 + }); 507 + 508 + expect(code).toBe(0); 509 + expect(stderr).toHaveLength(0); 510 + expect(stdout[0]).toContain("mill.config.ts"); 511 + 512 + const configSource = await readFile(join(tempDirectory, "mill.config.ts"), "utf-8"); 513 + expect(configSource).toContain("defineConfig"); 514 + expect(configSource).toContain("defaultExecutor"); 515 + expect(configSource).toContain("executors"); 516 + } finally { 517 + await rm(tempDirectory, { recursive: true, force: true }); 518 + } 519 + }); 520 + 304 521 it("wait timeout is deterministic with typed JSON error contract", async () => { 305 522 const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-wait-timeout-")); 306 523 const runsDirectory = join(tempDirectory, "runs"); ··· 319 536 status: "running", 320 537 programPath: "/tmp/program.ts", 321 538 driver: "default", 539 + executor: "direct", 322 540 createdAt: "2026-02-23T20:00:00.000Z", 323 541 updatedAt: "2026-02-23T20:00:00.000Z", 324 542 paths: {
+194 -8
packages/cli/src/public/index.api.ts
··· 1 + import * as Command from "@effect/platform/Command"; 2 + import * as FileSystem from "@effect/platform/FileSystem"; 3 + import * as BunContext from "@effect/platform-bun/BunContext"; 4 + import { Effect, Runtime, Scope } from "effect"; 1 5 import { 2 6 createDiscoveryPayload, 3 7 defineConfig, 4 8 getRunStatus, 5 9 processDriver, 6 10 runProgramSync, 11 + runWorker, 12 + submitRun, 7 13 waitForRun, 8 14 type ConfigOverrides, 15 + type LaunchWorkerInput, 9 16 } from "@mill/core"; 17 + import { createClaudeDriverRegistration } from "@mill/driver-claude"; 18 + import { createCodexDriverRegistration } from "@mill/driver-codex"; 10 19 import { createPiDriverRegistration } from "@mill/driver-pi"; 11 20 12 21 interface CliIo { ··· 23 32 readonly io?: CliIo; 24 33 } 25 34 35 + const runtime = Runtime.defaultRuntime; 36 + 26 37 const defaultIo: CliIo = { 27 38 stdout: (line) => { 28 39 console.log(line); ··· 32 43 }, 33 44 }; 34 45 46 + const createDirectExecutor = () => ({ 47 + description: "Local direct executor", 48 + runtime: { 49 + name: "direct", 50 + runProgram: (input: { readonly execute: Effect.Effect<unknown, unknown> }) => input.execute, 51 + }, 52 + }); 53 + 54 + const createVmExecutor = () => ({ 55 + description: "VM-style executor placeholder", 56 + runtime: { 57 + name: "vm", 58 + runProgram: (input: { readonly execute: Effect.Effect<unknown, unknown> }) => input.execute, 59 + }, 60 + }); 61 + 35 62 const defaultConfig = defineConfig({ 36 63 defaultDriver: "default", 64 + defaultExecutor: "direct", 37 65 defaultModel: "openai/gpt-5.3-codex", 38 66 drivers: { 39 67 default: processDriver(createPiDriverRegistration()), 68 + claude: processDriver(createClaudeDriverRegistration()), 69 + codex: processDriver(createCodexDriverRegistration()), 40 70 }, 71 + executors: { 72 + direct: createDirectExecutor(), 73 + vm: createVmExecutor(), 74 + }, 75 + extensions: [], 41 76 authoring: { 42 77 instructions: 43 78 "Use systemPrompt for WHO and prompt for WHAT. Prefer cheaper models for search and stronger models for synthesis.", ··· 70 105 return parsed; 71 106 }; 72 107 73 - const runSyncCommand = async ( 108 + const runWithBunContext = <A, E>(effect: Effect.Effect<A, E, BunContext.BunContext>): Promise<A> => 109 + Runtime.runPromise(runtime)(Effect.provide(effect, BunContext.layer)); 110 + 111 + const millBinPath = decodeURIComponent(new URL("../bin/mill.ts", import.meta.url).pathname); 112 + 113 + const launchDetachedWorker = async (input: LaunchWorkerInput): Promise<void> => { 114 + const workerCommand = Command.make( 115 + process.execPath, 116 + "run", 117 + millBinPath, 118 + "_worker", 119 + "--run-id", 120 + input.runId, 121 + "--program", 122 + input.programPath, 123 + "--runs-dir", 124 + input.runsDirectory, 125 + "--driver", 126 + input.driverName, 127 + "--executor", 128 + input.executorName, 129 + ).pipe( 130 + Command.workingDirectory(input.cwd), 131 + Command.stdin("inherit"), 132 + Command.stdout("inherit"), 133 + Command.stderr("inherit"), 134 + ); 135 + 136 + await runWithBunContext( 137 + Effect.gen(function* () { 138 + const detachedScope = yield* Scope.make(); 139 + 140 + yield* Scope.extend(Command.start(workerCommand), detachedScope); 141 + }), 142 + ); 143 + }; 144 + 145 + const runCommand = async ( 74 146 argv: ReadonlyArray<string>, 75 147 options: RunCliOptions, 76 148 io: CliIo, ··· 78 150 const programPath = argv[0]; 79 151 80 152 if (programPath === undefined) { 81 - io.stderr("Usage: mill run <program.ts> --sync [--json]"); 153 + io.stderr("Usage: mill run <program.ts> [--json] [--sync] [--driver] [--executor]"); 82 154 return 1; 83 155 } 84 156 85 - if (!argv.includes("--sync")) { 86 - io.stderr("v0 currently supports `mill run` only with --sync."); 157 + const runInput = { 158 + defaults: defaultConfig, 159 + programPath, 160 + cwd: options.cwd, 161 + homeDirectory: options.homeDirectory, 162 + runsDirectory: readFlagValue(argv, "--runs-dir") ?? options.runsDirectory, 163 + driverName: readFlagValue(argv, "--driver"), 164 + executorName: readFlagValue(argv, "--executor"), 165 + pathExists: options.pathExists, 166 + loadConfigOverrides: options.loadConfigOverrides, 167 + launchWorker: launchDetachedWorker, 168 + } as const; 169 + 170 + if (argv.includes("--sync")) { 171 + const output = await runProgramSync(runInput); 172 + 173 + if (argv.includes("--json")) { 174 + io.stdout(JSON.stringify(output)); 175 + return 0; 176 + } 177 + 178 + io.stdout(`run ${output.run.id} -> ${output.run.status}`); 179 + return 0; 180 + } 181 + 182 + const submittedRun = await submitRun(runInput); 183 + 184 + if (argv.includes("--json")) { 185 + io.stdout( 186 + JSON.stringify({ 187 + runId: submittedRun.id, 188 + status: submittedRun.status, 189 + paths: submittedRun.paths, 190 + }), 191 + ); 192 + return 0; 193 + } 194 + 195 + io.stdout(`run ${submittedRun.id} submitted status=${submittedRun.status}`); 196 + return 0; 197 + }; 198 + 199 + const workerCommand = async ( 200 + argv: ReadonlyArray<string>, 201 + options: RunCliOptions, 202 + io: CliIo, 203 + ): Promise<number> => { 204 + const runId = readFlagValue(argv, "--run-id"); 205 + const programPath = readFlagValue(argv, "--program"); 206 + 207 + if (runId === undefined || programPath === undefined) { 208 + io.stderr( 209 + "Usage: mill _worker --run-id <id> --program <abs-path> [--runs-dir] [--driver] [--executor]", 210 + ); 87 211 return 1; 88 212 } 89 213 90 - const output = await runProgramSync({ 214 + const output = await runWorker({ 91 215 defaults: defaultConfig, 216 + runId, 92 217 programPath, 93 218 cwd: options.cwd, 94 219 homeDirectory: options.homeDirectory, 95 220 runsDirectory: readFlagValue(argv, "--runs-dir") ?? options.runsDirectory, 96 221 driverName: readFlagValue(argv, "--driver"), 222 + executorName: readFlagValue(argv, "--executor"), 97 223 pathExists: options.pathExists, 98 224 loadConfigOverrides: options.loadConfigOverrides, 99 225 }); 100 226 101 227 if (argv.includes("--json")) { 102 228 io.stdout(JSON.stringify(output)); 103 - return 0; 104 229 } 105 230 106 - io.stdout(`run ${output.run.id} -> ${output.run.status}`); 231 + return 0; 232 + }; 233 + 234 + const INIT_CONFIG_TEMPLATE = [ 235 + 'import { defineConfig, processDriver } from "@mill/core";', 236 + 'import { createPiDriverRegistration } from "@mill/driver-pi";', 237 + 'import { createClaudeDriverRegistration } from "@mill/driver-claude";', 238 + 'import { createCodexDriverRegistration } from "@mill/driver-codex";', 239 + "", 240 + "export default defineConfig({", 241 + ' defaultDriver: "default",', 242 + ' defaultExecutor: "direct",', 243 + ' defaultModel: "openai/gpt-5.3-codex",', 244 + " drivers: {", 245 + " default: processDriver(createPiDriverRegistration()),", 246 + " claude: processDriver(createClaudeDriverRegistration()),", 247 + " codex: processDriver(createCodexDriverRegistration()),", 248 + " },", 249 + " executors: {", 250 + " direct: {", 251 + ' description: "Local direct executor",', 252 + " runtime: {", 253 + ' name: "direct",', 254 + " runProgram: ({ execute }) => execute,", 255 + " },", 256 + " },", 257 + " vm: {", 258 + ' description: "VM-style executor placeholder",', 259 + " runtime: {", 260 + ' name: "vm",', 261 + " runProgram: ({ execute }) => execute,", 262 + " },", 263 + " },", 264 + " },", 265 + " extensions: [],", 266 + " authoring: {", 267 + ' instructions: "Use systemPrompt for WHO and prompt for WHAT.",', 268 + " },", 269 + "});", 270 + ].join("\n"); 271 + 272 + const initCommand = async (options: RunCliOptions, io: CliIo): Promise<number> => { 273 + const cwd = options.cwd ?? process.cwd(); 274 + const configPath = `${cwd}/mill.config.ts`; 275 + 276 + await runWithBunContext( 277 + Effect.flatMap(FileSystem.FileSystem, (fileSystem) => 278 + fileSystem.writeFileString(configPath, `${INIT_CONFIG_TEMPLATE}\n`), 279 + ), 280 + ); 281 + 282 + io.stdout(`Created ${configPath}`); 107 283 return 0; 108 284 }; 109 285 ··· 251 427 "mill — Effect-first orchestration runtime", 252 428 "", 253 429 `Authoring guidance: ${payload.authoring.instructions}`, 430 + `Registered drivers: ${Object.keys(payload.drivers).join(", ")}`, 431 + `Registered executors: ${Object.keys(payload.executors).join(", ")}`, 254 432 "", 255 433 "Run `mill --help --json` for machine-readable discovery.", 256 434 ].join("\n"), ··· 259 437 } 260 438 261 439 if (argv[0] === "run") { 262 - return runSyncCommand(argv.slice(1), options ?? {}, io); 440 + return runCommand(argv.slice(1), options ?? {}, io); 441 + } 442 + 443 + if (argv[0] === "_worker") { 444 + return workerCommand(argv.slice(1), options ?? {}, io); 263 445 } 264 446 265 447 if (argv[0] === "status") { ··· 268 450 269 451 if (argv[0] === "wait") { 270 452 return waitCommand(argv.slice(1), options ?? {}, io); 453 + } 454 + 455 + if (argv[0] === "init") { 456 + return initCommand(options ?? {}, io); 271 457 } 272 458 273 459 io.stderr(`Unknown command: ${argv[0]}`);
+181
packages/cli/src/public/index.e2e.test.ts
··· 41 41 run: Schema.Struct({ 42 42 id: Schema.String, 43 43 status: Schema.String, 44 + driver: Schema.String, 45 + executor: Schema.String, 44 46 paths: Schema.Struct({ 45 47 runDir: Schema.String, 46 48 runFile: Schema.String, ··· 65 67 }), 66 68 ); 67 69 70 + const RunSubmitEnvelope = Schema.parseJson( 71 + Schema.Struct({ 72 + runId: Schema.String, 73 + status: Schema.Union(Schema.Literal("pending"), Schema.Literal("running")), 74 + paths: Schema.Struct({ 75 + runDir: Schema.String, 76 + runFile: Schema.String, 77 + eventsFile: Schema.String, 78 + resultFile: Schema.String, 79 + }), 80 + }), 81 + ); 82 + 68 83 const StatusEnvelope = Schema.parseJson( 69 84 Schema.Struct({ 70 85 id: Schema.String, ··· 91 106 "openai/gpt-5.3-codex", 92 107 "anthropic/claude-sonnet-4-6", 93 108 ]); 109 + expect(payload.drivers.claude?.models).toEqual(["anthropic/claude-sonnet-4-6"]); 110 + expect(payload.drivers.codex?.models).toEqual(["openai/gpt-5.3-codex"]); 94 111 expect(payload.authoring.instructions.length).toBeGreaterThan(0); 95 112 expect(payload.async.submit).toBe("mill run <program.ts> --json"); 96 113 }); 97 114 }); 98 115 99 116 describe("mill run/status/wait (e2e)", () => { 117 + it("supports run --driver and --executor selection", async () => { 118 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-select-e2e-")); 119 + const runsDirectory = join(tempDirectory, "runs"); 120 + const programPath = join(tempDirectory, "program.ts"); 121 + 122 + await writeFile( 123 + programPath, 124 + [ 125 + "const output = await mill.spawn({", 126 + ' agent: "scout",', 127 + ' systemPrompt: "You are concise.",', 128 + ' prompt: "Inspect repository layout.",', 129 + "});", 130 + "return output.text;", 131 + ].join("\n"), 132 + "utf-8", 133 + ); 134 + 135 + try { 136 + const runOutput = await commandOutput( 137 + Command.make( 138 + "bun", 139 + "run", 140 + "packages/cli/src/bin/mill.ts", 141 + "run", 142 + programPath, 143 + "--sync", 144 + "--json", 145 + "--driver", 146 + "codex", 147 + "--executor", 148 + "vm", 149 + "--runs-dir", 150 + runsDirectory, 151 + ), 152 + ); 153 + 154 + const runPayload = Schema.decodeUnknownSync(RunSyncEnvelope)(runOutput); 155 + expect(runPayload.run.driver).toBe("codex"); 156 + expect(runPayload.run.executor).toBe("vm"); 157 + expect(runPayload.result.spawns[0]?.driver).toBe("codex"); 158 + } finally { 159 + await rm(tempDirectory, { recursive: true, force: true }); 160 + } 161 + }); 162 + 163 + it("submits async run by default, then status/wait observes completion", async () => { 164 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-async-e2e-")); 165 + const runsDirectory = join(tempDirectory, "runs"); 166 + const programPath = join(tempDirectory, "program.ts"); 167 + 168 + await writeFile( 169 + programPath, 170 + [ 171 + "const scan = await mill.spawn({", 172 + ' agent: "scout",', 173 + ' systemPrompt: "You are concise.",', 174 + ' prompt: "Inspect repository layout.",', 175 + "});", 176 + "globalThis.__millAsyncProgramText = scan.text;", 177 + ].join("\n"), 178 + "utf-8", 179 + ); 180 + 181 + try { 182 + const submitOutput = await commandOutput( 183 + Command.make( 184 + "bun", 185 + "run", 186 + "packages/cli/src/bin/mill.ts", 187 + "run", 188 + programPath, 189 + "--json", 190 + "--runs-dir", 191 + runsDirectory, 192 + ), 193 + ); 194 + 195 + const submitPayload = Schema.decodeUnknownSync(RunSubmitEnvelope)(submitOutput); 196 + expect(submitPayload.runId.length).toBeGreaterThan(0); 197 + 198 + const statusOutput = await commandOutput( 199 + Command.make( 200 + "bun", 201 + "run", 202 + "packages/cli/src/bin/mill.ts", 203 + "status", 204 + submitPayload.runId, 205 + "--json", 206 + "--runs-dir", 207 + runsDirectory, 208 + ), 209 + ); 210 + 211 + const statusPayload = Schema.decodeUnknownSync(StatusEnvelope)(statusOutput); 212 + expect(statusPayload.id).toBe(submitPayload.runId); 213 + 214 + const waitOutput = await commandOutput( 215 + Command.make( 216 + "bun", 217 + "run", 218 + "packages/cli/src/bin/mill.ts", 219 + "wait", 220 + submitPayload.runId, 221 + "--timeout", 222 + "5", 223 + "--json", 224 + "--runs-dir", 225 + runsDirectory, 226 + ), 227 + ); 228 + 229 + const waitPayload = Schema.decodeUnknownSync(StatusEnvelope)(waitOutput); 230 + expect(waitPayload.id).toBe(submitPayload.runId); 231 + expect(waitPayload.status).toBe("complete"); 232 + 233 + const copiedProgram = await readFile(join(submitPayload.paths.runDir, "program.ts"), "utf-8"); 234 + const workerLog = await readFile( 235 + join(submitPayload.paths.runDir, "logs", "worker.log"), 236 + "utf-8", 237 + ); 238 + 239 + expect(copiedProgram).toContain("mill.spawn"); 240 + expect(workerLog.length).toBeGreaterThan(0); 241 + 242 + const workerExitCode = await commandExitCode( 243 + Command.make( 244 + "bun", 245 + "run", 246 + "packages/cli/src/bin/mill.ts", 247 + "_worker", 248 + "--run-id", 249 + submitPayload.runId, 250 + "--program", 251 + join(submitPayload.paths.runDir, "program.ts"), 252 + "--runs-dir", 253 + runsDirectory, 254 + ), 255 + ); 256 + 257 + expect(workerExitCode).toBe(0); 258 + 259 + const eventsFile = await readFile(submitPayload.paths.eventsFile, "utf-8"); 260 + const terminalEventCount = eventsFile 261 + .split("\n") 262 + .map((line) => line.trim()) 263 + .filter((line) => line.length > 0) 264 + .map((line) => JSON.parse(line) as { readonly type: string }) 265 + .filter( 266 + (event) => 267 + event.type === "run:complete" || 268 + event.type === "run:failed" || 269 + event.type === "run:cancelled", 270 + ).length; 271 + 272 + expect(terminalEventCount).toBe(1); 273 + } finally { 274 + await rm(tempDirectory, { recursive: true, force: true }); 275 + } 276 + }); 277 + 100 278 it("executes run --sync and wait --timeout returns terminal result", async () => { 101 279 const tempDirectory = await mkdtemp(join(tmpdir(), "mill-cli-e2e-")); 102 280 const runsDirectory = join(tempDirectory, "runs"); ··· 137 315 138 316 const runPayload = Schema.decodeUnknownSync(RunSyncEnvelope)(runOutput); 139 317 expect(runPayload.run.status).toBe("complete"); 318 + expect(runPayload.run.driver).toBe("default"); 319 + expect(runPayload.run.executor).toBe("direct"); 140 320 expect(runPayload.result.status).toBe("complete"); 141 321 expect(runPayload.result.spawns).toHaveLength(2); 142 322 ··· 206 386 status: "running", 207 387 programPath: "/tmp/program.ts", 208 388 driver: "default", 389 + executor: "direct", 209 390 createdAt: "2026-02-23T20:00:00.000Z", 210 391 updatedAt: "2026-02-23T20:00:00.000Z", 211 392 paths: {
+24
packages/core/src/domain/event.schema.test.ts
··· 35 35 } 36 36 }); 37 37 38 + it("decodes extension:error events for failed extension hooks", () => { 39 + const event = decodeMillEventJsonSync( 40 + JSON.stringify({ 41 + schemaVersion: 1, 42 + runId: "run_test_01", 43 + sequence: 9, 44 + timestamp: "2026-02-23T20:00:05.000Z", 45 + type: "extension:error", 46 + payload: { 47 + extensionName: "tools", 48 + hook: "onEvent", 49 + message: "hook failed", 50 + }, 51 + }), 52 + ); 53 + 54 + expect(event.type).toBe("extension:error"); 55 + 56 + if (event.type === "extension:error") { 57 + expect(event.payload.extensionName).toBe("tools"); 58 + expect(event.payload.hook).toBe("onEvent"); 59 + } 60 + }); 61 + 38 62 it("fails decoding unknown schemaVersion values", () => { 39 63 expect(() => 40 64 decodeMillEventJsonSync(
+12
packages/core/src/domain/event.schema.ts
··· 121 121 }); 122 122 export type SpawnCancelledEvent = Schema.Schema.Type<typeof SpawnCancelledEvent>; 123 123 124 + export const ExtensionErrorEvent = Schema.Struct({ 125 + ...EventEnvelope, 126 + type: Schema.Literal("extension:error"), 127 + payload: Schema.Struct({ 128 + extensionName: Schema.NonEmptyString, 129 + hook: Schema.Literal("setup", "onEvent"), 130 + message: Schema.String, 131 + }), 132 + }); 133 + export type ExtensionErrorEvent = Schema.Schema.Type<typeof ExtensionErrorEvent>; 134 + 124 135 export const MillEvent = Schema.Union( 125 136 RunStartEvent, 126 137 RunStatusEvent, ··· 133 144 SpawnErrorEvent, 134 145 SpawnCompleteEvent, 135 146 SpawnCancelledEvent, 147 + ExtensionErrorEvent, 136 148 ); 137 149 export type MillEvent = Schema.Schema.Type<typeof MillEvent>; 138 150
+1
packages/core/src/domain/run.schema.ts
··· 29 29 status: RunStatus, 30 30 programPath: Schema.NonEmptyString, 31 31 driver: Schema.NonEmptyString, 32 + executor: Schema.NonEmptyString, 32 33 createdAt: Schema.String, 33 34 updatedAt: Schema.String, 34 35 paths: RunPaths,
+1
packages/core/src/driver-registry.effect.ts
··· 1 + export * from "./internal/driver-registry.effect";
+1
packages/core/src/executor-registry.effect.ts
··· 1 + export * from "./internal/executor-registry.effect";
+82
packages/core/src/internal/driver-registry.effect.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { Effect } from "effect"; 3 + import { runWithRuntime } from "../public/test-runtime.api"; 4 + import type { DriverRegistration } from "../public/types"; 5 + import { makeDriverRegistry } from "./driver-registry.effect"; 6 + 7 + const makeDriver = (name: string): DriverRegistration => ({ 8 + description: `${name} driver`, 9 + modelFormat: "provider/model-id", 10 + process: { 11 + command: name, 12 + args: [], 13 + env: {}, 14 + }, 15 + codec: { 16 + modelCatalog: Effect.succeed([`${name}/model`]), 17 + }, 18 + runtime: { 19 + name, 20 + spawn: () => 21 + Effect.succeed({ 22 + events: [], 23 + result: { 24 + text: `${name}:ok`, 25 + sessionRef: `session/${name}`, 26 + agent: "scout", 27 + model: `${name}/model`, 28 + driver: name, 29 + exitCode: 0, 30 + }, 31 + }), 32 + }, 33 + }); 34 + 35 + describe("makeDriverRegistry", () => { 36 + it("resolves configured default driver when no override is provided", async () => { 37 + const registry = makeDriverRegistry({ 38 + defaultDriver: "default", 39 + drivers: { 40 + default: makeDriver("pi"), 41 + codex: makeDriver("codex"), 42 + }, 43 + }); 44 + 45 + const selected = await runWithRuntime(registry.resolve(undefined)); 46 + 47 + expect(selected.name).toBe("default"); 48 + expect(selected.registration.description).toBe("pi driver"); 49 + }); 50 + 51 + it("resolves explicit override driver when available", async () => { 52 + const registry = makeDriverRegistry({ 53 + defaultDriver: "default", 54 + drivers: { 55 + default: makeDriver("pi"), 56 + codex: makeDriver("codex"), 57 + }, 58 + }); 59 + 60 + const selected = await runWithRuntime(registry.resolve("codex")); 61 + 62 + expect(selected.name).toBe("codex"); 63 + expect(selected.registration.description).toBe("codex driver"); 64 + }); 65 + 66 + it("fails with a typed registry error for unknown drivers", async () => { 67 + const registry = makeDriverRegistry({ 68 + defaultDriver: "default", 69 + drivers: { 70 + default: makeDriver("pi"), 71 + }, 72 + }); 73 + 74 + const selectionError = await runWithRuntime(Effect.flip(registry.resolve("missing"))); 75 + 76 + expect(selectionError).toMatchObject({ 77 + _tag: "DriverRegistryError", 78 + requested: "missing", 79 + available: ["default"], 80 + }); 81 + }); 82 + });
+75
packages/core/src/internal/driver-registry.effect.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { DriverRegistration, DriverRuntime } from "../public/types"; 3 + 4 + export class DriverRegistryError extends Data.TaggedError("DriverRegistryError")<{ 5 + requested: string; 6 + available: ReadonlyArray<string>; 7 + message: string; 8 + }> {} 9 + 10 + export interface DriverRegistry { 11 + readonly list: ReadonlyArray<string>; 12 + readonly resolve: (name: string | undefined) => Effect.Effect< 13 + { 14 + readonly name: string; 15 + readonly registration: DriverRegistration; 16 + readonly runtime: DriverRuntime; 17 + }, 18 + DriverRegistryError 19 + >; 20 + } 21 + 22 + export interface MakeDriverRegistryInput { 23 + readonly defaultDriver: string; 24 + readonly drivers: Readonly<Record<string, DriverRegistration>>; 25 + } 26 + 27 + const sortedDriverNames = ( 28 + drivers: Readonly<Record<string, DriverRegistration>>, 29 + ): ReadonlyArray<string> => Object.keys(drivers).sort((left, right) => left.localeCompare(right)); 30 + 31 + const missingDriverError = ( 32 + requested: string, 33 + available: ReadonlyArray<string>, 34 + ): DriverRegistryError => 35 + new DriverRegistryError({ 36 + requested, 37 + available, 38 + message: `Unknown driver '${requested}'. Available drivers: ${available.join(", ")}.`, 39 + }); 40 + 41 + const missingRuntimeError = ( 42 + requested: string, 43 + available: ReadonlyArray<string>, 44 + ): DriverRegistryError => 45 + new DriverRegistryError({ 46 + requested, 47 + available, 48 + message: `Driver '${requested}' has no runtime adapter configured.`, 49 + }); 50 + 51 + export const makeDriverRegistry = (input: MakeDriverRegistryInput): DriverRegistry => { 52 + const available = sortedDriverNames(input.drivers); 53 + 54 + return { 55 + list: available, 56 + resolve: (name) => { 57 + const requested = name ?? input.defaultDriver; 58 + const registration = input.drivers[requested]; 59 + 60 + if (registration === undefined) { 61 + return Effect.fail(missingDriverError(requested, available)); 62 + } 63 + 64 + if (registration.runtime === undefined) { 65 + return Effect.fail(missingRuntimeError(requested, available)); 66 + } 67 + 68 + return Effect.succeed({ 69 + name: requested, 70 + registration, 71 + runtime: registration.runtime, 72 + }); 73 + }, 74 + }; 75 + };
+37 -2
packages/core/src/internal/engine.effect.test.ts
··· 43 43 const spawnTerminalTypes = new Set(["spawn:complete", "spawn:error", "spawn:cancelled"]); 44 44 45 45 describe("MillEngine sync lifecycle", () => { 46 + it("submits pending runs before worker execution", async () => { 47 + const runsDirectory = await mkdtemp(join(tmpdir(), "mill-engine-submit-")); 48 + const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); 49 + 50 + const engine = makeMillEngine({ 51 + runsDirectory, 52 + defaultModel: "openai/gpt-5.3-codex", 53 + driverName: "default", 54 + executorName: "direct", 55 + driver: testDriver, 56 + extensions: [], 57 + }); 58 + 59 + try { 60 + const submitted = await runWithBunContext( 61 + engine.submit({ 62 + runId, 63 + programPath: "/tmp/program.ts", 64 + }), 65 + ); 66 + 67 + expect(submitted.id).toBe(runId); 68 + expect(submitted.status).toBe("pending"); 69 + } finally { 70 + await rm(runsDirectory, { recursive: true, force: true }); 71 + } 72 + }); 73 + 46 74 it("persists deterministic run/start/spawn/complete lifecycle", async () => { 47 75 const runsDirectory = await mkdtemp(join(tmpdir(), "mill-engine-")); 48 76 const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); ··· 51 79 runsDirectory, 52 80 defaultModel: "openai/gpt-5.3-codex", 53 81 driverName: "default", 82 + executorName: "direct", 54 83 driver: testDriver, 84 + extensions: [], 55 85 }); 56 86 57 87 try { ··· 102 132 expect(runTerminalCount).toBe(1); 103 133 104 134 const spawnIds = events 105 - .filter((event): event is Extract<MillEvent, { type: "spawn:start" }> => 106 - event.type === "spawn:start", 135 + .filter( 136 + (event): event is Extract<MillEvent, { type: "spawn:start" }> => 137 + event.type === "spawn:start", 107 138 ) 108 139 .map((event) => event.payload.spawnId); 109 140 ··· 144 175 runsDirectory, 145 176 defaultModel: "openai/gpt-5.3-codex", 146 177 driverName: "default", 178 + executorName: "direct", 147 179 driver: testDriver, 180 + extensions: [], 148 181 }); 149 182 150 183 try { ··· 238 271 runsDirectory, 239 272 defaultModel: "openai/gpt-5.3-codex", 240 273 driverName: "default", 274 + executorName: "direct", 241 275 driver: testDriver, 276 + extensions: [], 242 277 }); 243 278 244 279 try {
+336 -56
packages/core/src/internal/engine.effect.ts
··· 15 15 type RunSyncOutput, 16 16 } from "../domain/run.schema"; 17 17 import { decodeSpawnResult, type SpawnOptions, type SpawnResult } from "../domain/spawn.schema"; 18 - import type { DriverRuntime } from "../public/types"; 18 + import type { DriverRuntime, ExtensionContext, ExtensionRegistration } from "../public/types"; 19 19 import { 20 20 LifecycleInvariantError, 21 21 applyLifecycleTransition, ··· 43 43 message: string; 44 44 }> {} 45 45 46 - export interface RunSyncInput { 46 + export interface RunSubmitInput { 47 47 readonly runId: RunId; 48 48 readonly programPath: string; 49 + } 50 + 51 + export interface RunSyncInput extends RunSubmitInput { 49 52 readonly executeProgram: ( 50 53 spawn: ( 51 54 input: SpawnOptions, ··· 57 60 } 58 61 59 62 export interface MillEngine { 63 + readonly submit: (input: RunSubmitInput) => Effect.Effect<RunSyncOutput["run"], PersistenceError>; 60 64 readonly runSync: ( 61 65 input: RunSyncInput, 62 66 ) => Effect.Effect< ··· 66 70 readonly status: ( 67 71 runId: RunId, 68 72 ) => Effect.Effect<RunSyncOutput["run"], RunNotFoundError | PersistenceError>; 73 + readonly result: ( 74 + runId: RunId, 75 + ) => Effect.Effect<RunResult | undefined, RunNotFoundError | PersistenceError>; 69 76 readonly wait: ( 70 77 runId: RunId, 71 78 timeout: number | string, ··· 78 85 export interface MakeMillEngineInput { 79 86 readonly runsDirectory: string; 80 87 readonly driverName: string; 88 + readonly executorName: string; 81 89 readonly defaultModel: string; 82 90 readonly driver: DriverRuntime; 91 + readonly extensions: ReadonlyArray<ExtensionRegistration>; 83 92 } 84 93 85 94 const toIsoTimestamp = Effect.map(Clock.currentTimeMillis, (millis) => ··· 115 124 yield* runStore.appendEvent(runId, event); 116 125 }); 117 126 127 + const appendExtensionErrorEvent = ( 128 + lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 129 + sequenceRef: Ref.Ref<number>, 130 + runStore: RunStore, 131 + runId: RunId, 132 + extensionName: string, 133 + hook: "setup" | "onEvent", 134 + message: string, 135 + ): Effect.Effect<void, PersistenceError | LifecycleInvariantError> => 136 + appendTier1Event(lifecycleStateRef, sequenceRef, runStore, runId, (sequence, timestamp) => ({ 137 + ...makeEventEnvelope(runId, sequence, timestamp), 138 + type: "extension:error", 139 + payload: { 140 + extensionName, 141 + hook, 142 + message, 143 + }, 144 + })); 145 + 146 + const notifyExtensionHookFailures = ( 147 + lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 148 + sequenceRef: Ref.Ref<number>, 149 + runStore: RunStore, 150 + runId: RunId, 151 + extensionName: string, 152 + hook: "setup" | "onEvent", 153 + message: string, 154 + ): Effect.Effect<void, never> => 155 + Effect.catchAll( 156 + appendExtensionErrorEvent( 157 + lifecycleStateRef, 158 + sequenceRef, 159 + runStore, 160 + runId, 161 + extensionName, 162 + hook, 163 + message, 164 + ), 165 + () => Effect.void, 166 + ); 167 + 168 + const runExtensionSetupHooks = ( 169 + extensions: ReadonlyArray<ExtensionRegistration>, 170 + extensionContext: ExtensionContext, 171 + lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 172 + sequenceRef: Ref.Ref<number>, 173 + runStore: RunStore, 174 + runId: RunId, 175 + ): Effect.Effect<void, PersistenceError | LifecycleInvariantError> => 176 + Effect.gen(function* () { 177 + for (const extension of extensions) { 178 + if (extension.setup === undefined) { 179 + continue; 180 + } 181 + 182 + const setupExit = yield* Effect.exit(extension.setup(extensionContext)); 183 + 184 + if (Exit.isFailure(setupExit)) { 185 + yield* notifyExtensionHookFailures( 186 + lifecycleStateRef, 187 + sequenceRef, 188 + runStore, 189 + runId, 190 + extension.name, 191 + "setup", 192 + Cause.pretty(setupExit.cause), 193 + ); 194 + } 195 + } 196 + }); 197 + 198 + const runExtensionOnEventHooks = ( 199 + extensions: ReadonlyArray<ExtensionRegistration>, 200 + extensionContext: ExtensionContext, 201 + lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 202 + sequenceRef: Ref.Ref<number>, 203 + runStore: RunStore, 204 + runId: RunId, 205 + event: MillEvent, 206 + ): Effect.Effect<void, PersistenceError | LifecycleInvariantError> => 207 + Effect.gen(function* () { 208 + if (event.type === "extension:error") { 209 + return; 210 + } 211 + 212 + for (const extension of extensions) { 213 + if (extension.onEvent === undefined) { 214 + continue; 215 + } 216 + 217 + const hookExit = yield* Effect.exit(extension.onEvent(event, extensionContext)); 218 + 219 + if (Exit.isFailure(hookExit)) { 220 + yield* notifyExtensionHookFailures( 221 + lifecycleStateRef, 222 + sequenceRef, 223 + runStore, 224 + runId, 225 + extension.name, 226 + "onEvent", 227 + Cause.pretty(hookExit.cause), 228 + ); 229 + } 230 + } 231 + }); 232 + 233 + const appendTier1EventWithHooks = ( 234 + extensions: ReadonlyArray<ExtensionRegistration>, 235 + extensionContext: ExtensionContext, 236 + lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 237 + sequenceRef: Ref.Ref<number>, 238 + runStore: RunStore, 239 + runId: RunId, 240 + eventBuilder: (sequence: number, timestamp: string) => MillEvent, 241 + ): Effect.Effect<void, PersistenceError | LifecycleInvariantError> => 242 + Effect.gen(function* () { 243 + const sequence = yield* nextSequence(sequenceRef); 244 + const timestamp = yield* toIsoTimestamp; 245 + const event = eventBuilder(sequence, timestamp); 246 + const lifecycleState = yield* Ref.get(lifecycleStateRef); 247 + const nextState = yield* applyLifecycleTransition(lifecycleState, event); 248 + 249 + yield* Ref.set(lifecycleStateRef, nextState); 250 + yield* runStore.appendEvent(runId, event); 251 + yield* runExtensionOnEventHooks( 252 + extensions, 253 + extensionContext, 254 + lifecycleStateRef, 255 + sequenceRef, 256 + runStore, 257 + runId, 258 + event, 259 + ); 260 + }); 261 + 118 262 const toTimeoutMillis = (timeout: number | string): number => { 119 263 if (typeof timeout === "number") { 120 264 return timeout; ··· 139 283 const waitForRunTerminal = ( 140 284 runStore: RunStore, 141 285 runId: RunId, 142 - ): Effect.Effect<RunSyncOutput["run"], RunNotFoundError | PersistenceError | LifecycleInvariantError> => 286 + ): Effect.Effect< 287 + RunSyncOutput["run"], 288 + RunNotFoundError | PersistenceError | LifecycleInvariantError 289 + > => 143 290 Effect.gen(function* () { 144 291 let observedEvents = 0; 145 292 let terminalObserved = false; ··· 172 319 }); 173 320 174 321 const appendSpawnErrorEvent = ( 322 + extensions: ReadonlyArray<ExtensionRegistration>, 323 + extensionContext: ExtensionContext, 175 324 lifecycleStateRef: Ref.Ref<LifecycleGuardState>, 176 325 sequenceRef: Ref.Ref<number>, 177 326 runStore: RunStore, ··· 179 328 spawnId: string, 180 329 message: string, 181 330 ): Effect.Effect<void, PersistenceError | LifecycleInvariantError> => 182 - appendTier1Event(lifecycleStateRef, sequenceRef, runStore, runId, (sequence, timestamp) => ({ 183 - ...makeEventEnvelope(runId, sequence, timestamp), 184 - type: "spawn:error", 185 - payload: { 186 - spawnId: decodeSpawnIdSync(spawnId), 187 - message, 188 - }, 189 - })); 331 + appendTier1EventWithHooks( 332 + extensions, 333 + extensionContext, 334 + lifecycleStateRef, 335 + sequenceRef, 336 + runStore, 337 + runId, 338 + (sequence, timestamp) => ({ 339 + ...makeEventEnvelope(runId, sequence, timestamp), 340 + type: "spawn:error", 341 + payload: { 342 + spawnId: decodeSpawnIdSync(spawnId), 343 + message, 344 + }, 345 + }), 346 + ); 190 347 191 348 export const makeMillEngine = (input: MakeMillEngineInput): MillEngine => { 192 349 const runStore = makeRunStore({ ··· 194 351 }); 195 352 196 353 return { 197 - runSync: (runInput) => 354 + submit: (submitInput) => 198 355 Effect.gen(function* () { 199 - const lifecycleStateRef = yield* Ref.make(initialLifecycleGuardState); 200 - const sequenceRef = yield* Ref.make(0); 201 - const spawnCounterRef = yield* Ref.make(0); 202 - const spawnResultsRef = yield* Ref.make<ReadonlyArray<SpawnResult>>([]); 356 + const existingRun = yield* Effect.catchTag( 357 + runStore.getRun(submitInput.runId), 358 + "RunNotFoundError", 359 + () => Effect.succeed(undefined), 360 + ); 203 361 204 - const startedAt = yield* toIsoTimestamp; 362 + if (existingRun !== undefined) { 363 + return existingRun; 364 + } 205 365 206 - yield* runStore.create({ 207 - runId: runInput.runId, 208 - programPath: runInput.programPath, 366 + const submittedAt = yield* toIsoTimestamp; 367 + 368 + return yield* runStore.create({ 369 + runId: submitInput.runId, 370 + programPath: submitInput.programPath, 209 371 driver: input.driverName, 210 - timestamp: startedAt, 372 + executor: input.executorName, 373 + timestamp: submittedAt, 374 + status: "pending", 211 375 }); 376 + }), 212 377 213 - yield* appendTier1Event( 214 - lifecycleStateRef, 215 - sequenceRef, 216 - runStore, 217 - runInput.runId, 218 - (sequence, timestamp) => ({ 219 - ...makeEventEnvelope(runInput.runId, sequence, timestamp), 220 - type: "run:start", 221 - payload: { 222 - programPath: runInput.programPath, 223 - }, 224 - }), 378 + runSync: (runInput) => 379 + Effect.gen(function* () { 380 + const existingRun = yield* Effect.catchTag( 381 + runStore.getRun(runInput.runId), 382 + "RunNotFoundError", 383 + () => Effect.succeed(undefined), 225 384 ); 226 385 227 - yield* appendTier1Event( 228 - lifecycleStateRef, 229 - sequenceRef, 230 - runStore, 231 - runInput.runId, 232 - (sequence, timestamp) => ({ 233 - ...makeEventEnvelope(runInput.runId, sequence, timestamp), 234 - type: "run:status", 235 - payload: { 236 - status: "running", 237 - }, 238 - }), 386 + let activeRun = existingRun; 387 + 388 + if (activeRun === undefined) { 389 + const startedAt = yield* toIsoTimestamp; 390 + 391 + activeRun = yield* runStore.create({ 392 + runId: runInput.runId, 393 + programPath: runInput.programPath, 394 + driver: input.driverName, 395 + executor: input.executorName, 396 + timestamp: startedAt, 397 + status: "running", 398 + }); 399 + } 400 + 401 + if (isRunTerminalStatus(activeRun.status)) { 402 + const existingResult = yield* runStore.getResult(runInput.runId); 403 + 404 + if (existingResult !== undefined) { 405 + return { 406 + run: activeRun, 407 + result: existingResult, 408 + } satisfies RunSyncOutput; 409 + } 410 + 411 + return yield* Effect.fail( 412 + new ProgramExecutionError({ 413 + runId: runInput.runId, 414 + message: `Run ${runInput.runId} is terminal (${activeRun.status}) but result.json is missing.`, 415 + }), 416 + ); 417 + } 418 + 419 + if (activeRun.status === "pending") { 420 + const runningAt = yield* toIsoTimestamp; 421 + activeRun = yield* runStore.setStatus(runInput.runId, "running", runningAt); 422 + } 423 + 424 + const existingEvents = yield* runStore.readEvents(runInput.runId); 425 + 426 + let lifecycleState = initialLifecycleGuardState; 427 + 428 + for (const event of existingEvents) { 429 + lifecycleState = yield* applyLifecycleTransition(lifecycleState, event); 430 + } 431 + 432 + const existingSpawnCount = existingEvents.filter( 433 + (event) => event.type === "spawn:start", 434 + ).length; 435 + const existingSpawnResults = existingEvents 436 + .filter( 437 + (event): event is Extract<MillEvent, { type: "spawn:complete" }> => 438 + event.type === "spawn:complete", 439 + ) 440 + .map((event) => event.payload.result); 441 + 442 + const maxSequence = existingEvents.reduce( 443 + (currentMax, event) => (event.sequence > currentMax ? event.sequence : currentMax), 444 + 0, 239 445 ); 240 446 447 + const lifecycleStateRef = yield* Ref.make(lifecycleState); 448 + const sequenceRef = yield* Ref.make(maxSequence); 449 + const spawnCounterRef = yield* Ref.make(existingSpawnCount); 450 + const spawnResultsRef = yield* Ref.make<ReadonlyArray<SpawnResult>>(existingSpawnResults); 451 + const extensionContext: ExtensionContext = { 452 + runId: runInput.runId, 453 + driverName: input.driverName, 454 + executorName: input.executorName, 455 + }; 456 + 457 + if (existingEvents.length === 0) { 458 + yield* runExtensionSetupHooks( 459 + input.extensions, 460 + extensionContext, 461 + lifecycleStateRef, 462 + sequenceRef, 463 + runStore, 464 + runInput.runId, 465 + ); 466 + 467 + yield* appendTier1EventWithHooks( 468 + input.extensions, 469 + extensionContext, 470 + lifecycleStateRef, 471 + sequenceRef, 472 + runStore, 473 + runInput.runId, 474 + (sequence, timestamp) => ({ 475 + ...makeEventEnvelope(runInput.runId, sequence, timestamp), 476 + type: "run:start", 477 + payload: { 478 + programPath: runInput.programPath, 479 + }, 480 + }), 481 + ); 482 + 483 + yield* appendTier1EventWithHooks( 484 + input.extensions, 485 + extensionContext, 486 + lifecycleStateRef, 487 + sequenceRef, 488 + runStore, 489 + runInput.runId, 490 + (sequence, timestamp) => ({ 491 + ...makeEventEnvelope(runInput.runId, sequence, timestamp), 492 + type: "run:status", 493 + payload: { 494 + status: "running", 495 + }, 496 + }), 497 + ); 498 + } 499 + 241 500 const spawn = ( 242 501 spawnInput: SpawnOptions, 243 502 ): Effect.Effect< ··· 262 521 }, 263 522 }; 264 523 265 - yield* appendTier1Event( 524 + yield* appendTier1EventWithHooks( 525 + input.extensions, 526 + extensionContext, 266 527 lifecycleStateRef, 267 528 sequenceRef, 268 529 runStore, ··· 295 556 const failureMessage = Cause.pretty(driverOutputExit.cause); 296 557 297 558 yield* appendSpawnErrorEvent( 559 + input.extensions, 560 + extensionContext, 298 561 lifecycleStateRef, 299 562 sequenceRef, 300 563 runStore, ··· 324 587 }, 325 588 }; 326 589 327 - yield* appendTier1Event( 590 + yield* appendTier1EventWithHooks( 591 + input.extensions, 592 + extensionContext, 328 593 lifecycleStateRef, 329 594 sequenceRef, 330 595 runStore, ··· 348 613 }, 349 614 }; 350 615 351 - yield* appendTier1Event( 616 + yield* appendTier1EventWithHooks( 617 + input.extensions, 618 + extensionContext, 352 619 lifecycleStateRef, 353 620 sequenceRef, 354 621 runStore, ··· 362 629 } 363 630 364 631 const spawnResultExit = yield* Effect.exit( 365 - Effect.mapError(decodeSpawnResult(driverOutputExit.value.result), (error) => 366 - new ProgramExecutionError({ 367 - runId: runInput.runId, 368 - message: `Spawn result decode failed: ${toMessage(error)}`, 369 - }), 632 + Effect.mapError( 633 + decodeSpawnResult(driverOutputExit.value.result), 634 + (error) => 635 + new ProgramExecutionError({ 636 + runId: runInput.runId, 637 + message: `Spawn result decode failed: ${toMessage(error)}`, 638 + }), 370 639 ), 371 640 ); 372 641 ··· 374 643 const failureMessage = Cause.pretty(spawnResultExit.cause); 375 644 376 645 yield* appendSpawnErrorEvent( 646 + input.extensions, 647 + extensionContext, 377 648 lifecycleStateRef, 378 649 sequenceRef, 379 650 runStore, ··· 402 673 }, 403 674 }; 404 675 405 - yield* appendTier1Event( 676 + yield* appendTier1EventWithHooks( 677 + input.extensions, 678 + extensionContext, 406 679 lifecycleStateRef, 407 680 sequenceRef, 408 681 runStore, ··· 421 694 const executionExit = yield* Effect.exit(runInput.executeProgram(spawn)); 422 695 const completedAt = yield* toIsoTimestamp; 423 696 const spawnResults = yield* Ref.get(spawnResultsRef); 697 + const startedAt = activeRun.createdAt; 424 698 425 699 if (Exit.isSuccess(executionExit)) { 426 700 const runResult: RunResult = { ··· 435 709 : JSON.stringify(executionExit.value), 436 710 }; 437 711 438 - yield* appendTier1Event( 712 + yield* appendTier1EventWithHooks( 713 + input.extensions, 714 + extensionContext, 439 715 lifecycleStateRef, 440 716 sequenceRef, 441 717 runStore, ··· 469 745 errorMessage: failureMessage, 470 746 }; 471 747 472 - yield* appendTier1Event( 748 + yield* appendTier1EventWithHooks( 749 + input.extensions, 750 + extensionContext, 473 751 lifecycleStateRef, 474 752 sequenceRef, 475 753 runStore, ··· 494 772 }), 495 773 496 774 status: (runId) => runStore.getRun(runId), 775 + 776 + result: (runId) => runStore.getResult(runId), 497 777 498 778 wait: (runId, timeout) => { 499 779 const timeoutMillis = toTimeoutMillis(timeout);
+68
packages/core/src/internal/executor-registry.effect.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { Effect } from "effect"; 3 + import { runWithRuntime } from "../public/test-runtime.api"; 4 + import type { ExecutorRegistration } from "../public/types"; 5 + import { makeExecutorRegistry } from "./executor-registry.effect"; 6 + 7 + const makeExecutor = (name: string): ExecutorRegistration => ({ 8 + description: `${name} executor`, 9 + runtime: { 10 + name, 11 + runProgram: (input) => 12 + Effect.zipRight( 13 + Effect.sync(() => { 14 + (globalThis as { __millExecutorName?: string }).__millExecutorName = name; 15 + }), 16 + input.execute, 17 + ), 18 + }, 19 + }); 20 + 21 + describe("makeExecutorRegistry", () => { 22 + it("resolves configured default executor when no override is provided", async () => { 23 + const registry = makeExecutorRegistry({ 24 + defaultExecutor: "direct", 25 + executors: { 26 + direct: makeExecutor("direct"), 27 + vm: makeExecutor("vm"), 28 + }, 29 + }); 30 + 31 + const selected = await runWithRuntime(registry.resolve(undefined)); 32 + 33 + expect(selected.name).toBe("direct"); 34 + expect(selected.registration.description).toBe("direct executor"); 35 + }); 36 + 37 + it("resolves explicit override executor when available", async () => { 38 + const registry = makeExecutorRegistry({ 39 + defaultExecutor: "direct", 40 + executors: { 41 + direct: makeExecutor("direct"), 42 + vm: makeExecutor("vm"), 43 + }, 44 + }); 45 + 46 + const selected = await runWithRuntime(registry.resolve("vm")); 47 + 48 + expect(selected.name).toBe("vm"); 49 + expect(selected.registration.description).toBe("vm executor"); 50 + }); 51 + 52 + it("fails with a typed registry error for unknown executors", async () => { 53 + const registry = makeExecutorRegistry({ 54 + defaultExecutor: "direct", 55 + executors: { 56 + direct: makeExecutor("direct"), 57 + }, 58 + }); 59 + 60 + const selectionError = await runWithRuntime(Effect.flip(registry.resolve("missing"))); 61 + 62 + expect(selectionError).toMatchObject({ 63 + _tag: "ExecutorRegistryError", 64 + requested: "missing", 65 + available: ["direct"], 66 + }); 67 + }); 68 + });
+61
packages/core/src/internal/executor-registry.effect.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { ExecutorRegistration, ExecutorRuntime } from "../public/types"; 3 + 4 + export class ExecutorRegistryError extends Data.TaggedError("ExecutorRegistryError")<{ 5 + requested: string; 6 + available: ReadonlyArray<string>; 7 + message: string; 8 + }> {} 9 + 10 + export interface ExecutorRegistry { 11 + readonly list: ReadonlyArray<string>; 12 + readonly resolve: (name: string | undefined) => Effect.Effect< 13 + { 14 + readonly name: string; 15 + readonly registration: ExecutorRegistration; 16 + readonly runtime: ExecutorRuntime; 17 + }, 18 + ExecutorRegistryError 19 + >; 20 + } 21 + 22 + export interface MakeExecutorRegistryInput { 23 + readonly defaultExecutor: string; 24 + readonly executors: Readonly<Record<string, ExecutorRegistration>>; 25 + } 26 + 27 + const sortedExecutorNames = ( 28 + executors: Readonly<Record<string, ExecutorRegistration>>, 29 + ): ReadonlyArray<string> => Object.keys(executors).sort((left, right) => left.localeCompare(right)); 30 + 31 + const missingExecutorError = ( 32 + requested: string, 33 + available: ReadonlyArray<string>, 34 + ): ExecutorRegistryError => 35 + new ExecutorRegistryError({ 36 + requested, 37 + available, 38 + message: `Unknown executor '${requested}'. Available executors: ${available.join(", ")}.`, 39 + }); 40 + 41 + export const makeExecutorRegistry = (input: MakeExecutorRegistryInput): ExecutorRegistry => { 42 + const available = sortedExecutorNames(input.executors); 43 + 44 + return { 45 + list: available, 46 + resolve: (name) => { 47 + const requested = name ?? input.defaultExecutor; 48 + const registration = input.executors[requested]; 49 + 50 + if (registration === undefined) { 51 + return Effect.fail(missingExecutorError(requested, available)); 52 + } 53 + 54 + return Effect.succeed({ 55 + name: requested, 56 + registration, 57 + runtime: registration.runtime, 58 + }); 59 + }, 60 + }; 61 + };
+3 -4
packages/core/src/internal/lifecycle-guard.effect.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 - import { Effect, Runtime, type Effect as EffectType } from "effect"; 2 + import { Effect, type Effect as EffectType } from "effect"; 3 3 import { decodeRunIdSync, decodeSpawnIdSync } from "../domain/run.schema"; 4 + import { runWithRuntime } from "../public/test-runtime.api"; 4 5 import { 5 6 applyLifecycleTransition, 6 7 initialLifecycleGuardState, 7 8 type LifecycleGuardState, 8 9 } from "./lifecycle-guard.effect"; 9 10 10 - const runtime = Runtime.defaultRuntime; 11 - const runEffect = <A, E>(effect: EffectType<A, E>): Promise<A> => 12 - Runtime.runPromise(runtime)(effect); 11 + const runEffect = <A, E>(effect: EffectType<A, E>): Promise<A> => runWithRuntime(effect); 13 12 14 13 const runId = decodeRunIdSync("run_lifecycle_guard"); 15 14 const spawnId = decodeSpawnIdSync("spawn_lifecycle_guard");
+4 -1
packages/core/src/internal/lifecycle-guard.effect.ts
··· 2 2 import type { MillEvent } from "../domain/event.schema"; 3 3 import type { RunStatus } from "../domain/run.schema"; 4 4 5 - type RunTerminalEventType = Extract<MillEvent["type"], "run:complete" | "run:failed" | "run:cancelled">; 5 + type RunTerminalEventType = Extract< 6 + MillEvent["type"], 7 + "run:complete" | "run:failed" | "run:cancelled" 8 + >; 6 9 type SpawnTerminalEventType = Extract< 7 10 MillEvent["type"], 8 11 "spawn:complete" | "spawn:error" | "spawn:cancelled"
+41 -15
packages/core/src/internal/run-store.effect.ts
··· 1 1 import * as FileSystem from "@effect/platform/FileSystem"; 2 2 import { Data, Effect } from "effect"; 3 - import { 4 - decodeMillEventJson, 5 - encodeMillEventJson, 6 - type MillEvent, 7 - } from "../domain/event.schema"; 3 + import { decodeMillEventJson, encodeMillEventJson, type MillEvent } from "../domain/event.schema"; 8 4 import { 9 5 decodeRunRecordJson, 6 + decodeRunResultJson, 10 7 type RunId, 11 8 type RunRecord, 12 9 type RunResult, ··· 24 21 readonly runId: RunId; 25 22 readonly programPath: string; 26 23 readonly driver: string; 24 + readonly executor?: string; 27 25 readonly timestamp: string; 26 + readonly status?: RunRecord["status"]; 28 27 } 29 28 30 29 export interface RunStore { ··· 37 36 runId: RunId, 38 37 status: RunRecord["status"], 39 38 timestamp: string, 40 - ) => Effect.Effect< 41 - RunRecord, 42 - RunNotFoundError | PersistenceError | LifecycleInvariantError 43 - >; 39 + ) => Effect.Effect<RunRecord, RunNotFoundError | PersistenceError | LifecycleInvariantError>; 44 40 readonly setResult: ( 45 41 runId: RunId, 46 42 result: RunResult, 47 43 timestamp: string, 48 - ) => Effect.Effect< 49 - void, 50 - RunNotFoundError | PersistenceError | LifecycleInvariantError 51 - >; 44 + ) => Effect.Effect<void, RunNotFoundError | PersistenceError | LifecycleInvariantError>; 52 45 readonly getRun: (runId: RunId) => Effect.Effect<RunRecord, RunNotFoundError | PersistenceError>; 46 + readonly getResult: ( 47 + runId: RunId, 48 + ) => Effect.Effect<RunResult | undefined, RunNotFoundError | PersistenceError>; 53 49 } 54 50 55 51 export interface MakeRunStoreInput { ··· 113 109 const paths = buildPaths(input.runsDirectory, createInput.runId); 114 110 const runRecord: RunRecord = { 115 111 id: createInput.runId, 116 - status: "running", 112 + status: createInput.status ?? "running", 117 113 programPath: createInput.programPath, 118 114 driver: createInput.driver, 115 + executor: createInput.executor ?? "direct", 119 116 createdAt: createInput.timestamp, 120 117 updatedAt: createInput.timestamp, 121 118 paths, ··· 180 177 const runRecord = yield* storeGetRun(input.runsDirectory, runId); 181 178 182 179 yield* mapPersistenceError(runRecord.paths.resultFile)( 183 - fileSystem.writeFileString(runRecord.paths.resultFile, `${JSON.stringify(result, null, 2)}\n`), 180 + fileSystem.writeFileString( 181 + runRecord.paths.resultFile, 182 + `${JSON.stringify(result, null, 2)}\n`, 183 + ), 184 184 ); 185 185 186 186 yield* storeSetStatus(input.runsDirectory, runId, result.status, timestamp); 187 187 }), 188 188 189 189 getRun: (runId) => storeGetRun(input.runsDirectory, runId), 190 + 191 + getResult: (runId) => 192 + Effect.gen(function* () { 193 + const fileSystem = yield* FileSystem.FileSystem; 194 + const runRecord = yield* storeGetRun(input.runsDirectory, runId); 195 + const hasResult = yield* mapPersistenceError(runRecord.paths.resultFile)( 196 + fileSystem.exists(runRecord.paths.resultFile), 197 + ); 198 + 199 + if (!hasResult) { 200 + return undefined; 201 + } 202 + 203 + const resultContent = yield* mapPersistenceError(runRecord.paths.resultFile)( 204 + fileSystem.readFileString(runRecord.paths.resultFile, "utf-8"), 205 + ); 206 + 207 + return yield* Effect.mapError( 208 + decodeRunResultJson(resultContent), 209 + (error) => 210 + new PersistenceError({ 211 + path: runRecord.paths.resultFile, 212 + message: toMessage(error), 213 + }), 214 + ); 215 + }), 190 216 }); 191 217 192 218 const storeGetRun = (
+20
packages/core/src/public/config-loader.api.test.ts
··· 8 8 9 9 const makeDefaults = (): MillConfig => ({ 10 10 defaultDriver: "default", 11 + defaultExecutor: "direct", 11 12 defaultModel: "openai/gpt-5.3-codex", 12 13 drivers: { 13 14 default: { ··· 23 24 }, 24 25 }, 25 26 }, 27 + executors: { 28 + direct: { 29 + description: "Direct test executor", 30 + runtime: { 31 + name: "direct", 32 + runProgram: (input) => input.execute, 33 + }, 34 + }, 35 + vm: { 36 + description: "VM test executor", 37 + runtime: { 38 + name: "vm", 39 + runProgram: (input) => input.execute, 40 + }, 41 + }, 42 + }, 43 + extensions: [], 26 44 authoring: { 27 45 instructions: "from-defaults", 28 46 }, ··· 157 175 'const instructions = [`Use systemPrompt for WHO.`, `Use prompt for WHAT.`].join(" ");', 158 176 "export default {", 159 177 ' defaultDriver: "pi-local" as const,', 178 + ' defaultExecutor: "vm" as const,', 160 179 ' defaultModel: "openai/gpt-5.3-codex" as const,', 161 180 " authoring: {", 162 181 " instructions,", ··· 176 195 expect(resolved.source).toBe("cwd"); 177 196 expect(resolved.configPath).toBe(configPath); 178 197 expect(resolved.config.defaultDriver).toBe("pi-local"); 198 + expect(resolved.config.defaultExecutor).toBe("vm"); 179 199 expect(resolved.config.defaultModel).toBe("openai/gpt-5.3-codex"); 180 200 expect(resolved.config.authoring.instructions).toBe( 181 201 "Use systemPrompt for WHO. Use prompt for WHAT.",
+2
packages/core/src/public/config-loader.api.ts
··· 85 85 86 86 const parseConfigOverridesFromText = (source: string): ConfigOverrides => ({ 87 87 defaultDriver: extractConfigString(source, "defaultDriver"), 88 + defaultExecutor: extractConfigString(source, "defaultExecutor"), 88 89 defaultModel: extractConfigString(source, "defaultModel"), 89 90 authoringInstructions: extractAuthoringInstructions(source), 90 91 }); ··· 158 159 const mergeConfig = (defaults: MillConfig, overrides: ConfigOverrides): MillConfig => ({ 159 160 ...defaults, 160 161 defaultDriver: overrides.defaultDriver ?? defaults.defaultDriver, 162 + defaultExecutor: overrides.defaultExecutor ?? defaults.defaultExecutor, 161 163 defaultModel: overrides.defaultModel ?? defaults.defaultModel, 162 164 authoring: { 163 165 instructions: overrides.authoringInstructions ?? defaults.authoring.instructions,
+33
packages/core/src/public/discovery.api.test.ts
··· 5 5 6 6 const makeDefaults = (): MillConfig => ({ 7 7 defaultDriver: "default", 8 + defaultExecutor: "direct", 8 9 defaultModel: "openai/gpt-5.3-codex", 9 10 drivers: { 10 11 default: { ··· 19 20 modelCatalog: Effect.succeed(["provider/model-a", "provider/model-b"]), 20 21 }, 21 22 }, 23 + codex: { 24 + description: "Codex adapter", 25 + modelFormat: "provider/model-id", 26 + process: { 27 + command: "codex", 28 + args: [], 29 + env: {}, 30 + }, 31 + codec: { 32 + modelCatalog: Effect.succeed(["openai/gpt-5.3-codex"]), 33 + }, 34 + }, 22 35 }, 36 + executors: { 37 + direct: { 38 + description: "direct", 39 + runtime: { 40 + name: "direct", 41 + runProgram: (input) => input.execute, 42 + }, 43 + }, 44 + vm: { 45 + description: "vm", 46 + runtime: { 47 + name: "vm", 48 + runProgram: (input) => input.execute, 49 + }, 50 + }, 51 + }, 52 + extensions: [], 23 53 authoring: { 24 54 instructions: "from-defaults", 25 55 }, ··· 47 77 "stopReason", 48 78 ]); 49 79 expect(payload.authoring.instructions).toBe("from-defaults"); 80 + expect(payload.executors.direct?.description).toBe("direct"); 81 + expect(payload.executors.vm?.description).toBe("vm"); 50 82 expect(payload.async).toEqual({ 51 83 submit: "mill run <program.ts> --json", 52 84 status: "mill status <runId> --json", ··· 63 95 }); 64 96 65 97 expect(payload.drivers.default?.models).toEqual(["provider/model-a", "provider/model-b"]); 98 + expect(payload.drivers.codex?.models).toEqual(["openai/gpt-5.3-codex"]); 66 99 }); 67 100 68 101 it("applies authoring instructions from resolved config overrides", async () => {
+14
packages/core/src/public/discovery.api.ts
··· 25 25 (entries) => Object.fromEntries(entries), 26 26 ); 27 27 28 + const buildDiscoveryExecutors = ( 29 + executors: ResolveConfigOptions["defaults"]["executors"], 30 + ): DiscoveryPayload["executors"] => 31 + Object.fromEntries( 32 + Object.entries(executors).map(([executorName, registration]) => [ 33 + executorName, 34 + { 35 + description: registration.description, 36 + }, 37 + ]), 38 + ); 39 + 28 40 export const createDiscoveryPayload = async ( 29 41 options: ResolveConfigOptions, 30 42 ): Promise<DiscoveryPayload> => { ··· 33 45 const drivers = await Runtime.runPromise(runtime)( 34 46 buildDiscoveryDrivers(resolvedConfig.config.drivers), 35 47 ); 48 + const executors = buildDiscoveryExecutors(resolvedConfig.config.executors); 36 49 37 50 return { 38 51 discoveryVersion: 1, ··· 42 55 resultFields: ["text", "sessionRef", "agent", "model", "driver", "exitCode", "stopReason"], 43 56 }, 44 57 drivers, 58 + executors, 45 59 authoring: { 46 60 instructions: resolvedConfig.config.authoring.instructions, 47 61 },
+197
packages/core/src/public/run.api.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { Effect } from "effect"; 6 + import { decodeMillEventJsonSync } from "../domain/event.schema"; 7 + import { runProgramSync, runWorker } from "./run.api"; 8 + import type { MillConfig } from "./types"; 9 + 10 + const makeConfig = (): MillConfig => ({ 11 + defaultDriver: "default", 12 + defaultExecutor: "direct", 13 + defaultModel: "openai/gpt-5.3-codex", 14 + drivers: { 15 + default: { 16 + description: "default driver", 17 + modelFormat: "provider/model-id", 18 + process: { 19 + command: "default", 20 + args: [], 21 + env: {}, 22 + }, 23 + codec: { 24 + modelCatalog: Effect.succeed(["default/model"]), 25 + }, 26 + runtime: { 27 + name: "default", 28 + spawn: (input) => 29 + Effect.succeed({ 30 + events: [ 31 + { 32 + type: "milestone", 33 + message: `default:${input.agent}`, 34 + }, 35 + ], 36 + result: { 37 + text: `default:${input.prompt}`, 38 + sessionRef: `session/default/${input.agent}`, 39 + agent: input.agent, 40 + model: input.model, 41 + driver: "default", 42 + exitCode: 0, 43 + }, 44 + }), 45 + }, 46 + }, 47 + codex: { 48 + description: "codex driver", 49 + modelFormat: "provider/model-id", 50 + process: { 51 + command: "codex", 52 + args: [], 53 + env: {}, 54 + }, 55 + codec: { 56 + modelCatalog: Effect.succeed(["openai/gpt-5.3-codex"]), 57 + }, 58 + runtime: { 59 + name: "codex", 60 + spawn: (input) => 61 + Effect.succeed({ 62 + events: [ 63 + { 64 + type: "milestone", 65 + message: `codex:${input.agent}`, 66 + }, 67 + ], 68 + result: { 69 + text: `codex:${input.prompt}`, 70 + sessionRef: `session/codex/${input.agent}`, 71 + agent: input.agent, 72 + model: input.model, 73 + driver: "codex", 74 + exitCode: 0, 75 + }, 76 + }), 77 + }, 78 + }, 79 + }, 80 + executors: { 81 + direct: { 82 + description: "direct executor", 83 + runtime: { 84 + name: "direct", 85 + runProgram: (input) => 86 + Effect.zipRight( 87 + Effect.sync(() => { 88 + (globalThis as { __millExecutorName?: string }).__millExecutorName = "direct"; 89 + }), 90 + input.execute, 91 + ), 92 + }, 93 + }, 94 + vm: { 95 + description: "vm executor", 96 + runtime: { 97 + name: "vm", 98 + runProgram: (input) => 99 + Effect.zipRight( 100 + Effect.sync(() => { 101 + (globalThis as { __millExecutorName?: string }).__millExecutorName = "vm"; 102 + }), 103 + input.execute, 104 + ), 105 + }, 106 + }, 107 + }, 108 + extensions: [ 109 + { 110 + name: "tools", 111 + setup: () => Effect.fail("setup exploded"), 112 + onEvent: (event) => 113 + event.type === "spawn:start" ? Effect.fail("event exploded") : Effect.void, 114 + api: { 115 + echo: (...args) => Effect.succeed(`echo:${String(args[0] ?? "")}`), 116 + }, 117 + }, 118 + ], 119 + authoring: { 120 + instructions: "use spawn + extension APIs", 121 + }, 122 + }); 123 + 124 + describe("run.api integration", () => { 125 + it("selects driver/executor overrides, injects extension API, and emits extension:error events", async () => { 126 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-run-api-")); 127 + const homeDirectory = join(tempDirectory, "home"); 128 + const programPath = join(tempDirectory, "program.ts"); 129 + 130 + await writeFile( 131 + programPath, 132 + [ 133 + 'const note = await mill.tools.echo("hello");', 134 + "const spawned = await mill.spawn({", 135 + ' agent: "scout",', 136 + ' systemPrompt: "You are concise.",', 137 + " prompt: note,", 138 + "});", 139 + 'return JSON.stringify({ note, driver: spawned.driver, executor: globalThis.__millExecutorName ?? "unknown" });', 140 + ].join("\n"), 141 + "utf-8", 142 + ); 143 + 144 + const defaults = makeConfig(); 145 + 146 + try { 147 + const output = await runProgramSync({ 148 + defaults, 149 + programPath, 150 + cwd: tempDirectory, 151 + homeDirectory, 152 + pathExists: async () => false, 153 + driverName: "codex", 154 + executorName: "vm", 155 + launchWorker: async (launchInput) => { 156 + await runWorker({ 157 + defaults, 158 + runId: launchInput.runId, 159 + programPath: launchInput.programPath, 160 + cwd: launchInput.cwd, 161 + homeDirectory, 162 + runsDirectory: launchInput.runsDirectory, 163 + driverName: launchInput.driverName, 164 + executorName: launchInput.executorName, 165 + pathExists: async () => false, 166 + }); 167 + }, 168 + }); 169 + 170 + expect(output.run.status).toBe("complete"); 171 + expect(output.run.driver).toBe("codex"); 172 + expect(output.run.executor).toBe("vm"); 173 + expect(output.result.spawns[0]?.driver).toBe("codex"); 174 + 175 + const parsedProgramResult = JSON.parse(output.result.programResult ?? "{}") as { 176 + readonly note?: string; 177 + readonly driver?: string; 178 + readonly executor?: string; 179 + }; 180 + 181 + expect(parsedProgramResult.note).toBe("echo:hello"); 182 + expect(parsedProgramResult.driver).toBe("codex"); 183 + expect(parsedProgramResult.executor).toBe("vm"); 184 + 185 + const eventsContent = await readFile(output.run.paths.eventsFile, "utf-8"); 186 + const eventTypes = eventsContent 187 + .split("\n") 188 + .map((line) => line.trim()) 189 + .filter((line) => line.length > 0) 190 + .map((line) => decodeMillEventJsonSync(line).type); 191 + 192 + expect(eventTypes.includes("extension:error")).toBe(true); 193 + } finally { 194 + await rm(tempDirectory, { recursive: true, force: true }); 195 + } 196 + }); 197 + });
+204 -46
packages/core/src/public/run.api.ts
··· 1 1 import * as FileSystem from "@effect/platform/FileSystem"; 2 2 import * as BunContext from "@effect/platform-bun/BunContext"; 3 3 import { Effect, Runtime } from "effect"; 4 - import { makeMillEngine, type ProgramExecutionError } from "../engine.effect"; 4 + import { makeMillEngine, ProgramExecutionError } from "../engine.effect"; 5 + import { makeDriverRegistry } from "../driver-registry.effect"; 6 + import { makeExecutorRegistry } from "../executor-registry.effect"; 5 7 import { decodeRunIdSync, type RunRecord, type RunSyncOutput } from "../run.schema"; 8 + import { runDetachedWorker } from "../worker.effect"; 6 9 import { decodeSpawnOptions } from "../spawn.schema"; 7 10 import { resolveConfig } from "./config-loader.api"; 8 11 import type { 9 12 ConfigOverrides, 10 - DriverRegistration, 13 + ExecutorRuntime, 14 + ExtensionRegistration, 11 15 ResolveConfigOptions, 12 16 SpawnInput, 13 17 SpawnOutput, ··· 25 29 interface GlobalMillContext { 26 30 mill?: { 27 31 spawn: (input: SpawnInput) => Promise<SpawnOutput>; 32 + [name: string]: unknown; 28 33 }; 29 34 } 30 35 31 - interface RunProgramSyncInput extends ResolveConfigOptions { 32 - readonly programPath: string; 36 + interface BaseRunInput extends ResolveConfigOptions { 33 37 readonly driverName?: string; 38 + readonly executorName?: string; 34 39 readonly runsDirectory?: string; 35 40 } 36 41 42 + export interface SubmitRunInput extends BaseRunInput { 43 + readonly programPath: string; 44 + readonly launchWorker: (input: LaunchWorkerInput) => Promise<void>; 45 + } 46 + 47 + export interface RunProgramSyncInput extends SubmitRunInput { 48 + readonly waitTimeoutSeconds?: number; 49 + } 50 + 37 51 interface GetRunStatusInput extends Omit< 38 52 ResolveConfigOptions, 39 53 "pathExists" | "loadConfigOverrides" 40 54 > { 41 55 readonly runId: string; 42 56 readonly driverName?: string; 57 + readonly executorName?: string; 43 58 readonly runsDirectory?: string; 44 59 readonly pathExists?: (path: string) => Promise<boolean>; 45 60 readonly loadConfigOverrides?: (path: string) => Promise<ConfigOverrides>; ··· 49 64 readonly timeoutSeconds: number; 50 65 } 51 66 67 + export interface RunWorkerInput extends BaseRunInput { 68 + readonly runId: string; 69 + readonly programPath: string; 70 + } 71 + 72 + export interface LaunchWorkerInput { 73 + readonly runId: string; 74 + readonly programPath: string; 75 + readonly runsDirectory: string; 76 + readonly driverName: string; 77 + readonly executorName: string; 78 + readonly cwd: string; 79 + } 80 + 81 + interface EngineContext { 82 + readonly engine: ReturnType<typeof makeMillEngine>; 83 + readonly selectedDriverName: string; 84 + readonly selectedExecutorName: string; 85 + readonly selectedExecutorRuntime: ExecutorRuntime; 86 + readonly selectedExtensions: ReadonlyArray<ExtensionRegistration>; 87 + readonly runsDirectory: string; 88 + } 89 + 90 + const DEFAULT_SYNC_WAIT_TIMEOUT_SECONDS = 60 * 60 * 24 * 365; 91 + 52 92 const normalizePath = (path: string): string => { 53 93 if (path.length <= 1) { 54 94 return path; ··· 89 129 fileSystem.readFileString(programPath, "utf-8"), 90 130 ); 91 131 92 - const resolveRuntimeDriver = ( 93 - registration: DriverRegistration | undefined, 94 - fallback: DriverRegistration | undefined, 95 - ) => { 96 - if (registration?.runtime !== undefined) { 97 - return registration.runtime; 98 - } 132 + const writeSubmissionArtifacts = ( 133 + run: RunRecord, 134 + programSource: string, 135 + ): Effect.Effect<void, unknown, BunContext.BunContext> => 136 + Effect.flatMap(FileSystem.FileSystem, (fileSystem) => 137 + Effect.gen(function* () { 138 + const copiedProgramPath = joinPath(run.paths.runDir, "program.ts"); 139 + const logsDirectory = joinPath(run.paths.runDir, "logs"); 140 + const workerLogPath = joinPath(logsDirectory, "worker.log"); 141 + 142 + yield* fileSystem.writeFileString(copiedProgramPath, programSource); 143 + yield* fileSystem.makeDirectory(logsDirectory, { recursive: true }); 144 + yield* fileSystem.writeFileString(workerLogPath, ""); 145 + }), 146 + ); 147 + 148 + const toExtensionApiBridge = ( 149 + extensions: ReadonlyArray<ExtensionRegistration>, 150 + ): Readonly<Record<string, unknown>> => 151 + Object.fromEntries( 152 + extensions 153 + .filter((extension) => extension.api !== undefined) 154 + .map((extension) => { 155 + const api = extension.api ?? {}; 99 156 100 - return fallback?.runtime; 101 - }; 157 + return [ 158 + extension.name, 159 + Object.fromEntries( 160 + Object.entries(api).map(([methodName, method]) => [ 161 + methodName, 162 + (...args: ReadonlyArray<unknown>) => 163 + Runtime.runPromise(runtime)(Effect.provide(method(...args), BunContext.layer)), 164 + ]), 165 + ), 166 + ] as const; 167 + }), 168 + ); 102 169 103 170 const executeProgramWithInjectedMill = ( 104 171 programSource: string, 105 172 spawn: (input: SpawnInput) => Effect.Effect<SpawnOutput, unknown>, 173 + extensions: ReadonlyArray<ExtensionRegistration>, 106 174 ): Effect.Effect<unknown, ProgramExecutionError> => 107 175 Effect.tryPromise({ 108 176 try: async () => { 109 177 const globalContext = globalThis as GlobalMillContext; 110 178 const previousMill = globalContext.mill; 111 179 const programRunner = new ProgramAsyncFunction(programSource); 180 + const extensionApiBridge = toExtensionApiBridge(extensions); 112 181 113 182 globalContext.mill = { 114 183 spawn: async (input) => { 115 184 const decodedInput = await Runtime.runPromise(runtime)(decodeSpawnOptions(input)); 116 185 return Runtime.runPromise(runtime)(Effect.provide(spawn(decodedInput), BunContext.layer)); 117 186 }, 187 + ...extensionApiBridge, 118 188 }; 119 189 120 190 try { ··· 134 204 }), 135 205 }); 136 206 137 - const makeEngineForConfig = async ( 138 - input: GetRunStatusInput, 139 - ): Promise<ReturnType<typeof makeMillEngine>> => { 207 + const makeEngineForConfig = async (input: BaseRunInput): Promise<EngineContext> => { 140 208 const cwd = input.cwd ?? process.cwd(); 141 209 const resolvedConfig = await resolveConfig(input); 142 - const selectedDriverName = input.driverName ?? resolvedConfig.config.defaultDriver; 143 - const selectedDriver = resolvedConfig.config.drivers[selectedDriverName]; 144 - const fallbackDriver = resolvedConfig.config.drivers[resolvedConfig.config.defaultDriver]; 145 - const runtimeDriver = resolveRuntimeDriver(selectedDriver, fallbackDriver); 210 + const driverRegistry = makeDriverRegistry({ 211 + defaultDriver: resolvedConfig.config.defaultDriver, 212 + drivers: resolvedConfig.config.drivers, 213 + }); 214 + const executorRegistry = makeExecutorRegistry({ 215 + defaultExecutor: resolvedConfig.config.defaultExecutor, 216 + executors: resolvedConfig.config.executors, 217 + }); 218 + const selectedDriver = await Runtime.runPromise(runtime)( 219 + driverRegistry.resolve(input.driverName), 220 + ); 221 + const selectedExecutor = await Runtime.runPromise(runtime)( 222 + executorRegistry.resolve(input.executorName), 223 + ); 146 224 const runsDirectory = resolveRunsDirectory(cwd, input.homeDirectory, input.runsDirectory); 147 225 148 - return makeMillEngine({ 226 + return { 227 + selectedDriverName: selectedDriver.name, 228 + selectedExecutorName: selectedExecutor.name, 229 + selectedExecutorRuntime: selectedExecutor.runtime, 230 + selectedExtensions: resolvedConfig.config.extensions, 149 231 runsDirectory, 150 - driverName: selectedDriverName, 151 - defaultModel: resolvedConfig.config.defaultModel, 152 - driver: runtimeDriver ?? fallbackDriver.runtime!, 232 + engine: makeMillEngine({ 233 + runsDirectory, 234 + driverName: selectedDriver.name, 235 + executorName: selectedExecutor.name, 236 + defaultModel: resolvedConfig.config.defaultModel, 237 + driver: selectedDriver.runtime, 238 + extensions: resolvedConfig.config.extensions, 239 + }), 240 + }; 241 + }; 242 + 243 + export const submitRun = async (input: SubmitRunInput): Promise<RunRecord> => { 244 + const cwd = input.cwd ?? process.cwd(); 245 + const programPath = resolveProgramPath(cwd, input.programPath); 246 + const programSource = await runWithBunContext(readProgramSource(programPath)); 247 + const engineContext = await makeEngineForConfig(input); 248 + const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); 249 + 250 + const submittedRun = await runWithBunContext( 251 + engineContext.engine.submit({ 252 + runId, 253 + programPath, 254 + }), 255 + ); 256 + 257 + await runWithBunContext(writeSubmissionArtifacts(submittedRun, programSource)); 258 + 259 + const copiedProgramPath = joinPath(submittedRun.paths.runDir, "program.ts"); 260 + 261 + await input.launchWorker({ 262 + runId: submittedRun.id, 263 + programPath: copiedProgramPath, 264 + runsDirectory: engineContext.runsDirectory, 265 + driverName: engineContext.selectedDriverName, 266 + executorName: engineContext.selectedExecutorName, 267 + cwd, 153 268 }); 269 + 270 + return submittedRun; 154 271 }; 155 272 156 273 export const runProgramSync = async (input: RunProgramSyncInput): Promise<RunSyncOutput> => { 274 + const submittedRun = await submitRun(input); 275 + const timeoutSeconds = input.waitTimeoutSeconds ?? DEFAULT_SYNC_WAIT_TIMEOUT_SECONDS; 276 + 277 + const terminalRun = await waitForRun({ 278 + defaults: input.defaults, 279 + runId: submittedRun.id, 280 + timeoutSeconds, 281 + cwd: input.cwd, 282 + homeDirectory: input.homeDirectory, 283 + runsDirectory: input.runsDirectory, 284 + driverName: input.driverName, 285 + executorName: input.executorName, 286 + pathExists: input.pathExists, 287 + loadConfigOverrides: input.loadConfigOverrides, 288 + }); 289 + 290 + const engineContext = await makeEngineForConfig(input); 291 + const result = await runWithBunContext( 292 + engineContext.engine.result(decodeRunIdSync(submittedRun.id)), 293 + ); 294 + 295 + if (result === undefined) { 296 + throw new Error(`Run ${submittedRun.id} completed without persisted result.`); 297 + } 298 + 299 + return { 300 + run: terminalRun, 301 + result, 302 + }; 303 + }; 304 + 305 + export const runWorker = async (input: RunWorkerInput): Promise<RunSyncOutput> => { 157 306 const cwd = input.cwd ?? process.cwd(); 158 - const resolvedConfig = await resolveConfig(input); 159 - const selectedDriverName = input.driverName ?? resolvedConfig.config.defaultDriver; 160 - const selectedDriver = resolvedConfig.config.drivers[selectedDriverName]; 161 - const fallbackDriver = resolvedConfig.config.drivers[resolvedConfig.config.defaultDriver]; 162 - const runtimeDriver = resolveRuntimeDriver(selectedDriver, fallbackDriver); 163 307 const programPath = resolveProgramPath(cwd, input.programPath); 164 - const runsDirectory = resolveRunsDirectory(cwd, input.homeDirectory, input.runsDirectory); 165 - 166 308 const programSource = await runWithBunContext(readProgramSource(programPath)); 167 - 168 - const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); 169 - const engine = makeMillEngine({ 170 - runsDirectory, 171 - driverName: selectedDriverName, 172 - defaultModel: resolvedConfig.config.defaultModel, 173 - driver: runtimeDriver ?? fallbackDriver.runtime!, 174 - }); 309 + const engineContext = await makeEngineForConfig(input); 175 310 176 311 return runWithBunContext( 177 - engine.runSync({ 178 - runId, 312 + runDetachedWorker({ 313 + engine: engineContext.engine, 314 + runId: decodeRunIdSync(input.runId), 179 315 programPath, 180 - executeProgram: (spawn) => executeProgramWithInjectedMill(programSource, spawn), 316 + runsDirectory: engineContext.runsDirectory, 317 + executeProgram: (spawn) => 318 + Effect.mapError( 319 + engineContext.selectedExecutorRuntime.runProgram({ 320 + runId: input.runId, 321 + programPath, 322 + execute: executeProgramWithInjectedMill( 323 + programSource, 324 + spawn, 325 + engineContext.selectedExtensions, 326 + ), 327 + }), 328 + (error) => 329 + new ProgramExecutionError({ 330 + runId: input.runId, 331 + message: String(error), 332 + }), 333 + ), 181 334 }), 182 335 ); 183 336 }; 184 337 185 338 export const getRunStatus = async (input: GetRunStatusInput): Promise<RunRecord> => { 186 - const engine = await makeEngineForConfig(input); 339 + const engineContext = await makeEngineForConfig(input); 187 340 188 - return runWithBunContext(engine.status(decodeRunIdSync(input.runId))); 341 + return runWithBunContext(engineContext.engine.status(decodeRunIdSync(input.runId))); 189 342 }; 190 343 191 344 export const waitForRun = async (input: WaitForRunInput): Promise<RunRecord> => { 192 - const engine = await makeEngineForConfig(input); 345 + const engineContext = await makeEngineForConfig(input); 193 346 const waitOutcome = await runWithBunContext( 194 - Effect.either(engine.wait(decodeRunIdSync(input.runId), Math.round(input.timeoutSeconds * 1000))), 347 + Effect.either( 348 + engineContext.engine.wait( 349 + decodeRunIdSync(input.runId), 350 + Math.round(input.timeoutSeconds * 1000), 351 + ), 352 + ), 195 353 ); 196 354 197 355 if (waitOutcome._tag === "Right") {
+3
packages/core/src/public/test-runtime.api.ts
··· 3 3 4 4 const runtime = Runtime.defaultRuntime; 5 5 6 + export const runWithRuntime = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => 7 + Runtime.runPromise(runtime)(effect); 8 + 6 9 export const runWithBunContext = <A, E>( 7 10 effect: Effect.Effect<A, E, BunContext.BunContext>, 8 11 ): Promise<A> => Runtime.runPromise(runtime)(Effect.provide(effect, BunContext.layer));
+46
packages/core/src/public/types.ts
··· 47 47 readonly spawn: (input: DriverSpawnInput) => Effect.Effect<DriverSpawnOutput, unknown>; 48 48 } 49 49 50 + export interface ExecutorRunInput { 51 + readonly runId: string; 52 + readonly programPath: string; 53 + readonly execute: Effect.Effect<unknown, unknown>; 54 + } 55 + 56 + export interface ExecutorRuntime { 57 + readonly name: string; 58 + readonly runProgram: (input: ExecutorRunInput) => Effect.Effect<unknown, unknown>; 59 + } 60 + 61 + export interface ExtensionContext { 62 + readonly runId: string; 63 + readonly driverName: string; 64 + readonly executorName: string; 65 + } 66 + 67 + export interface ExtensionRegistration { 68 + readonly name: string; 69 + readonly setup?: (ctx: ExtensionContext) => Effect.Effect<void, unknown>; 70 + readonly onEvent?: ( 71 + event: { readonly type: string }, 72 + ctx: ExtensionContext, 73 + ) => Effect.Effect<void, unknown>; 74 + readonly api?: Readonly< 75 + Record<string, (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>> 76 + >; 77 + } 78 + 50 79 export interface Mill { 51 80 spawn(input: SpawnInput): Promise<SpawnOutput>; 52 81 } ··· 69 98 readonly runtime?: DriverRuntime; 70 99 } 71 100 101 + export interface ExecutorRegistration { 102 + readonly description: string; 103 + readonly runtime: ExecutorRuntime; 104 + } 105 + 72 106 export interface MillConfig { 73 107 readonly defaultDriver: string; 108 + readonly defaultExecutor: string; 74 109 readonly defaultModel: string; 75 110 readonly drivers: Readonly<Record<string, DriverRegistration>>; 111 + readonly executors: Readonly<Record<string, ExecutorRegistration>>; 112 + readonly extensions: ReadonlyArray<ExtensionRegistration>; 76 113 readonly authoring: { 77 114 readonly instructions: string; 78 115 }; ··· 95 132 } 96 133 > 97 134 >; 135 + readonly executors: Readonly< 136 + Record< 137 + string, 138 + { 139 + readonly description: string; 140 + } 141 + > 142 + >; 98 143 readonly authoring: { 99 144 readonly instructions: string; 100 145 }; ··· 109 154 110 155 export interface ConfigOverrides { 111 156 readonly defaultDriver?: string; 157 + readonly defaultExecutor?: string; 112 158 readonly defaultModel?: string; 113 159 readonly authoringInstructions?: string; 114 160 }
+122
packages/core/src/runtime/worker.effect.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { mkdtemp, readFile, rm } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { Effect } from "effect"; 6 + import { decodeMillEventJsonSync } from "../domain/event.schema"; 7 + import { decodeRunIdSync } from "../domain/run.schema"; 8 + import { runWithBunContext } from "../public/test-runtime.api"; 9 + import type { DriverRuntime } from "../public/types"; 10 + import { makeMillEngine } from "../internal/engine.effect"; 11 + import { makeRunStore } from "../internal/run-store.effect"; 12 + import { runDetachedWorker } from "./worker.effect"; 13 + 14 + const testDriver: DriverRuntime = { 15 + name: "test-driver", 16 + spawn: (input) => 17 + Effect.succeed({ 18 + events: [ 19 + { 20 + type: "milestone", 21 + message: `spawned:${input.agent}`, 22 + }, 23 + ], 24 + result: { 25 + text: `driver:${input.prompt}`, 26 + sessionRef: `session/${input.agent}`, 27 + agent: input.agent, 28 + model: input.model, 29 + driver: "test-driver", 30 + exitCode: 0, 31 + }, 32 + }), 33 + }; 34 + 35 + describe("runDetachedWorker", () => { 36 + it("finalizes exactly once and is idempotent on subsequent invocations", async () => { 37 + const runsDirectory = await mkdtemp(join(tmpdir(), "mill-worker-")); 38 + const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); 39 + 40 + const store = makeRunStore({ runsDirectory }); 41 + const engine = makeMillEngine({ 42 + runsDirectory, 43 + defaultModel: "openai/gpt-5.3-codex", 44 + driverName: "default", 45 + executorName: "direct", 46 + driver: testDriver, 47 + extensions: [], 48 + }); 49 + 50 + try { 51 + const submittedRun = await runWithBunContext( 52 + store.create({ 53 + runId, 54 + programPath: "/tmp/program.ts", 55 + driver: "default", 56 + executor: "direct", 57 + status: "pending", 58 + timestamp: "2026-02-23T20:00:00.000Z", 59 + }), 60 + ); 61 + 62 + const firstRun = await runWithBunContext( 63 + runDetachedWorker({ 64 + runId, 65 + programPath: submittedRun.programPath, 66 + runsDirectory, 67 + engine, 68 + executeProgram: (spawn) => 69 + Effect.gen(function* () { 70 + const result = yield* spawn({ 71 + agent: "scout", 72 + systemPrompt: "You are concise.", 73 + prompt: "Say hello", 74 + }); 75 + 76 + expect(result.sessionRef.length).toBeGreaterThan(0); 77 + }), 78 + }), 79 + ); 80 + 81 + expect(firstRun.run.status).toBe("complete"); 82 + 83 + const secondRun = await runWithBunContext( 84 + runDetachedWorker({ 85 + runId, 86 + programPath: submittedRun.programPath, 87 + runsDirectory, 88 + engine, 89 + executeProgram: () => 90 + Effect.die(new Error("second worker invocation must not re-execute program")), 91 + }), 92 + ); 93 + 94 + expect(secondRun.run.status).toBe("complete"); 95 + 96 + const eventsContent = await readFile(firstRun.run.paths.eventsFile, "utf-8"); 97 + const events = eventsContent 98 + .split("\n") 99 + .map((line) => line.trim()) 100 + .filter((line) => line.length > 0) 101 + .map((line) => decodeMillEventJsonSync(line)); 102 + 103 + const runTerminalEvents = events.filter( 104 + (event) => 105 + event.type === "run:complete" || 106 + event.type === "run:failed" || 107 + event.type === "run:cancelled", 108 + ); 109 + 110 + expect(runTerminalEvents).toHaveLength(1); 111 + 112 + const workerLog = await readFile( 113 + join(firstRun.run.paths.runDir, "logs", "worker.log"), 114 + "utf-8", 115 + ); 116 + expect(workerLog).toContain("worker:start"); 117 + expect(workerLog).toContain("worker:complete"); 118 + } finally { 119 + await rm(runsDirectory, { recursive: true, force: true }); 120 + } 121 + }); 122 + });
+110 -24
packages/core/src/runtime/worker.effect.ts
··· 1 - import { Effect } from "effect"; 2 - import type { RunId } from "../domain/run.schema"; 3 - import type { SpawnOptions, SpawnResult } from "../domain/spawn.schema"; 4 - import type { MillEngine } from "../internal/engine.effect"; 1 + import * as FileSystem from "@effect/platform/FileSystem"; 2 + import { Clock, Effect } from "effect"; 3 + import type { RunId, RunSyncOutput } from "../domain/run.schema"; 4 + import { 5 + ProgramExecutionError, 6 + type MillEngine, 7 + type RunSyncInput, 8 + } from "../internal/engine.effect"; 9 + import { PersistenceError, RunNotFoundError } from "../internal/run-store.effect"; 10 + import { LifecycleInvariantError } from "../internal/lifecycle-guard.effect"; 5 11 6 - export interface WorkerInput { 12 + export interface DetachedWorkerInput { 7 13 readonly engine: MillEngine; 8 14 readonly runId: RunId; 9 15 readonly programPath: string; 10 - readonly spawn: SpawnOptions; 16 + readonly runsDirectory: string; 17 + readonly executeProgram: RunSyncInput["executeProgram"]; 11 18 } 12 19 13 - export const runWorker = (input: WorkerInput): Effect.Effect<SpawnResult> => 14 - Effect.flatMap( 15 - input.engine.runSync({ 20 + const isTerminalStatus = (status: RunSyncOutput["run"]["status"]): boolean => 21 + status === "complete" || status === "failed" || status === "cancelled"; 22 + 23 + const normalizePath = (path: string): string => { 24 + if (path.length <= 1) { 25 + return path; 26 + } 27 + 28 + return path.endsWith("/") ? path.slice(0, -1) : path; 29 + }; 30 + 31 + const joinPath = (base: string, child: string): string => 32 + normalizePath(base) === "/" ? `/${child}` : `${normalizePath(base)}/${child}`; 33 + 34 + const toIsoTimestamp = Effect.map(Clock.currentTimeMillis, (millis) => 35 + new Date(millis).toISOString(), 36 + ); 37 + 38 + const appendWorkerLog = ( 39 + logFilePath: string, 40 + message: string, 41 + ): Effect.Effect<void, PersistenceError> => 42 + Effect.gen(function* () { 43 + const timestamp = yield* toIsoTimestamp; 44 + const fileSystem = yield* FileSystem.FileSystem; 45 + const logsDirectory = logFilePath.slice(0, logFilePath.lastIndexOf("/")); 46 + 47 + yield* Effect.mapError( 48 + fileSystem.makeDirectory(logsDirectory, { recursive: true }), 49 + (error) => 50 + new PersistenceError({ 51 + path: logsDirectory, 52 + message: String(error), 53 + }), 54 + ); 55 + 56 + yield* Effect.mapError( 57 + fileSystem.writeFileString(logFilePath, `${timestamp} ${message}\n`, { flag: "a" }), 58 + (error) => 59 + new PersistenceError({ 60 + path: logFilePath, 61 + message: String(error), 62 + }), 63 + ); 64 + }); 65 + 66 + export const runDetachedWorker = ( 67 + input: DetachedWorkerInput, 68 + ): Effect.Effect< 69 + RunSyncOutput, 70 + RunNotFoundError | PersistenceError | ProgramExecutionError | LifecycleInvariantError 71 + > => 72 + Effect.gen(function* () { 73 + const submittedRun = yield* input.engine.submit({ 16 74 runId: input.runId, 17 75 programPath: input.programPath, 18 - executeProgram: (spawn) => Effect.flatMap(spawn(input.spawn), () => Effect.void), 19 - }), 20 - (output) => 21 - Effect.succeed( 22 - output.result.spawns[0] ?? { 23 - text: "", 24 - sessionRef: "session/worker-missing", 25 - agent: input.spawn.agent, 26 - model: input.spawn.model ?? "unknown", 27 - driver: "unknown", 28 - exitCode: 1, 29 - errorMessage: "no spawn result", 30 - }, 31 - ), 32 - ); 76 + }); 77 + 78 + const runDirectory = 79 + submittedRun.paths.runDir.length > 0 80 + ? submittedRun.paths.runDir 81 + : joinPath(input.runsDirectory, input.runId); 82 + const workerLogPath = joinPath(runDirectory, "logs/worker.log"); 83 + 84 + yield* appendWorkerLog(workerLogPath, `worker:start runId=${input.runId}`); 85 + 86 + if (isTerminalStatus(submittedRun.status)) { 87 + const existingResult = yield* input.engine.result(input.runId); 88 + 89 + if (existingResult !== undefined) { 90 + yield* appendWorkerLog( 91 + workerLogPath, 92 + `worker:terminal-noop runId=${input.runId} status=${submittedRun.status}`, 93 + ); 94 + 95 + return { 96 + run: submittedRun, 97 + result: existingResult, 98 + } satisfies RunSyncOutput; 99 + } 100 + } 101 + 102 + const runOutput = yield* Effect.tapError( 103 + input.engine.runSync({ 104 + runId: input.runId, 105 + programPath: input.programPath, 106 + executeProgram: input.executeProgram, 107 + }), 108 + (error) => 109 + appendWorkerLog( 110 + workerLogPath, 111 + `worker:failed runId=${input.runId} message=${String(error)}`, 112 + ), 113 + ); 114 + 115 + yield* appendWorkerLog(workerLogPath, `worker:complete runId=${input.runId}`); 116 + 117 + return runOutput; 118 + });
+1
packages/core/src/worker.effect.ts
··· 1 + export * from "./runtime/worker.effect";
+41
packages/driver-claude/src/public/index.api.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import * as BunContext from "@effect/platform-bun/BunContext"; 3 + import { Effect, Runtime } from "effect"; 4 + import { createClaudeDriverRegistration } from "./index.api"; 5 + 6 + const runtime = Runtime.defaultRuntime; 7 + 8 + describe("createClaudeDriverRegistration", () => { 9 + it("exposes catalog-backed model discovery", async () => { 10 + const driver = createClaudeDriverRegistration(); 11 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 12 + 13 + expect(models).toEqual(["anthropic/claude-sonnet-4-6"]); 14 + expect(driver.runtime).toBeDefined(); 15 + }); 16 + 17 + it("spawns runtime outputs via generic driver contracts", async () => { 18 + const driver = createClaudeDriverRegistration(); 19 + 20 + if (driver.runtime === undefined) { 21 + throw new Error("driver runtime is required"); 22 + } 23 + 24 + const output = await Runtime.runPromise(runtime)( 25 + Effect.provide( 26 + driver.runtime.spawn({ 27 + runId: "run_claude_test", 28 + spawnId: "spawn_claude_test", 29 + agent: "scout", 30 + systemPrompt: "You are concise.", 31 + prompt: "Say hello", 32 + model: "anthropic/claude-sonnet-4-6", 33 + }), 34 + BunContext.layer, 35 + ), 36 + ); 37 + 38 + expect(output.result.driver).toBe("claude"); 39 + expect(output.result.sessionRef.length).toBeGreaterThan(0); 40 + }); 41 + });
+46 -1
packages/driver-claude/src/public/index.api.ts
··· 1 - import type { DriverProcessConfig } from "@mill/core"; 1 + import { Effect } from "effect"; 2 + import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 + 4 + const CLAUDE_MODELS: ReadonlyArray<string> = ["anthropic/claude-sonnet-4-6"]; 5 + 6 + export interface CreateClaudeDriverRegistrationInput { 7 + readonly process?: DriverProcessConfig; 8 + } 9 + 10 + export const createClaudeCodec = (): DriverCodec => ({ 11 + modelCatalog: Effect.succeed(CLAUDE_MODELS), 12 + }); 2 13 3 14 export const createClaudeDriverConfig = (): DriverProcessConfig => ({ 4 15 command: "claude", 5 16 args: [], 6 17 env: {}, 7 18 }); 19 + 20 + export const createClaudeDriverRegistration = ( 21 + input?: CreateClaudeDriverRegistrationInput, 22 + ): DriverRegistration => { 23 + const process = input?.process ?? createClaudeDriverConfig(); 24 + 25 + return { 26 + description: "Claude process driver", 27 + modelFormat: "provider/model-id", 28 + process, 29 + codec: createClaudeCodec(), 30 + runtime: { 31 + name: "claude", 32 + spawn: (spawnInput) => 33 + Effect.succeed({ 34 + events: [ 35 + { 36 + type: "milestone", 37 + message: `claude:${spawnInput.agent}`, 38 + }, 39 + ], 40 + result: { 41 + text: `claude:${spawnInput.prompt}`, 42 + sessionRef: `session/claude/${spawnInput.agent}`, 43 + agent: spawnInput.agent, 44 + model: spawnInput.model, 45 + driver: "claude", 46 + exitCode: 0, 47 + stopReason: "complete", 48 + }, 49 + }), 50 + }, 51 + }; 52 + };
+41
packages/driver-codex/src/public/index.api.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import * as BunContext from "@effect/platform-bun/BunContext"; 3 + import { Effect, Runtime } from "effect"; 4 + import { createCodexDriverRegistration } from "./index.api"; 5 + 6 + const runtime = Runtime.defaultRuntime; 7 + 8 + describe("createCodexDriverRegistration", () => { 9 + it("exposes catalog-backed model discovery", async () => { 10 + const driver = createCodexDriverRegistration(); 11 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 12 + 13 + expect(models).toEqual(["openai/gpt-5.3-codex"]); 14 + expect(driver.runtime).toBeDefined(); 15 + }); 16 + 17 + it("spawns runtime outputs via generic driver contracts", async () => { 18 + const driver = createCodexDriverRegistration(); 19 + 20 + if (driver.runtime === undefined) { 21 + throw new Error("driver runtime is required"); 22 + } 23 + 24 + const output = await Runtime.runPromise(runtime)( 25 + Effect.provide( 26 + driver.runtime.spawn({ 27 + runId: "run_codex_test", 28 + spawnId: "spawn_codex_test", 29 + agent: "scout", 30 + systemPrompt: "You are concise.", 31 + prompt: "Say hello", 32 + model: "openai/gpt-5.3-codex", 33 + }), 34 + BunContext.layer, 35 + ), 36 + ); 37 + 38 + expect(output.result.driver).toBe("codex"); 39 + expect(output.result.sessionRef.length).toBeGreaterThan(0); 40 + }); 41 + });
+46 -1
packages/driver-codex/src/public/index.api.ts
··· 1 - import type { DriverProcessConfig } from "@mill/core"; 1 + import { Effect } from "effect"; 2 + import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 + 4 + const CODEX_MODELS: ReadonlyArray<string> = ["openai/gpt-5.3-codex"]; 5 + 6 + export interface CreateCodexDriverRegistrationInput { 7 + readonly process?: DriverProcessConfig; 8 + } 9 + 10 + export const createCodexCodec = (): DriverCodec => ({ 11 + modelCatalog: Effect.succeed(CODEX_MODELS), 12 + }); 2 13 3 14 export const createCodexDriverConfig = (): DriverProcessConfig => ({ 4 15 command: "codex", 5 16 args: [], 6 17 env: {}, 7 18 }); 19 + 20 + export const createCodexDriverRegistration = ( 21 + input?: CreateCodexDriverRegistrationInput, 22 + ): DriverRegistration => { 23 + const process = input?.process ?? createCodexDriverConfig(); 24 + 25 + return { 26 + description: "Codex process driver", 27 + modelFormat: "provider/model-id", 28 + process, 29 + codec: createCodexCodec(), 30 + runtime: { 31 + name: "codex", 32 + spawn: (spawnInput) => 33 + Effect.succeed({ 34 + events: [ 35 + { 36 + type: "milestone", 37 + message: `codex:${spawnInput.agent}`, 38 + }, 39 + ], 40 + result: { 41 + text: `codex:${spawnInput.prompt}`, 42 + sessionRef: `session/codex/${spawnInput.agent}`, 43 + agent: spawnInput.agent, 44 + model: spawnInput.model, 45 + driver: "codex", 46 + exitCode: 0, 47 + stopReason: "complete", 48 + }, 49 + }), 50 + }, 51 + }; 52 + };
+4 -5
packages/driver-pi/src/internal/pi.codec.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 - import { Effect, Runtime } from "effect"; 2 + import { Effect } from "effect"; 3 + import { runWithRuntime } from "../public/test-runtime.api"; 3 4 import { decodePiProcessOutput } from "./pi.codec"; 4 - 5 - const runtime = Runtime.defaultRuntime; 6 5 7 6 describe("pi codec terminal sequencing", () => { 8 7 it("rejects duplicate final lines deterministically", async () => { ··· 26 25 }), 27 26 ].join("\n"); 28 27 29 - const decodeError = await Runtime.runPromise(runtime)(Effect.flip(decodePiProcessOutput(output))); 28 + const decodeError = await runWithRuntime(Effect.flip(decodePiProcessOutput(output))); 30 29 31 30 expect(decodeError).toMatchObject({ 32 31 _tag: "PiCodecError", ··· 46 45 JSON.stringify({ type: "tool_call", toolName: "grep" }), 47 46 ].join("\n"); 48 47 49 - const decodeError = await Runtime.runPromise(runtime)(Effect.flip(decodePiProcessOutput(output))); 48 + const decodeError = await runWithRuntime(Effect.flip(decodePiProcessOutput(output))); 50 49 51 50 expect(decodeError).toMatchObject({ 52 51 _tag: "PiCodecError",
+6
packages/driver-pi/src/public/test-runtime.api.ts
··· 1 + import { Runtime, type Effect } from "effect"; 2 + 3 + const runtime = Runtime.defaultRuntime; 4 + 5 + export const runWithRuntime = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => 6 + Runtime.runPromise(runtime)(effect);
+142
scripts/check-exports.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { 6 + checkExportBoundaries, 7 + collectWorkspacePackageJsonPaths, 8 + isInternalExportPath, 9 + } from "./check-exports"; 10 + 11 + describe("check-exports", () => { 12 + it("collects package.json files for all workspace globs", async () => { 13 + const workspaceRoot = await mkdtemp(join(tmpdir(), "mill-check-exports-workspaces-")); 14 + 15 + try { 16 + await writeFile( 17 + join(workspaceRoot, "package.json"), 18 + JSON.stringify( 19 + { 20 + name: "mill-fixture", 21 + private: true, 22 + workspaces: ["packages/*", "tools/*"], 23 + }, 24 + null, 25 + 2, 26 + ), 27 + "utf-8", 28 + ); 29 + 30 + await mkdir(join(workspaceRoot, "packages", "core"), { recursive: true }); 31 + await mkdir(join(workspaceRoot, "tools", "kit"), { recursive: true }); 32 + 33 + await writeFile( 34 + join(workspaceRoot, "packages", "core", "package.json"), 35 + JSON.stringify({ name: "@fixture/core", exports: { ".": "./src/public/index.api.ts" } }, null, 2), 36 + "utf-8", 37 + ); 38 + await writeFile( 39 + join(workspaceRoot, "tools", "kit", "package.json"), 40 + JSON.stringify({ name: "@fixture/kit", exports: { ".": "./src/public/index.api.ts" } }, null, 2), 41 + "utf-8", 42 + ); 43 + 44 + const packageJsonPaths = await collectWorkspacePackageJsonPaths(workspaceRoot); 45 + const relativePaths = packageJsonPaths 46 + .map((path) => path.replace(`${workspaceRoot}/`, "")) 47 + .sort(); 48 + 49 + expect(relativePaths).toEqual(["packages/core/package.json", "tools/kit/package.json"]); 50 + } finally { 51 + await rm(workspaceRoot, { recursive: true, force: true }); 52 + } 53 + }); 54 + 55 + it("flags exports that expose internal/runtime/domain paths", async () => { 56 + const workspaceRoot = await mkdtemp(join(tmpdir(), "mill-check-exports-invalid-")); 57 + 58 + try { 59 + await writeFile( 60 + join(workspaceRoot, "package.json"), 61 + JSON.stringify({ name: "mill-fixture", private: true, workspaces: ["packages/*"] }, null, 2), 62 + "utf-8", 63 + ); 64 + await mkdir(join(workspaceRoot, "packages", "core"), { recursive: true }); 65 + 66 + await writeFile( 67 + join(workspaceRoot, "packages", "core", "package.json"), 68 + JSON.stringify( 69 + { 70 + name: "@fixture/core", 71 + exports: { 72 + ".": "./src/public/index.api.ts", 73 + "./bad": { 74 + import: "./dist/internal/runtime.js", 75 + default: ["./dist/domain/model.js", "./dist/public/index.js"], 76 + }, 77 + }, 78 + }, 79 + null, 80 + 2, 81 + ), 82 + "utf-8", 83 + ); 84 + 85 + const result = await checkExportBoundaries(workspaceRoot); 86 + expect(result.packageCount).toBe(1); 87 + expect(result.violations).toHaveLength(1); 88 + expect(result.violations[0]?.packageName).toBe("@fixture/core"); 89 + expect(result.violations[0]?.invalidExports).toEqual([ 90 + "./dist/domain/model.js", 91 + "./dist/internal/runtime.js", 92 + ]); 93 + } finally { 94 + await rm(workspaceRoot, { recursive: true, force: true }); 95 + } 96 + }); 97 + 98 + it("flags exports with internal/runtime/domain subpath keys", async () => { 99 + const workspaceRoot = await mkdtemp(join(tmpdir(), "mill-check-exports-invalid-keys-")); 100 + 101 + try { 102 + await writeFile( 103 + join(workspaceRoot, "package.json"), 104 + JSON.stringify({ name: "mill-fixture", private: true, workspaces: ["packages/*"] }, null, 2), 105 + "utf-8", 106 + ); 107 + await mkdir(join(workspaceRoot, "packages", "core"), { recursive: true }); 108 + 109 + await writeFile( 110 + join(workspaceRoot, "packages", "core", "package.json"), 111 + JSON.stringify( 112 + { 113 + name: "@fixture/core", 114 + exports: { 115 + ".": "./src/public/index.api.ts", 116 + "./internal": "./dist/public/re-export.js", 117 + "./runtime/worker": { 118 + import: "./dist/public/runtime-worker.js", 119 + }, 120 + }, 121 + }, 122 + null, 123 + 2, 124 + ), 125 + "utf-8", 126 + ); 127 + 128 + const result = await checkExportBoundaries(workspaceRoot); 129 + expect(result.violations).toHaveLength(1); 130 + expect(result.violations[0]?.invalidExports).toEqual(["./internal", "./runtime/worker"]); 131 + } finally { 132 + await rm(workspaceRoot, { recursive: true, force: true }); 133 + } 134 + }); 135 + 136 + it("recognizes internal export paths", () => { 137 + expect(isInternalExportPath("./src/public/index.api.ts")).toBe(false); 138 + expect(isInternalExportPath("./src/internal/engine.effect.ts")).toBe(true); 139 + expect(isInternalExportPath("./dist/domain/run.schema.js")).toBe(true); 140 + expect(isInternalExportPath("./dist/runtime/worker.effect.js")).toBe(true); 141 + }); 142 + });
+127 -20
scripts/check-exports.ts
··· 1 - const normalizeExportEntries = (value: unknown): Array<string> => { 1 + import { join } from "node:path"; 2 + 3 + export interface ExportBoundaryViolation { 4 + readonly packageName: string; 5 + readonly packageJsonPath: string; 6 + readonly invalidExports: ReadonlyArray<string>; 7 + } 8 + 9 + export interface ExportBoundaryCheckResult { 10 + readonly packageCount: number; 11 + readonly violations: ReadonlyArray<ExportBoundaryViolation>; 12 + } 13 + 14 + const workspaceGlobsFrom = (value: unknown): ReadonlyArray<string> => { 15 + if (Array.isArray(value)) { 16 + return value.filter((entry): entry is string => typeof entry === "string"); 17 + } 18 + 19 + if (typeof value === "object" && value !== null) { 20 + const packages = (value as { readonly packages?: unknown }).packages; 21 + if (Array.isArray(packages)) { 22 + return packages.filter((entry): entry is string => typeof entry === "string"); 23 + } 24 + } 25 + 26 + return []; 27 + }; 28 + 29 + const toPackageJsonGlob = (workspaceGlob: string): string => { 30 + if (workspaceGlob.endsWith("package.json")) { 31 + return workspaceGlob; 32 + } 33 + 34 + const normalized = workspaceGlob.endsWith("/") ? workspaceGlob.slice(0, -1) : workspaceGlob; 35 + return `${normalized}/package.json`; 36 + }; 37 + 38 + export const collectWorkspacePackageJsonPaths = async ( 39 + rootDirectory: string, 40 + ): Promise<ReadonlyArray<string>> => { 41 + const rootPackageJsonPath = join(rootDirectory, "package.json"); 42 + const rootPackageJson = (await Bun.file(rootPackageJsonPath).json()) as { 43 + readonly workspaces?: unknown; 44 + }; 45 + 46 + const workspaceGlobs = workspaceGlobsFrom(rootPackageJson.workspaces); 47 + const packageJsonPaths = new Set<string>(); 48 + 49 + for (const workspaceGlob of workspaceGlobs) { 50 + const packageJsonGlob = toPackageJsonGlob(workspaceGlob); 51 + for await (const path of new Bun.Glob(packageJsonGlob).scan(rootDirectory)) { 52 + packageJsonPaths.add(join(rootDirectory, path)); 53 + } 54 + } 55 + 56 + return [...packageJsonPaths].sort(); 57 + }; 58 + 59 + export const normalizeExportEntries = (value: unknown): Array<string> => { 2 60 if (typeof value === "string") { 3 61 return [value]; 4 62 } 5 63 64 + if (Array.isArray(value)) { 65 + return value.flatMap((entry) => normalizeExportEntries(entry)); 66 + } 67 + 6 68 if (typeof value === "object" && value !== null) { 7 69 return Object.values(value).flatMap((entry) => normalizeExportEntries(entry)); 8 70 } ··· 10 72 return []; 11 73 }; 12 74 13 - const run = async (): Promise<void> => { 14 - const exportViolations: Array<string> = []; 75 + export const normalizeExportKeys = (value: unknown): Array<string> => { 76 + if (Array.isArray(value)) { 77 + return value.flatMap((entry) => normalizeExportKeys(entry)); 78 + } 79 + 80 + if (typeof value === "object" && value !== null) { 81 + return Object.entries(value).flatMap(([key, entry]) => { 82 + const keys = key.startsWith(".") ? [key] : []; 83 + return [...keys, ...normalizeExportKeys(entry)]; 84 + }); 85 + } 86 + 87 + return []; 88 + }; 89 + 90 + export const isInternalExportPath = (entry: string): boolean => 91 + /(^|\/)(src\/)?(internal|runtime|domain)(\/|$)/.test(entry.replace(/^\.\//, "")); 92 + 93 + export const checkExportBoundaries = async ( 94 + rootDirectory: string, 95 + ): Promise<ExportBoundaryCheckResult> => { 96 + const packageJsonPaths = await collectWorkspacePackageJsonPaths(rootDirectory); 97 + const violations: Array<ExportBoundaryViolation> = []; 98 + 99 + for (const packageJsonPath of packageJsonPaths) { 100 + const packageJson = (await Bun.file(packageJsonPath).json()) as { 101 + readonly name?: unknown; 102 + readonly exports?: unknown; 103 + }; 15 104 16 - for await (const path of new Bun.Glob("packages/*/package.json").scan(".")) { 17 - const packageJson = await Bun.file(path).json(); 18 - const exportsField = packageJson.exports as unknown; 19 - const exportPaths = normalizeExportEntries(exportsField); 20 - const invalidPaths = exportPaths.filter( 21 - (entry) => 22 - /\/src\/(internal|runtime|domain)\//.test(entry) || 23 - /\/(internal|runtime|domain)\//.test(entry), 24 - ); 105 + const packageName = typeof packageJson.name === "string" ? packageJson.name : packageJsonPath; 106 + const invalidExports = [ 107 + ...new Set([ 108 + ...normalizeExportKeys(packageJson.exports), 109 + ...normalizeExportEntries(packageJson.exports), 110 + ].filter((entry) => isInternalExportPath(entry))), 111 + ].sort(); 25 112 26 - if (invalidPaths.length > 0) { 27 - exportViolations.push(`${packageJson.name}: ${invalidPaths.join(", ")}`); 113 + if (invalidExports.length > 0) { 114 + violations.push({ 115 + packageName, 116 + packageJsonPath, 117 + invalidExports, 118 + }); 28 119 } 29 120 } 30 121 31 - if (exportViolations.length > 0) { 122 + return { 123 + packageCount: packageJsonPaths.length, 124 + violations, 125 + }; 126 + }; 127 + 128 + const formatViolation = (violation: ExportBoundaryViolation): string => 129 + `${violation.packageName}: ${violation.invalidExports.join(", ")} (${violation.packageJsonPath})`; 130 + 131 + export const runCheck = async (rootDirectory: string = process.cwd()): Promise<number> => { 132 + const result = await checkExportBoundaries(rootDirectory); 133 + 134 + if (result.violations.length > 0) { 32 135 console.error("Invalid package exports detected:"); 33 - for (const violation of exportViolations) { 34 - console.error(`- ${violation}`); 136 + for (const violation of result.violations) { 137 + console.error(`- ${formatViolation(violation)}`); 35 138 } 36 - process.exit(1); 139 + return 1; 37 140 } 38 141 39 - console.log("Package export boundary check passed."); 142 + console.log(`Package export boundary check passed for ${result.packageCount} package(s).`); 143 + return 0; 40 144 }; 41 145 42 - void run(); 146 + if (import.meta.main) { 147 + const exitCode = await runCheck(); 148 + process.exit(exitCode); 149 + }
+137
scripts/guardrail-harness.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { runGuardrailCommand, runGuardrailSuite } from "./guardrail-harness"; 6 + 7 + const repositoryRoot = process.cwd(); 8 + const astGrepConfigPath = join(repositoryRoot, ".ast-grep", "sgconfig.yml"); 9 + 10 + describe("guardrail harness", () => { 11 + it("runs required guardrail checks from Bun tests", async () => { 12 + const result = await runGuardrailSuite({ 13 + cwd: repositoryRoot, 14 + checks: [ 15 + { 16 + name: "ast-grep-rule-tests", 17 + cmd: ["bun", "run", "lint:ast-grep:test"], 18 + }, 19 + { 20 + name: "effect", 21 + cmd: ["bun", "run", "lint:effect"], 22 + }, 23 + { 24 + name: "boundary", 25 + cmd: ["bun", "run", "lint:boundary"], 26 + }, 27 + { 28 + name: "runtime-safety", 29 + cmd: ["bun", "run", "lint:runtime-safety"], 30 + }, 31 + { 32 + name: "exports", 33 + cmd: ["bun", "run", "lint:exports"], 34 + }, 35 + ], 36 + }); 37 + 38 + expect(result.failures).toHaveLength(0); 39 + }); 40 + 41 + it("fails boundary checks for public -> internal imports and Runtime.runPromise outside boundary", async () => { 42 + const fixtureRoot = await mkdtemp(join(tmpdir(), "mill-guardrail-boundary-")); 43 + 44 + try { 45 + const badPublicPath = join(fixtureRoot, "packages", "core", "src", "public", "bad.ts"); 46 + const badInternalPath = join( 47 + fixtureRoot, 48 + "packages", 49 + "core", 50 + "src", 51 + "internal", 52 + "bridge.effect.ts", 53 + ); 54 + await mkdir(join(fixtureRoot, "packages", "core", "src", "public"), { recursive: true }); 55 + await mkdir(join(fixtureRoot, "packages", "core", "src", "internal"), { recursive: true }); 56 + 57 + await writeFile( 58 + badPublicPath, 59 + ['import { makeEngine } from "../internal/engine.effect";'].join("\n"), 60 + "utf-8", 61 + ); 62 + await writeFile( 63 + badInternalPath, 64 + [ 65 + "import * as Runtime from \"effect/Runtime\";", 66 + "const run = Runtime.runPromise(runtime)(effect);", 67 + ].join("\n"), 68 + "utf-8", 69 + ); 70 + 71 + const commandResult = await runGuardrailCommand({ 72 + cwd: fixtureRoot, 73 + cmd: [ 74 + "ast-grep", 75 + "scan", 76 + "--config", 77 + astGrepConfigPath, 78 + "packages/core/src", 79 + "--error", 80 + "--filter", 81 + "no-(public-import-internal|runtime-runpromise-outside-boundary)", 82 + ], 83 + }); 84 + 85 + expect(commandResult.exitCode).toBeGreaterThan(0); 86 + expect(commandResult.combinedOutput).toContain("no-public-import-internal"); 87 + expect(commandResult.combinedOutput).toContain("no-runtime-runpromise-outside-boundary"); 88 + } finally { 89 + await rm(fixtureRoot, { recursive: true, force: true }); 90 + } 91 + }); 92 + 93 + it("fails runtime safety checks for shell/env/time/random/json violations", async () => { 94 + const fixtureRoot = await mkdtemp(join(tmpdir(), "mill-guardrail-runtime-")); 95 + 96 + try { 97 + const badInternalPath = join(fixtureRoot, "packages", "core", "src", "internal", "bad.effect.ts"); 98 + await mkdir(join(fixtureRoot, "packages", "core", "src", "internal"), { recursive: true }); 99 + 100 + await writeFile( 101 + badInternalPath, 102 + [ 103 + 'import * as Command from "@effect/platform/Command";', 104 + 'const payload = JSON.parse("{}") as Record<string, unknown>;', 105 + "const token = process.env.API_TOKEN;", 106 + "const now = Date.now();", 107 + "const random = Math.random();", 108 + 'const cmd = Command.make("bash", "-lc", "echo unsafe");', 109 + ].join("\n"), 110 + "utf-8", 111 + ); 112 + 113 + const commandResult = await runGuardrailCommand({ 114 + cwd: fixtureRoot, 115 + cmd: [ 116 + "ast-grep", 117 + "scan", 118 + "--config", 119 + astGrepConfigPath, 120 + "packages/core/src/internal", 121 + "--error", 122 + "--filter", 123 + "no-(json-parse-outside-codec|shell-string-command|process-env-outside-config|date-now-outside-clock|math-random-outside-random)", 124 + ], 125 + }); 126 + 127 + expect(commandResult.exitCode).toBeGreaterThan(0); 128 + expect(commandResult.combinedOutput).toContain("no-json-parse-outside-codec"); 129 + expect(commandResult.combinedOutput).toContain("no-shell-string-command"); 130 + expect(commandResult.combinedOutput).toContain("no-process-env-outside-config"); 131 + expect(commandResult.combinedOutput).toContain("no-date-now-outside-clock"); 132 + expect(commandResult.combinedOutput).toContain("no-math-random-outside-random"); 133 + } finally { 134 + await rm(fixtureRoot, { recursive: true, force: true }); 135 + } 136 + }); 137 + });
+66
scripts/guardrail-harness.ts
··· 1 + export interface GuardrailCheck { 2 + readonly name: string; 3 + readonly cmd: ReadonlyArray<string>; 4 + } 5 + 6 + export interface GuardrailCommandInput { 7 + readonly cwd: string; 8 + readonly cmd: ReadonlyArray<string>; 9 + } 10 + 11 + export interface GuardrailCommandResult { 12 + readonly cwd: string; 13 + readonly cmd: ReadonlyArray<string>; 14 + readonly exitCode: number; 15 + readonly stdout: string; 16 + readonly stderr: string; 17 + readonly combinedOutput: string; 18 + } 19 + 20 + export interface GuardrailSuiteInput { 21 + readonly cwd: string; 22 + readonly checks: ReadonlyArray<GuardrailCheck>; 23 + } 24 + 25 + export interface GuardrailSuiteResult { 26 + readonly results: ReadonlyArray<{ readonly check: GuardrailCheck; readonly result: GuardrailCommandResult }>; 27 + readonly failures: ReadonlyArray<{ readonly check: GuardrailCheck; readonly result: GuardrailCommandResult }>; 28 + } 29 + 30 + export const runGuardrailCommand = async ( 31 + input: GuardrailCommandInput, 32 + ): Promise<GuardrailCommandResult> => { 33 + const subprocess = Bun.spawn({ 34 + cmd: [...input.cmd], 35 + cwd: input.cwd, 36 + stdout: "pipe", 37 + stderr: "pipe", 38 + }); 39 + 40 + const [stdout, stderr, exitCode] = await Promise.all([ 41 + new Response(subprocess.stdout).text(), 42 + new Response(subprocess.stderr).text(), 43 + subprocess.exited, 44 + ]); 45 + 46 + return { 47 + cwd: input.cwd, 48 + cmd: input.cmd, 49 + exitCode, 50 + stdout, 51 + stderr, 52 + combinedOutput: [stdout, stderr].filter((entry) => entry.length > 0).join("\n"), 53 + }; 54 + }; 55 + 56 + export const runGuardrailSuite = async (input: GuardrailSuiteInput): Promise<GuardrailSuiteResult> => { 57 + const results: Array<{ readonly check: GuardrailCheck; readonly result: GuardrailCommandResult }> = []; 58 + 59 + for (const check of input.checks) { 60 + const result = await runGuardrailCommand({ cwd: input.cwd, cmd: check.cmd }); 61 + results.push({ check, result }); 62 + } 63 + 64 + const failures = results.filter(({ result }) => result.exitCode !== 0); 65 + return { results, failures }; 66 + };