From b585a1b9ce23db98c6b30d69509cfc9a9fdda44c Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:05:00 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Add=20cooperative=20service=20h?= =?UTF-8?q?ost=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/bun-service.ts | 17 ++ packages/cli/src/compiled-service.ts | 17 ++ packages/cli/src/deno-service.ts | 17 ++ packages/cli/src/node-service.ts | 17 ++ packages/cli/src/service-host.ts | 245 ++++++++++++++++++ .../tests/fixtures/cooperative-service.mjs | 84 ++++++ packages/cli/tests/service-host.test.ts | 182 +++++++++++++ packages/runtime/apis.ts | 13 +- packages/runtime/mod.ts | 24 ++ packages/runtime/service.ts | 185 +++++++++++++ packages/runtime/test/README.md | 8 + packages/runtime/test/mod.ts | 3 +- packages/runtime/test/stubs.ts | 19 ++ packages/runtime/tests/service.test.ts | 119 +++++++++ 14 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/bun-service.ts create mode 100644 packages/cli/src/compiled-service.ts create mode 100644 packages/cli/src/deno-service.ts create mode 100644 packages/cli/src/node-service.ts create mode 100644 packages/cli/src/service-host.ts create mode 100644 packages/cli/tests/fixtures/cooperative-service.mjs create mode 100644 packages/cli/tests/service-host.test.ts create mode 100644 packages/runtime/service.ts create mode 100644 packages/runtime/tests/service.test.ts diff --git a/packages/cli/src/bun-service.ts b/packages/cli/src/bun-service.ts new file mode 100644 index 00000000..4b9f80e7 --- /dev/null +++ b/packages/cli/src/bun-service.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import type { Operation } from "effection"; +import { inheritedEnvironment, installHostService } from "./service-host.ts"; + +export function useBunService(): Operation { + return installHostService({ + token: () => randomBytes(32).toString("hex"), + environment: () => inheritedEnvironment(process.env), + stdout(bytes) { + process.stdout.write(bytes); + }, + stderr(bytes) { + process.stderr.write(bytes); + }, + }); +} diff --git a/packages/cli/src/compiled-service.ts b/packages/cli/src/compiled-service.ts new file mode 100644 index 00000000..998225de --- /dev/null +++ b/packages/cli/src/compiled-service.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import type { Operation } from "effection"; +import { inheritedEnvironment, installHostService } from "./service-host.ts"; + +export function useCompiledService(): Operation { + return installHostService({ + token: () => randomBytes(32).toString("hex"), + environment: () => inheritedEnvironment(process.env), + stdout(bytes) { + process.stdout.write(bytes); + }, + stderr(bytes) { + process.stderr.write(bytes); + }, + }); +} diff --git a/packages/cli/src/deno-service.ts b/packages/cli/src/deno-service.ts new file mode 100644 index 00000000..c0cc0233 --- /dev/null +++ b/packages/cli/src/deno-service.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import type { Operation } from "effection"; +import { inheritedEnvironment, installHostService } from "./service-host.ts"; + +export function useDenoService(): Operation { + return installHostService({ + token: () => randomBytes(32).toString("hex"), + environment: () => inheritedEnvironment(process.env), + stdout(bytes) { + process.stdout.write(bytes); + }, + stderr(bytes) { + process.stderr.write(bytes); + }, + }); +} diff --git a/packages/cli/src/node-service.ts b/packages/cli/src/node-service.ts new file mode 100644 index 00000000..060b5226 --- /dev/null +++ b/packages/cli/src/node-service.ts @@ -0,0 +1,17 @@ +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import type { Operation } from "effection"; +import { inheritedEnvironment, installHostService } from "./service-host.ts"; + +export function useNodeService(): Operation { + return installHostService({ + token: () => randomBytes(32).toString("hex"), + environment: () => inheritedEnvironment(process.env), + stdout(bytes) { + process.stdout.write(bytes); + }, + stderr(bytes) { + process.stderr.write(bytes); + }, + }); +} diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts new file mode 100644 index 00000000..e128ca95 --- /dev/null +++ b/packages/cli/src/service-host.ts @@ -0,0 +1,245 @@ +/** Shared mechanics for the runtime-named cooperative service adapters. */ + +import { ensure, race, resource, withResolvers, type Operation } from "effection"; +import { daemon, Stdio } from "@effectionx/process"; +import { timebox } from "@effectionx/timebox"; +import { + API, + SERVICE_HOSTNAME, + SERVICE_READY_PREFIX, + ServiceProcessExitBeforeReadyError, + ServiceProtocolDuplicateError, + ServiceProtocolMalformedError, + ServiceStartupTimeoutError, + ServiceUnexpectedExitError, + parseServiceReadyRecord, + timeout, +} from "@executablemd/runtime"; +import type { ServiceEndpoint, ServiceResource, ServiceStartOptions } from "@executablemd/runtime"; + +interface HostServiceAdapter { + token(): string; + environment(): Record; + stdout(bytes: Uint8Array): void; + stderr(bytes: Uint8Array): void; +} + +interface ProtocolObserver { + stdout(bytes: Uint8Array): Operation; + flush(): Operation; +} + +const encoder = new TextEncoder(); +const prefixBytes = encoder.encode(SERVICE_READY_PREFIX); + +function concat(left: Uint8Array, right: Uint8Array): Uint8Array { + const joined = new Uint8Array(left.byteLength + right.byteLength); + joined.set(left); + joined.set(right, left.byteLength); + return joined; +} + +function startsWithPrefix(line: Uint8Array): boolean { + if (line.byteLength < prefixBytes.byteLength) { + return false; + } + for (let index = 0; index < prefixBytes.byteLength; index += 1) { + if (line[index] !== prefixBytes[index]) { + return false; + } + } + return true; +} + +function createProtocolObserver(options: { + token: string; + ready(endpoint: ServiceEndpoint): void; + fail(error: Error): void; + forward(bytes: Uint8Array): void; +}): ProtocolObserver { + let pending: Uint8Array = new Uint8Array(); + let readinessSeen = false; + + function consume(line: Uint8Array): void { + if (!startsWithPrefix(line)) { + options.forward(line); + return; + } + if (readinessSeen) { + options.fail(new ServiceProtocolDuplicateError()); + return; + } + + let payload: string; + try { + payload = new TextDecoder("utf-8", { fatal: true }).decode( + line.subarray(prefixBytes.byteLength, line.byteLength - 1), + ); + } catch { + options.fail(new ServiceProtocolMalformedError()); + return; + } + + try { + const endpoint = parseServiceReadyRecord(payload, options.token); + readinessSeen = true; + options.ready(endpoint); + } catch (error) { + options.fail(error instanceof Error ? error : new ServiceProtocolMalformedError()); + } + } + + return { + *stdout(bytes: Uint8Array): Operation { + pending = concat(pending, bytes); + let newline = pending.indexOf(10); + while (newline !== -1) { + const line = pending.slice(0, newline + 1); + pending = pending.slice(newline + 1); + consume(line); + newline = pending.indexOf(10); + } + }, + *flush(): Operation { + if (pending.byteLength > 0) { + if (startsWithPrefix(pending)) { + pending = new Uint8Array(); + throw new ServiceProtocolMalformedError(); + } else { + options.forward(pending); + pending = new Uint8Array(); + } + } + }, + }; +} + +function validTimeout(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + throw new Error("service startup timeout must be a positive finite number"); + } + return value; +} + +function exitFacts(status: { code?: number; signal?: string }): { + code?: number; + signal?: string; +} { + return { + ...(typeof status.code === "number" ? { code: status.code } : {}), + ...(typeof status.signal === "string" ? { signal: status.signal } : {}), + }; +} + +function* waitForStartup(options: { + ready: Operation; + protocolFailure: Operation; + process: { join(): Operation<{ code?: number; signal?: string }> }; + observer: ProtocolObserver; + startupTimeout: number; +}): Operation { + const result = yield* timebox(options.startupTimeout, () => + race([ + options.ready, + (function* (): Operation { + const status = yield* options.process.join(); + yield* options.observer.flush(); + throw new ServiceProcessExitBeforeReadyError(exitFacts(status)); + })(), + (function* (): Operation { + return yield* options.protocolFailure; + })(), + ]), + ); + if (result.timeout) { + throw new ServiceStartupTimeoutError(options.startupTimeout); + } + return result.value; +} + +function startHostService( + options: ServiceStartOptions, + adapter: HostServiceAdapter, +): Operation { + return resource(function* (provide) { + const token = adapter.token(); + if (!/^[0-9a-f]{64}$/.test(token)) { + throw new Error("host service adapter returned an invalid authentication token"); + } + const startupTimeout = validTimeout(options.startupTimeout ?? (yield* timeout)); + const ready = withResolvers(); + const protocolFailure = withResolvers(); + const observer = createProtocolObserver({ + token, + ready: ready.resolve, + fail: protocolFailure.reject, + forward: adapter.stdout, + }); + + yield* Stdio.around({ + *stdout([bytes]) { + yield* observer.stdout(bytes); + }, + *stderr([bytes]) { + adapter.stderr(bytes); + }, + }); + + yield* ensure(function* () { + yield* observer.flush(); + }); + + const environment = { + ...adapter.environment(), + XMD_SERVICE_PROTOCOL: "1", + XMD_SERVICE_TOKEN: token, + XMD_SERVICE_HOST: SERVICE_HOSTNAME, + XMD_SERVICE_PORT: "0", + }; + const process = yield* daemon(options.command, { + shell: true, + cwd: options.cwd, + env: environment, + }); + const endpoint = yield* waitForStartup({ + ready: ready.operation, + protocolFailure: protocolFailure.operation, + process, + observer, + startupTimeout, + }); + + yield* race([ + provide({ endpoint }), + (function* (): Operation { + const status = yield* process.join(); + yield* observer.flush(); + throw new ServiceUnexpectedExitError(exitFacts(status)); + })(), + protocolFailure.operation, + ]); + }); +} + +export function installHostService(adapter: HostServiceAdapter): Operation { + return API.Service.around( + { + *start([options]) { + return yield* startHostService(options, adapter); + }, + }, + { at: "min" }, + ); +} + +export function inheritedEnvironment( + source: Record, +): Record { + const environment: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (value !== undefined) { + environment[name] = value; + } + } + return environment; +} diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs new file mode 100644 index 00000000..25eeca41 --- /dev/null +++ b/packages/cli/tests/fixtures/cooperative-service.mjs @@ -0,0 +1,84 @@ +import { createServer } from "node:http"; + +const [mode = "normal", nonce = "none"] = process.argv.slice(2); +const host = process.env.XMD_SERVICE_HOST; +const requestedPort = Number(process.env.XMD_SERVICE_PORT); +const token = process.env.XMD_SERVICE_TOKEN; + +process.stderr.write(`service pid:${process.pid}\n`); + +if (mode === "exit-before") { + process.exit(17); +} + +if (mode === "non-cooperative") { + setInterval(() => {}, 1_000); +} else { + process.stdout.write("service stdout before readiness\n"); + process.stderr.write("service stderr before readiness\n"); + + const server = createServer((_request, response) => { + response.end(`service:${nonce}`); + }); + + server.listen(requestedPort, host, () => { + const address = server.address(); + if (typeof address !== "object" || address === null) { + process.exit(18); + } + + const ready = { + version: 1, + token, + hostname: host, + port: address.port, + }; + if (mode === "malformed") { + process.stdout.write(`XMD_SERVICE_READY:{not-json}\n`); + return; + } + if (mode === "non-object") { + process.stdout.write(`XMD_SERVICE_READY:null\n`); + return; + } + if (mode === "incompatible") { + process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify({ ...ready, version: 2 })}\n`); + return; + } + if (mode === "forged") { + process.stdout.write( + `XMD_SERVICE_READY:${JSON.stringify({ ...ready, token: "forged-token" })}\n`, + ); + return; + } + if (mode === "wrong-host") { + process.stdout.write( + `XMD_SERVICE_READY:${JSON.stringify({ ...ready, hostname: "0.0.0.0" })}\n`, + ); + return; + } + if (mode === "extra-member") { + process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify({ ...ready, unsafe: token })}\n`); + return; + } + if (mode === "partial-record") { + process.stdout.write(`XMD_SERVICE_READY:${JSON.stringify(ready)}`); + setTimeout(() => process.exit(20), 10); + } + if (mode === "delayed") { + return; + } + + const line = `XMD_SERVICE_READY:${JSON.stringify(ready)}\n`; + process.stdout.write(line); + process.stdout.write("service stdout after readiness\n"); + process.stderr.write("service stderr after readiness\n"); + + if (mode === "duplicate") { + setTimeout(() => process.stdout.write(line), 10); + } + if (mode === "exit-after") { + setTimeout(() => process.exit(19), 10); + } + }); +} diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts new file mode 100644 index 00000000..836cc204 --- /dev/null +++ b/packages/cli/tests/service-host.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection"; +import { when } from "@effectionx/converge"; +import { timebox } from "@effectionx/timebox"; +import { + ServiceProcessExitBeforeReadyError, + ServiceProtocolDuplicateError, + ServiceProtocolHostnameMismatchError, + ServiceProtocolIncompatibleError, + ServiceProtocolMalformedError, + ServiceProtocolTokenMismatchError, + ServiceStartupTimeoutError, + ServiceUnexpectedExitError, + startService, +} from "@executablemd/runtime"; +import { inheritedEnvironment, installHostService } from "../src/service-host.ts"; +import process from "node:process"; + +const TOKEN = "12".repeat(32); +const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname; + +function command(mode: string, nonce = "nonce"): string { + return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`; +} + +function adapter(stdout: string[], stderr: string[]) { + const decoder = new TextDecoder(); + return { + token: () => TOKEN, + environment: () => inheritedEnvironment(process.env), + stdout(bytes: Uint8Array) { + stdout.push(decoder.decode(bytes)); + }, + stderr(bytes: Uint8Array) { + stderr.push(decoder.decode(bytes)); + }, + }; +} + +function fixturePids(stderr: string[]): number[] { + return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1])); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function* expectGone(pids: number[]): Operation { + const result = yield* timebox(2_000, () => + when(function* () { + if (pids.some(isAlive)) { + throw new Error("service child has not exited yet"); + } + }), + ); + expect(result.timeout).toBe(false); +} + +describe("cooperative host service adapter", () => { + it("starts real isolated services, forwards live output, and suppresses readiness", function* () { + const stdout: string[] = []; + const stderr: string[] = []; + + yield* scoped(function* () { + yield* installHostService(adapter(stdout, stderr)); + const first = yield* startService({ command: command("normal", "first") }); + const second = yield* startService({ command: command("normal", "second") }); + + expect(first.endpoint.port).not.toBe(second.endpoint.port); + expect(Object.isFrozen(first.endpoint)).toBe(true); + + const firstResponse = yield* until( + globalThis + .fetch(`http://${first.endpoint.hostname}:${first.endpoint.port}`) + .then((response) => response.text()), + ); + const secondResponse = yield* until( + globalThis + .fetch(`http://${second.endpoint.hostname}:${second.endpoint.port}`) + .then((response) => response.text()), + ); + expect(firstResponse).toBe("service:first"); + expect(secondResponse).toBe("service:second"); + }); + + expect(stdout.join("")).toContain("service stdout before readiness"); + expect(stdout.join("")).toContain("service stdout after readiness"); + expect(stderr.join("")).toContain("service stderr before readiness"); + expect(stderr.join("")).toContain("service stderr after readiness"); + expect(stdout.join("")).not.toContain("XMD_SERVICE_READY"); + expect(stdout.join("")).not.toContain(TOKEN); + expect(fixturePids(stderr)).toHaveLength(2); + yield* expectGone(fixturePids(stderr)); + }); + + it("categorizes startup failures without exposing protocol records", function* () { + const cases: Array<[string, { prototype: Error }, number]> = [ + ["exit-before", ServiceProcessExitBeforeReadyError, 2_000], + ["malformed", ServiceProtocolMalformedError, 2_000], + ["non-object", ServiceProtocolMalformedError, 2_000], + ["incompatible", ServiceProtocolIncompatibleError, 2_000], + ["forged", ServiceProtocolTokenMismatchError, 2_000], + ["wrong-host", ServiceProtocolHostnameMismatchError, 2_000], + ["extra-member", ServiceProtocolMalformedError, 2_000], + ["partial-record", ServiceProtocolMalformedError, 2_000], + ["non-cooperative", ServiceStartupTimeoutError, 75], + ]; + + for (const [mode, ErrorType, startupTimeout] of cases) { + let failure: unknown; + const stdout: string[] = []; + const stderr: string[] = []; + try { + yield* scoped(function* () { + yield* installHostService(adapter(stdout, stderr)); + yield* startService({ command: command(mode), startupTimeout }); + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(ErrorType); + expect(String(failure)).not.toContain(TOKEN); + expect(JSON.stringify(failure)).not.toContain(TOKEN); + expect(stdout.join("")).not.toContain("XMD_SERVICE_READY"); + yield* expectGone(fixturePids(stderr)); + } + }); + + it("cancels startup and releases the child before readiness", function* () { + const pidPublished = withResolvers(); + const decoder = new TextDecoder(); + let pid = 0; + + yield* scoped(function* () { + yield* installHostService({ + ...adapter([], []), + stderr(bytes: Uint8Array) { + const match = /service pid:(\d+)/.exec(decoder.decode(bytes)); + if (match) { + pidPublished.resolve(Number(match[1])); + } + }, + }); + yield* spawn(function* () { + yield* startService({ command: command("delayed"), startupTimeout: 10_000 }); + }); + pid = yield* pidPublished.operation; + }); + + expect(pid).toBeGreaterThan(0); + yield* expectGone([pid]); + }); + + it("fails the owning scope when a ready process exits or repeats readiness", function* () { + const cases: Array<[string, { prototype: Error }]> = [ + ["exit-after", ServiceUnexpectedExitError], + ["duplicate", ServiceProtocolDuplicateError], + ]; + + for (const [mode, ErrorType] of cases) { + let failure: unknown; + try { + yield* scoped(function* () { + yield* installHostService(adapter([], [])); + yield* startService({ command: command(mode) }); + yield* suspend(); + }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ErrorType); + expect(String(failure)).not.toContain(TOKEN); + } + }); +}); diff --git a/packages/runtime/apis.ts b/packages/runtime/apis.ts index 2a08fcee..1dfd263a 100644 --- a/packages/runtime/apis.ts +++ b/packages/runtime/apis.ts @@ -1,7 +1,8 @@ /** * Runtime Context APIs — platform I/O operations with pluggable middleware. * - * Five domain-specific context APIs built on `@effectionx/context-api`. + * Five host-backed domain APIs plus the provider-neutral Service Api, built on + * `@effectionx/context-api`. * Each API provides default Node.js implementations. Use `.around()` to * install middleware (mocking, instrumentation, sandboxing) scoped to the * current Effection scope. @@ -25,7 +26,7 @@ * }); * ``` * - * ## Why four separate APIs? + * ## Why separate APIs? * * - **Process** — subprocess lifecycle has its own cancellation semantics * (killing processes on scope teardown). Middleware targets exec only. @@ -41,6 +42,8 @@ * use `.around()` to mock platform/env for deterministic replay; an * entrypoint installs its `command` and `compile` with `{ at: "min" }` so * ordinary middleware can wrap them. + * - **Service** — scoped cooperative-service acquisition. Its terminal handler + * requires an explicit host provider and never detects or imports a runtime. * * ## Middleware * @@ -59,7 +62,8 @@ * ## Test stubs * * Common stubs are provided by `@executablemd/runtime/test`: - * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`. + * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`, + * `useStubService(endpoint)`. */ import { type Api, createApi } from "@effectionx/context-api"; @@ -80,6 +84,7 @@ import { exec as processExec } from "@effectionx/process"; import { race, sleep, until } from "effection"; import type { Operation } from "effection"; import { timeout as contextualTimeout } from "./config.ts"; +import { Service } from "./service.ts"; /** * Result of a `stat` call. @@ -339,6 +344,7 @@ export const API: { Fs: Api; Fetch: Api; Env: Api; + Service: typeof Service; } = { /** * Subprocess execution. @@ -539,6 +545,7 @@ export const API: { ); }, }), + Service, }; export const exec: typeof API.Process.operations.exec = API.Process.operations.exec; diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 5099f055..911b26b2 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -13,6 +13,7 @@ * - `API.Env` — the host: variables, platform info, the command that invokes * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) + * - `API.Service` — scoped cooperative service startup (`startService`) * - `Config` — shared execution config (`timeout`) * * See `apis.ts` for architecture rationale. @@ -39,5 +40,28 @@ export { } from "./apis.ts"; export type { EvalBlock, ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.ts"; export { findFreePort } from "./find-free-port.ts"; +export { + Service, + SERVICE_HOSTNAME, + SERVICE_READY_PREFIX, + ServiceProcessExitBeforeReadyError, + ServiceProtocolDuplicateError, + ServiceProtocolHostnameMismatchError, + ServiceProtocolIncompatibleError, + ServiceProtocolMalformedError, + ServiceProtocolTokenMismatchError, + ServiceProviderError, + ServiceStartupTimeoutError, + ServiceTeardownError, + ServiceUnexpectedExitError, + parseServiceReadyRecord, + startService, +} from "./service.ts"; +export type { + ServiceEndpoint, + ServiceHandler, + ServiceResource, + ServiceStartOptions, +} from "./service.ts"; export { Config, timeout } from "./config.ts"; export type { ConfigApi } from "./config.ts"; diff --git a/packages/runtime/service.ts b/packages/runtime/service.ts new file mode 100644 index 00000000..2a6891fe --- /dev/null +++ b/packages/runtime/service.ts @@ -0,0 +1,185 @@ +/** + * Provider-neutral cooperative service lifecycle. + * + * The shared runtime owns the protocol shape and validation. A runtime-named + * host adapter supplies process startup through `API.Service` middleware. + */ + +import { type Api, createApi, type Operations } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +export const SERVICE_READY_PREFIX = "XMD_SERVICE_READY:"; +export const SERVICE_HOSTNAME = "127.0.0.1"; + +export interface ServiceEndpoint { + readonly hostname: string; + readonly port: number; +} + +export interface ServiceStartOptions { + readonly command: string; + readonly cwd?: string; + readonly startupTimeout?: number; +} + +export interface ServiceResource { + readonly endpoint: Readonly; +} + +export interface ServiceHandler { + start(options: ServiceStartOptions): Operation; +} + +export class ServiceProviderError extends Error { + override name = "ServiceProviderError"; + + constructor() { + super( + "service startup requires a host provider; install runtime.service middleware before execution", + ); + } +} + +export class ServiceProtocolMalformedError extends Error { + override name = "ServiceProtocolMalformedError"; + + constructor() { + super("service emitted a malformed cooperative readiness record"); + } +} + +export class ServiceProtocolIncompatibleError extends Error { + override name = "ServiceProtocolIncompatibleError"; + + constructor() { + super("service emitted an incompatible cooperative readiness record"); + } +} + +export class ServiceProtocolTokenMismatchError extends Error { + override name = "ServiceProtocolTokenMismatchError"; + + constructor() { + super("service readiness authentication failed"); + } +} + +export class ServiceProtocolHostnameMismatchError extends Error { + override name = "ServiceProtocolHostnameMismatchError"; + + constructor() { + super("service readiness hostname is not authorized"); + } +} + +export class ServiceProtocolDuplicateError extends Error { + override name = "ServiceProtocolDuplicateError"; + + constructor() { + super("service emitted more than one cooperative readiness record"); + } +} + +export class ServiceStartupTimeoutError extends Error { + override name = "ServiceStartupTimeoutError"; + + constructor(timeout: number) { + super(`service did not become ready within ${timeout}ms`); + } +} + +interface ServiceExitStatus { + readonly code?: number; + readonly signal?: string; +} + +function exitDescription(status: ServiceExitStatus): string { + if (status.signal !== undefined) { + return `signal ${status.signal}`; + } + if (status.code !== undefined) { + return `exit code ${status.code}`; + } + return "an unknown exit status"; +} + +export class ServiceProcessExitBeforeReadyError extends Error { + override name = "ServiceProcessExitBeforeReadyError"; + + constructor(status: ServiceExitStatus) { + super(`service process exited before readiness with ${exitDescription(status)}`); + } +} + +export class ServiceUnexpectedExitError extends Error { + override name = "ServiceUnexpectedExitError"; + + constructor(status: ServiceExitStatus) { + super(`service process exited after readiness with ${exitDescription(status)}`); + } +} + +export class ServiceTeardownError extends Error { + override name = "ServiceTeardownError"; + + constructor(options?: { cause?: unknown }) { + super("service process failed to terminate cleanly", options); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactMembers(record: Record): boolean { + const members = Object.keys(record); + return ( + members.length === 4 && + members.includes("version") && + members.includes("token") && + members.includes("hostname") && + members.includes("port") + ); +} + +/** Parse and authenticate one prefix-stripped v1 readiness payload. */ +export function parseServiceReadyRecord(payload: string, expectedToken: string): ServiceEndpoint { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + throw new ServiceProtocolMalformedError(); + } + + if (!isRecord(parsed) || !hasExactMembers(parsed)) { + throw new ServiceProtocolMalformedError(); + } + if (parsed.version !== 1) { + throw new ServiceProtocolIncompatibleError(); + } + if (typeof parsed.token !== "string" || parsed.token !== expectedToken) { + throw new ServiceProtocolTokenMismatchError(); + } + if (parsed.hostname !== SERVICE_HOSTNAME) { + throw new ServiceProtocolHostnameMismatchError(); + } + if ( + typeof parsed.port !== "number" || + !Number.isInteger(parsed.port) || + parsed.port < 1 || + parsed.port > 65_535 + ) { + throw new ServiceProtocolMalformedError(); + } + + return Object.freeze({ hostname: SERVICE_HOSTNAME, port: parsed.port }); +} + +export const Service: Api = createApi("runtime.service", { + // deno-lint-ignore require-yield + *start(_options: ServiceStartOptions): Operation { + throw new ServiceProviderError(); + }, +}); + +export const startService: Operations["start"] = Service.operations.start; diff --git a/packages/runtime/test/README.md b/packages/runtime/test/README.md index 7ee59ede..41fcef0b 100644 --- a/packages/runtime/test/README.md +++ b/packages/runtime/test/README.md @@ -12,6 +12,7 @@ Use these helpers when a test needs: - an in-memory filesystem instead of real files - a simple `exec` stub for `echo`-style command output - a predictable failing `exec` for error-path assertions +- a scoped cooperative-service endpoint without a real host process Use raw `API.*.around()` directly when a test needs custom behavior that the shared helpers do not provide. @@ -76,6 +77,13 @@ import { useFailingExec } from "@executablemd/runtime/test"; yield * useFailingExec(127, "command not found"); ``` +### `useStubService(endpoint)` + +Installs a provider-neutral scoped `API.Service` stub. It reconstructs an exact +frozen loopback endpoint and rejects any non-loopback hostname or invalid port. +Use the production host adapters when testing protocol, process, output or +teardown behavior. + ## Composition The helpers are designed to compose. diff --git a/packages/runtime/test/mod.ts b/packages/runtime/test/mod.ts index 9cc8e8d3..98a3361b 100644 --- a/packages/runtime/test/mod.ts +++ b/packages/runtime/test/mod.ts @@ -6,6 +6,7 @@ * - `useStubFs(files)` — in-memory filesystem * - `useEchoExec()` — simple echo-based exec * - `useFailingExec(exitCode, stderr)` — always-failing exec + * - `useStubService(endpoint)` — scoped provider-neutral service endpoint */ -export { useStubFs, useEchoExec, useFailingExec } from "./stubs.ts"; +export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.ts"; diff --git a/packages/runtime/test/stubs.ts b/packages/runtime/test/stubs.ts index 6520e702..807880db 100644 --- a/packages/runtime/test/stubs.ts +++ b/packages/runtime/test/stubs.ts @@ -23,6 +23,25 @@ import type { Operation } from "effection"; import { API } from "../apis.ts"; import type { StatResult } from "../apis.ts"; +import { SERVICE_HOSTNAME } from "../service.ts"; +import type { ServiceEndpoint } from "../service.ts"; + +/** Install a provider-neutral scoped service endpoint stub. */ +export function* useStubService(endpoint: ServiceEndpoint): Operation { + if (endpoint.hostname !== SERVICE_HOSTNAME) { + throw new Error("stub service endpoint must use 127.0.0.1"); + } + if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) { + throw new Error("stub service endpoint port must be an integer from 1 through 65535"); + } + const exact = Object.freeze({ hostname: SERVICE_HOSTNAME, port: endpoint.port }); + yield* API.Service.around({ + // deno-lint-ignore require-yield + *start() { + return { endpoint: exact }; + }, + }); +} /** * Install an in-memory filesystem stub. diff --git a/packages/runtime/tests/service.test.ts b/packages/runtime/tests/service.test.ts new file mode 100644 index 00000000..a4b19040 --- /dev/null +++ b/packages/runtime/tests/service.test.ts @@ -0,0 +1,119 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import { + API, + SERVICE_HOSTNAME, + ServiceProtocolHostnameMismatchError, + ServiceProtocolIncompatibleError, + ServiceProtocolMalformedError, + ServiceProtocolTokenMismatchError, + ServiceProviderError, + parseServiceReadyRecord, + startService, +} from "../mod.ts"; + +const TOKEN = "ab".repeat(32); + +function record(overrides: Record = {}): string { + return JSON.stringify({ + version: 1, + token: TOKEN, + hostname: SERVICE_HOSTNAME, + port: 49_152, + ...overrides, + }); +} + +describe("runtime.service", () => { + it("fails when no provider is installed", function* () { + let failure: unknown; + try { + yield* startService({ command: "server" }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ServiceProviderError); + }); + + it("accepts the exact authenticated v1 record and freezes the endpoint", function* () { + const endpoint = parseServiceReadyRecord(record(), TOKEN); + expect(endpoint).toEqual({ hostname: SERVICE_HOSTNAME, port: 49_152 }); + expect(Object.keys(endpoint)).toEqual(["hostname", "port"]); + expect(Object.isFrozen(endpoint)).toBe(true); + }); + + it("rejects malformed records without retaining their input", function* () { + const unsafe = `unsafe-${TOKEN}`; + const malformed = [ + unsafe, + "null", + "[]", + "{}", + JSON.stringify({ version: 1, token: TOKEN, hostname: SERVICE_HOSTNAME }), + record({ extra: unsafe }), + record({ port: 0 }), + record({ port: 65_536 }), + record({ port: 1.5 }), + record({ port: "49152" }), + ]; + + for (const candidate of malformed) { + let failure: unknown; + try { + parseServiceReadyRecord(candidate, TOKEN); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ServiceProtocolMalformedError); + expect(String(failure)).not.toContain(TOKEN); + expect(JSON.stringify(failure)).not.toContain(TOKEN); + expect(String(failure)).not.toContain(candidate); + } + }); + + it("categorizes incompatible, forged, and unauthorized records safely", function* () { + const cases: Array<[string, new () => Error]> = [ + [record({ version: 2 }), ServiceProtocolIncompatibleError], + [record({ token: "cd".repeat(32) }), ServiceProtocolTokenMismatchError], + [record({ hostname: "0.0.0.0" }), ServiceProtocolHostnameMismatchError], + ]; + + for (const [candidate, ErrorType] of cases) { + let failure: unknown; + try { + parseServiceReadyRecord(candidate, TOKEN); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ErrorType); + expect(String(failure)).not.toContain(TOKEN); + expect(JSON.stringify(failure)).not.toContain(candidate); + } + }); + + it("uses a scoped contextual provider without leaking it", function* () { + const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 7001 }); + const provided = yield* scoped(function* () { + yield* API.Service.around( + { + *start() { + return { endpoint }; + }, + }, + { at: "min" }, + ); + return yield* startService({ command: "server" }); + }); + + expect(provided.endpoint).toBe(endpoint); + + let failure: unknown; + try { + yield* startService({ command: "server" }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ServiceProviderError); + }); +}); From ac55d004016fbfd3ed728bfb35698b956497fa49 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:05:27 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20Add=20replay-safe=20service=20m?= =?UTF-8?q?odifiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/bun.ts | 3 +- packages/cli/src/cli.ts | 38 +- packages/cli/src/compiled.ts | 3 +- packages/cli/src/deno.ts | 3 +- packages/cli/src/node.ts | 3 +- packages/cli/tests/service-document.test.ts | 145 +++ packages/core/components/LlamafileProvider.md | 72 -- packages/core/mod.ts | 9 +- packages/core/src/data-uri-compiler.ts | 1 - packages/core/src/eval-handler.ts | 57 + packages/core/src/execute.ts | 20 +- packages/core/src/expand.ts | 65 +- packages/core/src/live-env.ts | 155 +++ packages/core/src/modifiers.ts | 22 + packages/core/src/modifiers/ephemeral.ts | 16 + packages/core/src/modifiers/service.ts | 47 + packages/core/src/temp-file-compiler.ts | 1 - packages/core/tests/ephemeral-service.test.ts | 265 +++++ packages/core/tests/find-free-port.test.ts | 383 ------- .../core/tests/provider-integration.test.ts | 992 ------------------ packages/runtime/find-free-port.ts | 40 - packages/runtime/mod.ts | 1 - packages/testing/tests/smoke.test.ts | 8 +- packages/workflow/mod.ts | 1 + packages/workflow/src/service-denial.ts | 24 + .../workflow/tests/service-denial.test.ts | 39 + smoke-test/CooperativeProvider.md | 19 + smoke-test/Guide/Daemons.md | 43 +- smoke-test/Guide/Evaluation.md | 29 +- smoke-test/Guide/Summary.md | 8 +- 30 files changed, 923 insertions(+), 1589 deletions(-) create mode 100644 packages/cli/tests/service-document.test.ts delete mode 100644 packages/core/components/LlamafileProvider.md create mode 100644 packages/core/src/live-env.ts create mode 100644 packages/core/src/modifiers/ephemeral.ts create mode 100644 packages/core/src/modifiers/service.ts create mode 100644 packages/core/tests/ephemeral-service.test.ts delete mode 100644 packages/core/tests/find-free-port.test.ts delete mode 100644 packages/core/tests/provider-integration.test.ts delete mode 100644 packages/runtime/find-free-port.ts create mode 100644 packages/workflow/src/service-denial.ts create mode 100644 packages/workflow/tests/service-denial.test.ts create mode 100644 smoke-test/CooperativeProvider.md diff --git a/packages/cli/src/bun.ts b/packages/cli/src/bun.ts index 76cbc582..5a72f684 100644 --- a/packages/cli/src/bun.ts +++ b/packages/cli/src/bun.ts @@ -11,6 +11,7 @@ import process from "node:process"; import { API } from "@executablemd/runtime"; import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; +import { useBunService } from "./bun-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); @@ -29,5 +30,5 @@ await main(function* (args) { }, { at: "min" }, ); - yield* runXmd(args); + yield* runXmd(args, useBunService); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3f5bd814..e3617a4f 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -407,6 +407,8 @@ interface DocumentMode { props?: Record; } +export type HostServiceInstaller = () => Operation; + /** * Run one document and report how it finished. * @@ -415,7 +417,11 @@ interface DocumentMode { * what the process status is. Rendered output, the --verbose journal echo, and * a value root's JSON line are the document's own output and stay. */ -function* runDocument(config: DocumentConfig, mode: DocumentMode): Operation> { +function* runDocument( + config: DocumentConfig, + mode: DocumentMode, + installService: HostServiceInstaller, +): Operation> { const { root, componentDir, verbose, journal, raw, secretDetection } = config; // Every CLI invocation starts from an empty stream. --journal writes @@ -497,6 +503,10 @@ function* runDocument(config: DocumentConfig, mode: DocumentMode): Operation> { +function* runScopedDocument( + config: DocumentConfig, + mode: DocumentMode, + installService: HostServiceInstaller, +): Operation> { try { - return yield* scoped(() => runDocument(config, mode)); + return yield* scoped(() => runDocument(config, mode, installService)); } catch (error) { return Err(error instanceof Error ? error : new Error(String(error))); } @@ -592,7 +606,11 @@ interface TestConfig extends Omit { * end. A single document behaves exactly as it always has: one reported * failure, no heading, no summary. */ -function* test(config: TestConfig, args: string[]): Operation { +function* test( + config: TestConfig, + args: string[], + installService: HostServiceInstaller, +): Operation { const patterns = readPatternFlags(args); if (patterns.missingValue) { console.error( @@ -621,7 +639,11 @@ function* test(config: TestConfig, args: string[]): Operation { return; } announceSecretDetection(config.secretDetection); - const result = yield* runScopedDocument({ ...config, root: { path } }, { testing: true }); + const result = yield* runScopedDocument( + { ...config, root: { path } }, + { testing: true }, + installService, + ); if (!result.ok) { reportFailure(result.error); yield* exit(1); @@ -660,6 +682,7 @@ function* test(config: TestConfig, args: string[]): Operation { componentDir: componentSearchPath(document, target.root, config.componentDir), }, { testing: true }, + installService, ); if (!result.ok) { reportFailure(result.error, document.relativePath); @@ -935,7 +958,7 @@ function* resolveRunProps( * `process.stdout` and `node:fs/promises`. Routing those through contextual * APIs is #156. */ -export function* runXmd(args: string[]): Operation { +export function* runXmd(args: string[], installService: HostServiceInstaller): Operation { // First, so that no later scanner — help, properties, agent flags — can // mistake the inline document's own text for an option. const evalFlags = readEvalFlags(args); @@ -1025,6 +1048,7 @@ export function* runXmd(args: string[]): Operation { denyAll: config.denyAll, }, }, + installService, ); if (!result.ok) { reportFailure(result.error); @@ -1049,7 +1073,7 @@ export function* runXmd(args: string[]): Operation { yield* exit(1); break; } - yield* test(command.config, evalFlags.rest); + yield* test(command.config, evalFlags.rest, installService); break; } case "test-agent": diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index a38146c5..3ac11182 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -10,6 +10,7 @@ import process from "node:process"; import { API } from "@executablemd/runtime"; import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; +import { useCompiledService } from "./compiled-service.ts"; await main(function* (args) { // The base providers for this host. `at: "min"` puts them beneath ordinary @@ -26,5 +27,5 @@ await main(function* (args) { }, { at: "min" }, ); - yield* runXmd(args); + yield* runXmd(args, useCompiledService); }); diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 8f870d33..8d0ff3ea 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -13,6 +13,7 @@ import process from "node:process"; import { API } from "@executablemd/runtime"; import { compileDataUri } from "@executablemd/core"; import { runXmd } from "./cli.ts"; +import { useDenoService } from "./deno-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); @@ -31,5 +32,5 @@ await main(function* (args) { }, { at: "min" }, ); - yield* runXmd(args); + yield* runXmd(args, useDenoService); }); diff --git a/packages/cli/src/node.ts b/packages/cli/src/node.ts index 8c58f128..01d20f05 100755 --- a/packages/cli/src/node.ts +++ b/packages/cli/src/node.ts @@ -17,6 +17,7 @@ import process from "node:process"; import { API } from "@executablemd/runtime"; import { compileTempFile } from "@executablemd/core"; import { runXmd } from "./cli.ts"; +import { useNodeService } from "./node-service.ts"; const ENTRYPOINT = fileURLToPath(import.meta.url); @@ -36,5 +37,5 @@ await main(function* (args) { }, { at: "min" }, ); - yield* runXmd(args); + yield* runXmd(args, useNodeService); }); diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts new file mode 100644 index 00000000..47d60060 --- /dev/null +++ b/packages/cli/tests/service-document.test.ts @@ -0,0 +1,145 @@ +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { once } from "@effectionx/node/events"; +import { race, resource, scoped, type Operation } from "effection"; +import { createServer } from "node:http"; +import process from "node:process"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { collect, execute, useTempFileCompiler } from "@executablemd/core"; +import { SERVICE_HOSTNAME } from "@executablemd/runtime"; +import { useStubFs } from "@executablemd/runtime/test"; +import { inheritedEnvironment, installHostService } from "../src/service-host.ts"; + +const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname; +const command = `node ${JSON.stringify(fixture)} normal document`; + +const SAMPLE = `--- +meta: + componentName: Sample +props: + type: object + properties: {} + additionalProperties: false +--- + +\`\`\`js persist eval +const result = yield* Sample.operations.sample({ content: "", componentName: "Sample" }); +return result; +\`\`\` +`; + +const PROVIDER = `--- +meta: + componentName: Provider +--- + +\`\`\`bash service=server exec +${command} +\`\`\` + +\`\`\`js persist ephemeral eval +const endpoint = server; +yield* Sample.around({ + *sample() { + const response = yield* fetch(\`http://\${endpoint.hostname}:\${endpoint.port}\`).expect(); + return \`\${yield* response.text()}:\${endpoint.port}\`; + }, +}); +\`\`\` + + +`; + +function* runDocument(stream: InMemoryStream): Operation { + return String( + yield* scoped(function* () { + return yield* collect( + yield* execute({ + path: "doc.md", + stream, + componentDirs: ["components", "."], + }), + ); + }), + ); +} + +function responsePort(output: string): number { + const match = /service:document:(\d+)/.exec(output); + if (!match) { + throw new Error(`service response missing from output: ${output}`); + } + return Number(match[1]); +} + +function occupy(port: number): Operation { + return resource(function* (provide) { + const server = createServer((_request, response) => response.end("foreign")); + const listening = once(server, "listening"); + const failed = once<[Error]>(server, "error"); + server.listen(port, SERVICE_HOSTNAME); + yield* race([ + listening, + (function* () { + const [error] = yield* failed; + throw error; + })(), + ]); + try { + yield* provide(); + } finally { + server.close(); + } + }); +} + +describe("cooperative service document integration", () => { + beforeAll(() => useTempFileCompiler()); + + it("reconstructs a real service on partial replay and skips completed replay", function* () { + const full = new InMemoryStream(); + let tokenCalls = 0; + + yield* scoped(function* () { + yield* installHostService({ + token() { + tokenCalls += 1; + return tokenCalls.toString(16).padStart(64, "0"); + }, + environment: () => inheritedEnvironment(process.env), + stdout() {}, + stderr() {}, + }); + yield* useStubFs({ + "doc.md": "\n", + "components/Provider.md": PROVIDER, + "components/Sample.md": SAMPLE, + }); + + const first = yield* runDocument(full); + const firstPort = responsePort(first); + expect(tokenCalls).toBe(1); + const journal = JSON.stringify(full.snapshot()); + expect(journal).not.toContain("service stdout"); + expect(journal).not.toContain("service stderr"); + expect(journal).not.toContain("XMD_SERVICE_READY"); + expect(journal).not.toContain( + "0000000000000000000000000000000000000000000000000000000000000001", + ); + + yield* occupy(firstPort); + const events = full.snapshot(); + const firstYield = events.findIndex((event) => event.type === "yield"); + const partial = new InMemoryStream(events.slice(0, firstYield + 1)); + const resumed = yield* runDocument(partial); + + expect(responsePort(resumed)).not.toBe(firstPort); + expect(resumed).not.toContain("foreign"); + expect(tokenCalls).toBe(2); + + const completed = yield* runDocument(full); + expect(completed).toBe(first); + expect(tokenCalls).toBe(2); + }); + }); +}); diff --git a/packages/core/components/LlamafileProvider.md b/packages/core/components/LlamafileProvider.md deleted file mode 100644 index 611adc45..00000000 --- a/packages/core/components/LlamafileProvider.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -meta: - componentName: LlamafileProvider - -props: - type: object - properties: - model: - type: string - description: > - Model identifier. Serves two purposes: it is passed as the `model` field - in every /v1/chat/completions request, and it is the routing key that - sample calls use to target this provider. Must be unique among all - LlamafileProvider instances active simultaneously in the same document run. - Example: "phi3-mini", "qwen3-0.6b" - command: - type: string - description: > - Shell command to start the llamafile or llama.cpp server. - {port} is substituted with the allocated port number before execution. - Example: "./phi3-mini.llamafile --nobrowser" - required: [model, command] - additionalProperties: false ---- - -```ts eval -const port = yield * findFreePort(); -const baseUrl = `http://127.0.0.1:${port}`; -``` - -```bash daemon exec -{command} --port {port} -``` - -```ts eval -yield * - when(function* () { - yield* fetch(`${baseUrl}/health`).expect(); - }); -``` - -```ts persist eval -yield * - Sample.around( - { - *sample([context], next) { - if (context.model !== undefined && context.model !== model) { - return yield* next(context); - } - - const messages = []; - if (context.system) { - messages.push({ role: "system", content: context.system }); - } - messages.push({ role: "user", content: context.content }); - - const result = yield* fetch(`${baseUrl}/v1/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model, messages, temperature: 0, max_tokens: 2048 }), - }) - .expect() - .json(); - - return result.choices[0].message.content; - }, - }, - { at: "min" }, - ); -``` - - diff --git a/packages/core/mod.ts b/packages/core/mod.ts index a5eb7e95..d1c66473 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -105,11 +105,16 @@ export { evalFactory } from "./src/eval-handler.ts"; export { persistFactory } from "./src/modifiers/persist.ts"; export { timeoutFactory, parseDuration } from "./src/modifiers/timeout.ts"; export { daemonFactory } from "./src/modifiers/daemon.ts"; +export { EphemeralEvalOutputError, ephemeralFactory } from "./src/modifiers/ephemeral.ts"; +export { serviceFactory } from "./src/modifiers/service.ts"; +export { + InvalidServiceBindingError, + LiveBindingCollisionError, + ServiceBindingCollisionError, +} from "./src/live-env.ts"; export { interpolateEvalBindings } from "./src/eval-interpolate.ts"; -export { findFreePort } from "@executablemd/runtime"; - export type { TransformResult } from "./src/eval-transform.ts"; export { transformBlock, serializeExports, isJson } from "./src/eval-transform.ts"; diff --git a/packages/core/src/data-uri-compiler.ts b/packages/core/src/data-uri-compiler.ts index 50c25e98..5fdf5be1 100644 --- a/packages/core/src/data-uri-compiler.ts +++ b/packages/core/src/data-uri-compiler.ts @@ -30,7 +30,6 @@ const STANDARD_IMPORTS = [ 'import { when } from "@effectionx/converge";', 'import { fetch } from "@effectionx/fetch";', 'import { Sample, Elicitation } from "@executablemd/core";', - 'import { findFreePort } from "@executablemd/runtime";', ]; /** Compile one eval block by importing it as a data: URI. */ diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts index f6198277..91a50a23 100644 --- a/packages/core/src/eval-handler.ts +++ b/packages/core/src/eval-handler.ts @@ -18,6 +18,58 @@ import { ErrorMode } from "./errors.ts"; import { commitExports, evaluationEnv } from "./eval-env.ts"; import { compileBlock } from "./eval-context.ts"; import { transformBlock, serializeExports } from "./eval-transform.ts"; +import { + commitLiveExports, + liveEnvironment, + validateLiveExports, + validateLiveOverlay, +} from "./live-env.ts"; +import { EphemeralEval, EphemeralEvalOutputError } from "./modifiers/ephemeral.ts"; +import type { CodeBlockContext, CodeBlockResult, EvalEnv } from "./types.ts"; + +function* runEphemeralEval( + ctx: CodeBlockContext, + evalEnv: EvalEnv, + persist: boolean, + mode: import("./errors.ts").ErrorMode, +): Operation { + const live = liveEnvironment(evalEnv); + validateLiveOverlay(evalEnv, live); + let outputAttempted = false; + const merged: Record = { ...evalEnv.values, ...live.values }; + merged.output = (_value: unknown): never => { + outputAttempted = true; + throw new EphemeralEvalOutputError("ephemeral eval cannot produce document output"); + }; + + const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(merged)); + validateLiveExports(transformed.exports, evalEnv); + const fn = yield* compileBlock(transformed.code, transformed.userImports ?? []); + const blockEnv = evaluationEnv(merged, mode); + + let returnValue: unknown; + if (persist) { + const scope = yield* evalScope; + if (!scope) { + throw new Error( + `persist ephemeral eval block "${ctx.blockId}" requires a component eval scope; none is in scope.`, + ); + } + returnValue = unbox(yield* scope.eval(() => fn(blockEnv))); + } else { + returnValue = yield* scoped(() => fn(blockEnv)); + } + + if (outputAttempted) { + throw new EphemeralEvalOutputError("ephemeral eval cannot produce document output"); + } + if (returnValue !== undefined && returnValue !== null) { + throw new EphemeralEvalOutputError("ephemeral eval cannot return document output"); + } + + commitLiveExports(live, blockEnv, transformed.exports); + return { output: "", exitCode: 0, stderr: "" }; +} /** * Refuse `retain()` for the scope this is installed in. @@ -70,11 +122,16 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => ); } const persist = yield* ephemeral(persistent); + const reconstruct = yield* ephemeral(EphemeralEval.get()); // Captured here, on the expansion frame, where the block's documentation or // error mode is ambient. A persist block runs on the invocation's // eval-scope loop task, which predates that error mode and cannot inherit it. const mode = (yield* ephemeral(ErrorMode.get())) ?? "print"; + if (reconstruct) { + return yield* ephemeral(runEphemeralEval(ctx, evalEnv, persist, mode)); + } + // Inject output() function into env so eval blocks can produce // rendered output. The function is a plain synchronous call: // output("some text") diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 3eeba803..81936880 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -76,6 +76,8 @@ import { evalFactory } from "./eval-handler.ts"; import { persistFactory } from "./modifiers/persist.ts"; import { timeoutFactory } from "./modifiers/timeout.ts"; import { daemonFactory } from "./modifiers/daemon.ts"; +import { ephemeralFactory } from "./modifiers/ephemeral.ts"; +import { serviceFactory } from "./modifiers/service.ts"; import { DEFAULT_COMPONENT_DIRS, effectiveRegistry, @@ -88,6 +90,7 @@ import type { RootDocumentSource } from "./root-source.ts"; import { useEvalScope } from "@effectionx/scope-eval"; import { Stdio } from "@effectionx/process"; import { useSecretDetection } from "./secrets/policy.ts"; +import { liveEnvironment } from "./live-env.ts"; export interface ExecuteSettings { /** Durable stream for journaling. */ @@ -564,6 +567,7 @@ function* documentWorkflow(props: Record): Workflow registry.set("persist", persistFactory); registry.set("timeout", timeoutFactory); registry.set("daemon", daemonFactory); + registry.set("ephemeral", ephemeralFactory); + registry.set("service", serviceFactory); for (const [name, handler] of Object.entries(customModifiers)) { registry.set(name, handler); } @@ -805,10 +811,16 @@ function* executeDocument(options: ExecuteOptions): Operation }, }); - yield* Stdio.around({ - *stdout() {}, - *stderr() {}, - }); + // The discard provider is the base so a cooperative-service observer can + // authenticate and forward its own process output without unsilencing + // unrelated document subprocesses. + yield* Stdio.around( + { + *stdout() {}, + *stderr() {}, + }, + { at: "min" }, + ); // The state this run owns: the table its printed errors record their // causes in, the schema compilers, and the slot its completion reads its diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 46ae6f46..df3183ef 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -15,7 +15,6 @@ import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; -import { parse } from "acorn"; import type { Segment, TextSegment, @@ -72,6 +71,9 @@ import { renderSegments } from "./render.ts"; import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; +import { derivedEnvironment, liveEnvironment, validateBindingName } from "./live-env.ts"; + +export { validateBindingName } from "./live-env.ts"; /** * Mutable counter for generating unique, deterministic blockId values. @@ -166,7 +168,9 @@ function expandChildrenScoped( path: string, ): Operation { return scoped(function* () { - yield* provideEnv({ values: { ...(callerEnv?.values ?? {}), ...(override ?? {}) } }); + yield* provideEnv( + derivedEnvironment(callerEnv, { ...(callerEnv?.values ?? {}), ...(override ?? {}) }), + ); if (scope) { yield* provideEvalScope(scope); } @@ -238,7 +242,10 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { function environmentFor(request: ProjectionRequest): EvalEnv | undefined { if (request.kind === "children" && request.override) { - return { values: { ...(state.callerEnv?.values ?? {}), ...request.override } }; + return derivedEnvironment(state.callerEnv, { + ...(state.callerEnv?.values ?? {}), + ...request.override, + }); } return state.callerEnv; } @@ -504,7 +511,6 @@ function validateRenderOverride(override: unknown): Record | un } const MAX_EXPANSION_DEPTH = 64; -const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; /** * Expand an array of segments, resolving components and executing code blocks. @@ -1082,7 +1088,10 @@ function* expandEach( // bindings and the current component's bindings. const contextEnv = yield* env; const callerEnv = segment.projectedEnv - ? { values: { ...segment.projectedEnv.values, ...(contextEnv?.values ?? {}) } } + ? derivedEnvironment(segment.projectedEnv, { + ...segment.projectedEnv.values, + ...(contextEnv?.values ?? {}), + }) : contextEnv; const parentEvalScope = yield* evalScope; @@ -1900,7 +1909,10 @@ function* expandComponent( // The current context env's bindings take precedence (innermost-wins). const contextEnv = yield* env; const callerEvalEnv = projectedEnv - ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } + ? derivedEnvironment(projectedEnv, { + ...projectedEnv.values, + ...(contextEnv?.values ?? {}), + }) : contextEnv; // Recurse with augmented hide set. @@ -1917,6 +1929,7 @@ function* expandComponent( // next() delegates to the parent scope's middleware. const newHideSet = new Set([...hideSet, name]); const componentEnv: EvalEnv = { values: { ...validatedProps } }; + liveEnvironment(componentEnv); // Children are caller-provided content, not the component's own body. // Use the parent's hide set (without the current component name) so @@ -2391,11 +2404,13 @@ function* expandFunctionComponent( // it reads is what its author wrote beside it. if (projectedEnv) { const siteEnv = yield* env; + const projectedSiteEnv = derivedEnvironment(projectedEnv, { + ...projectedEnv.values, + ...(siteEnv?.values ?? {}), + }); yield* Component.around( { - env: () => ({ - values: { ...projectedEnv.values, ...(siteEnv?.values ?? {}) }, - }), + env: () => projectedSiteEnv, }, { at: "min" }, ); @@ -2512,38 +2527,6 @@ function asFailure(thrown: unknown): Error { return thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown }); } -export function validateBindingName(value: Json | undefined): Result { - if (value === undefined) { - return Ok(undefined); - } - if (typeof value !== "string") { - return Err(new Error("must be a non-empty string literal.")); - } - if (value.length === 0) { - return Err(new Error("must be non-empty.")); - } - if (!IDENTIFIER_RE.test(value)) { - return Err(new Error(`must be a valid JavaScript identifier. Got: "${value}"`)); - } - // The identifier shape is not sufficient: reserved and contextual words - // (in, let, await, ...) match the regex but cannot form an ES-module - // binding, which is where these names end up (eval preamble destructures - // `const { name } = env;`). Parse the destructuring shape to reject them. - if (!isModuleBindingName(value)) { - return Err(new Error(`must be a valid JavaScript binding name. Got: "${value}"`)); - } - return Ok(value); -} - -function isModuleBindingName(name: string): boolean { - try { - parse(`const { ${name} } = 0;`, { ecmaVersion: "latest", sourceType: "module" }); - return true; - } catch { - return false; - } -} - /** * Resolve eval expression props against env.values using the shared VM * context. Merges resolved values into the props record. diff --git a/packages/core/src/live-env.ts b/packages/core/src/live-env.ts new file mode 100644 index 00000000..a4cff45d --- /dev/null +++ b/packages/core/src/live-env.ts @@ -0,0 +1,155 @@ +/** Invocation-local bindings reconstructed during live execution. */ + +import { Err, Ok, type Result } from "effection"; +import { parse } from "acorn"; +import type { EvalEnv, Json } from "./types.ts"; + +const LIVE_ENV = Symbol("@executablemd/core:live-env"); +const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +export interface LiveEnv { + values: Record; +} + +export class InvalidServiceBindingError extends Error { + override name = "InvalidServiceBindingError"; +} + +export class ServiceBindingCollisionError extends Error { + override name = "ServiceBindingCollisionError"; +} + +export class LiveBindingCollisionError extends Error { + override name = "LiveBindingCollisionError"; +} + +function isLiveEnv(value: unknown): value is LiveEnv { + if (typeof value !== "object" || value === null || !("values" in value)) { + return false; + } + return typeof value.values === "object" && value.values !== null && !Array.isArray(value.values); +} + +function attach(env: EvalEnv, live: LiveEnv): void { + Object.defineProperty(env, LIVE_ENV, { + configurable: false, + enumerable: false, + writable: false, + value: live, + }); +} + +/** The private live overlay owned by this durable environment. */ +export function liveEnvironment(env: EvalEnv): LiveEnv { + const attached = Reflect.get(env, LIVE_ENV); + if (isLiveEnv(attached)) { + return attached; + } + const live = { values: {} }; + attach(env, live); + return live; +} + +/** Make a derived durable environment share its source invocation's overlay. */ +export function inheritLiveEnvironment(source: EvalEnv, derived: EvalEnv): EvalEnv { + attach(derived, liveEnvironment(source)); + return derived; +} + +export function derivedEnvironment( + source: EvalEnv | undefined, + values: Record, +): EvalEnv { + const derived = { values }; + return source === undefined ? derived : inheritLiveEnvironment(source, derived); +} + +function isModuleBindingName(name: string): boolean { + try { + parse(`const { ${name} } = 0;`, { ecmaVersion: "latest", sourceType: "module" }); + return true; + } catch { + return false; + } +} + +export function validateBindingName(value: Json | undefined): Result { + if (value === undefined) { + return Ok(undefined); + } + if (typeof value !== "string") { + return Err(new Error("must be a non-empty string literal.")); + } + if (value.length === 0) { + return Err(new Error("must be non-empty.")); + } + if (!IDENTIFIER_RE.test(value)) { + return Err(new Error(`must be a valid JavaScript identifier. Got: "${value}"`)); + } + if (!isModuleBindingName(value)) { + return Err(new Error(`must be a valid JavaScript binding name. Got: "${value}"`)); + } + return Ok(value); +} + +/** Validate `service=` completely before the process is spawned. */ +export function validateServiceBinding( + candidate: string | undefined, + durable: EvalEnv, + live: LiveEnv, +): string { + const result = validateBindingName(candidate); + if (!result.ok || result.value === undefined) { + throw new InvalidServiceBindingError( + result.ok ? "service requires a binding name" : `service binding ${result.error.message}`, + ); + } + if (result.value in durable.values) { + throw new ServiceBindingCollisionError( + `service binding "${result.value}" collides with a durable binding`, + ); + } + if (result.value in live.values) { + throw new ServiceBindingCollisionError( + `service binding "${result.value}" collides with a live binding`, + ); + } + return result.value; +} + +/** Refuse every export before an ephemeral block with a collision executes. */ +export function validateLiveExports(exports: string[], durable: EvalEnv): void { + const collision = exports.find((name) => name in durable.values); + if (collision !== undefined) { + throw new LiveBindingCollisionError( + `ephemeral eval export "${collision}" collides with a durable binding`, + ); + } +} + +/** Refuse a durable value added after a live binding of the same name. */ +export function validateLiveOverlay(durable: EvalEnv, live: LiveEnv): void { + const collision = Object.keys(live.values).find((name) => name in durable.values); + if (collision !== undefined) { + throw new LiveBindingCollisionError( + `live binding "${collision}" collides with a durable binding`, + ); + } +} + +/** Atomically publish a successful ephemeral evaluation's declared exports. */ +export function commitLiveExports( + live: LiveEnv, + snapshot: Record, + exports: string[], +): void { + const committed: Array<[string, unknown]> = []; + for (const name of exports) { + if (name in snapshot) { + committed.push([name, snapshot[name]]); + } + } + for (const [name, value] of committed) { + live.values[name] = value; + } +} diff --git a/packages/core/src/modifiers.ts b/packages/core/src/modifiers.ts index f505f615..c7353a4c 100644 --- a/packages/core/src/modifiers.ts +++ b/packages/core/src/modifiers.ts @@ -104,6 +104,28 @@ export function composeModifierChain( throw new Error("No terminal modifier (exec/eval) in chain"); }; + const terminalNames = new Set(["exec", "eval", "daemon", "service"]); + const firstTerminal = modifiers.find((modifier) => terminalNames.has(modifier.name)); + if ( + modifiers.some((modifier) => modifier.name === "ephemeral") && + firstTerminal?.name !== "eval" + ) { + return function* () { + throw new Error("ephemeral is valid only with the eval terminal modifier"); + }; + } + const serviceIndex = modifiers.findIndex((modifier) => modifier.name === "service"); + if (serviceIndex > 0) { + return function* () { + throw new Error("service must be the outermost modifier"); + }; + } + if (serviceIndex === 0 && !modifiers.some((modifier) => modifier.name === "exec")) { + return function* () { + throw new Error("service requires the exec detection modifier"); + }; + } + // Build the middleware array — each factory is called with its params const middlewares: ModifierMiddleware[] = []; for (const mod of modifiers) { diff --git a/packages/core/src/modifiers/ephemeral.ts b/packages/core/src/modifiers/ephemeral.ts new file mode 100644 index 00000000..257a44b2 --- /dev/null +++ b/packages/core/src/modifiers/ephemeral.ts @@ -0,0 +1,16 @@ +/** Public replay-time eval reconstruction modifier. */ + +import { createContext } from "effection"; +import { ephemeral } from "@executablemd/durable-streams"; +import type { ModifierFactory } from "../modifiers.ts"; + +export const EphemeralEval = createContext("component.ephemeral-eval", false); + +export class EphemeralEvalOutputError extends Error { + override name = "EphemeralEvalOutputError"; +} + +export const ephemeralFactory: ModifierFactory = (_params) => (_args, next) => + (function* () { + return yield* ephemeral(EphemeralEval.with(true, () => ephemeral(next()))); + })(); diff --git a/packages/core/src/modifiers/service.ts b/packages/core/src/modifiers/service.ts new file mode 100644 index 00000000..142c3f8b --- /dev/null +++ b/packages/core/src/modifiers/service.ts @@ -0,0 +1,47 @@ +/** Cooperative service terminal modifier. */ + +import { ephemeral } from "@executablemd/durable-streams"; +import { unbox } from "@effectionx/scope-eval"; +import type { Operation } from "effection"; +import { cwd, startService, timeout } from "@executablemd/runtime"; +import type { ModifierFactory } from "../modifiers.ts"; +import { useCodeBlock } from "../modifiers.ts"; +import { env, evalScope } from "../component-api.ts"; +import { liveEnvironment, validateServiceBinding } from "../live-env.ts"; +import type { CodeBlockResult } from "../types.ts"; + +export const serviceFactory: ModifierFactory = (params) => (_args, _next) => + (function* () { + const ctx = yield* useCodeBlock(); + + const start: Operation = { + *[Symbol.iterator]() { + const durable = yield* env; + if (!durable) { + throw new Error("service requires a component binding environment; none is in scope."); + } + const live = liveEnvironment(durable); + const binding = validateServiceBinding(params, durable, live); + + const scope = yield* evalScope; + if (!scope) { + throw new Error("service requires a component eval scope; none is in scope."); + } + + const directory = yield* cwd(); + const startupTimeout = yield* timeout; + const acquired = yield* scope.eval(function* () { + const service = yield* startService({ + command: ctx.content, + cwd: directory, + startupTimeout, + }); + return service.endpoint; + }); + live.values[binding] = unbox(acquired); + return { output: "", exitCode: 0, stderr: "" }; + }, + }; + + return yield* ephemeral(start); + })(); diff --git a/packages/core/src/temp-file-compiler.ts b/packages/core/src/temp-file-compiler.ts index 1a35de56..01cf2075 100644 --- a/packages/core/src/temp-file-compiler.ts +++ b/packages/core/src/temp-file-compiler.ts @@ -36,7 +36,6 @@ const STANDARD_IMPORTS = [ 'import { when } from "@effectionx/converge";', 'import { fetch } from "@effectionx/fetch";', 'import { Sample, Elicitation } from "@executablemd/core";', - 'import { findFreePort } from "@executablemd/runtime";', ]; const EVAL_DIR = ".xmd-eval"; diff --git a/packages/core/tests/ephemeral-service.test.ts b/packages/core/tests/ephemeral-service.test.ts new file mode 100644 index 00000000..401465a1 --- /dev/null +++ b/packages/core/tests/ephemeral-service.test.ts @@ -0,0 +1,265 @@ +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { resource, scoped, type Operation } from "effection"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { API, SERVICE_HOSTNAME } from "@executablemd/runtime"; +import type { ServiceEndpoint } from "@executablemd/runtime"; +import { useStubFs } from "@executablemd/runtime/test"; +import { collect } from "../src/collect.ts"; +import { execute } from "../src/execute.ts"; +import { useTempFileCompiler } from "../src/temp-file-compiler.ts"; + +const SAMPLE = `--- +meta: + componentName: Sample +props: + type: object + properties: {} + additionalProperties: false +--- + +\`\`\`js persist eval +const sampleResult = yield* Sample.operations.sample({ content: "", componentName: "Sample" }); +return sampleResult; +\`\`\` +`; + +function* useServiceStub( + endpoint: ServiceEndpoint, + lifecycle: { starts: number; stops: number }, +): Operation { + yield* API.Service.around( + { + *start() { + return yield* resource(function* (provide) { + lifecycle.starts += 1; + try { + yield* provide({ endpoint }); + } finally { + lifecycle.stops += 1; + } + }); + }, + }, + { at: "min" }, + ); +} + +describe("ephemeral eval and service bindings", () => { + beforeAll(() => useTempFileCompiler()); + + it("reconstructs live bindings without exposing them to durable consumers", function* () { + const stream = new InMemoryStream(); + yield* useStubFs({ + "doc.md": `\n`, + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`js ephemeral eval +const live = 5; +\`\`\` + +\`\`\`js eval +output(typeof live); +\`\`\` + +literal:{live} + +\`\`\`js persist ephemeral eval +yield* Sample.around({ + *sample() { return String(live); }, +}); +\`\`\` + + +`, + "components/Sample.md": SAMPLE, + }); + + const output = String( + yield* collect( + yield* execute({ + path: "doc.md", + stream, + componentDirs: ["components", "."], + }), + ), + ); + + expect(output).toContain("undefined"); + expect(output).toContain("literal:{live}"); + expect(output).toContain("5"); + + const evalEvents = stream + .snapshot() + .filter((event) => event.type === "yield" && event.description.type === "eval"); + expect(evalEvents).toHaveLength(2); + }); + + it("publishes the exact frozen service endpoint only to ephemeral eval", function* () { + const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 43_210 }); + const lifecycle = { starts: 0, stops: 0 }; + const stream = new InMemoryStream(); + yield* useServiceStub(endpoint, lifecycle); + yield* useStubFs({ + "doc.md": ` + +\`\`\`js eval +output(typeof server); +\`\`\` + +projected:{server} + + +\n`, + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`bash service=server exec +cooperative-server +\`\`\` + +endpoint:{server.port} + +\`\`\`js persist ephemeral eval +const captured = server; +yield* Sample.around({ + *sample() { return captured === server && Object.isFrozen(server) ? String(server.port) : "bad"; }, +}); +\`\`\` + + +`, + "components/Sample.md": SAMPLE, + }); + + const output = String( + yield* collect( + yield* execute({ + path: "doc.md", + stream, + componentDirs: ["components", "."], + }), + ), + ); + + expect(output).toContain("endpoint:{server.port}"); + expect(output).toContain("undefined"); + expect(output).toContain("projected:{server}"); + expect(output).toContain("43210"); + expect(lifecycle.starts).toBe(1); + expect(lifecycle.stops).toBe(1); + expect( + stream + .snapshot() + .some((event) => event.type === "yield" && event.description.type === "service"), + ).toBe(false); + }); + + it("completed replay starts neither services nor ephemeral eval", function* () { + const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 41_001 }); + const lifecycle = { starts: 0, stops: 0 }; + const stream = new InMemoryStream(); + const files = { + "doc.md": `\`\`\`bash service=server exec +cooperative-server +\`\`\` + +\`\`\`js ephemeral eval +const reconstructed = server; +\`\`\` + +done +`, + }; + yield* useServiceStub(endpoint, lifecycle); + yield* useStubFs(files); + + expect( + String( + yield* scoped(function* () { + return yield* collect(yield* execute({ path: "doc.md", stream })); + }), + ), + ).toContain("done"); + expect(lifecycle).toEqual({ starts: 1, stops: 1 }); + + expect( + String( + yield* scoped(function* () { + return yield* collect(yield* execute({ path: "doc.md", stream })); + }), + ), + ).toContain("done"); + expect(lifecycle).toEqual({ starts: 1, stops: 1 }); + }); + + it("rejects forbidden output and return values before live exports commit", function* () { + const cases = [ + `const leaked = 1; output("secret-value");`, + `const leaked = 1; return "secret-value";`, + ]; + + for (const source of cases) { + const stream = new InMemoryStream(); + yield* useStubFs({ + "doc.md": `\n\n\`\`\`js ephemeral eval\n${source}\n\`\`\`\n\n\n`, + }); + let failure: unknown; + try { + yield* collect(yield* execute({ path: "doc.md", stream })); + } catch (error) { + failure = error; + } + expect(String(failure)).toContain("ephemeral eval cannot"); + expect(String(failure)).not.toContain("secret-value"); + expect( + stream + .snapshot() + .some((event) => event.type === "yield" && event.description.type === "eval"), + ).toBe(false); + } + }); + + it("validates service binding collisions before spawning", function* () { + const cases: Array<[string, string, number]> = [ + ["service", "requires a binding name", 0], + ["service=bad-name", "must be a valid JavaScript identifier", 0], + ["service=taken", "collides with a durable binding", 0], + ["service=server", "collides with a live binding", 1], + ]; + + for (const [modifier, expectedMessage, expectedStarts] of cases) { + const lifecycle = { starts: 0, stops: 0 }; + const failure = yield* scoped(function* () { + yield* useServiceStub( + Object.freeze({ hostname: SERVICE_HOSTNAME, port: 40_001 + expectedStarts }), + lifecycle, + ); + const prefix = + modifier === "service=taken" + ? "```js eval\nconst taken = 1;\n```\n\n" + : modifier === "service=server" + ? "```bash service=server exec\none\n```\n\n" + : ""; + yield* useStubFs({ + "doc.md": `\n\n${prefix}\`\`\`bash ${modifier} exec\ntwo\n\`\`\`\n\n\n`, + }); + + try { + yield* collect(yield* execute({ path: "doc.md", stream: new InMemoryStream() })); + } catch (error) { + return error; + } + return undefined; + }); + expect(String(failure)).toContain(expectedMessage); + expect(lifecycle.starts).toBe(expectedStarts); + expect(lifecycle.stops).toBe(expectedStarts); + } + }); +}); diff --git a/packages/core/tests/find-free-port.test.ts b/packages/core/tests/find-free-port.test.ts deleted file mode 100644 index 899e8375..00000000 --- a/packages/core/tests/find-free-port.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * Tier R — findFreePort and VM globals tests. - * - * Verifies findFreePort returns a usable port and that VM sandbox - * globals are accessible — both standalone and inside eval blocks. - */ -import { describe, it, beforeAll } from "@executablemd/test-support/bdd"; -import { expect } from "@executablemd/test-support/expect"; -import { race } from "effection"; -import type { Operation } from "effection"; -import { once } from "@effectionx/node"; -import { createServer } from "node:net"; -import { InMemoryStream } from "@executablemd/durable-streams"; -import { findFreePort } from "@executablemd/runtime"; -import { compileBlock } from "../src/eval-context.ts"; -import { useTempFileCompiler } from "../src/temp-file-compiler.ts"; -import { execute } from "../src/execute.ts"; -import { collect } from "../src/collect.ts"; -import { asText } from "./helpers.ts"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; - -function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "xmd-r-test-")); -} - -function cleanup(dir: string): void { - fs.rmSync(dir, { recursive: true, force: true }); -} - -function writeFiles(dir: string, files: Record): void { - for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(dir, filePath); - const fileDir = path.dirname(fullPath); - fs.mkdirSync(fileDir, { recursive: true }); - fs.writeFileSync(fullPath, content); - } -} - -describe("Tier R — findFreePort", () => { - // R1: findFreePort returns a number > 0 - it("R1: findFreePort returns a number > 0", function* () { - const port = yield* findFreePort(); - expect(typeof port).toBe("number"); - expect(port).toBeGreaterThan(0); - expect(port).toBeLessThanOrEqual(65535); - }); - - // R3: Returned port is bindable (open a server on it) - it("R3: returned port is bindable", function* () { - const port = yield* findFreePort(); - - const server = createServer(); - const listening = once(server, "listening"); - const error = once<[Error]>(server, "error"); - - server.listen(port); - - try { - yield* race([ - listening, - { - *[Symbol.iterator]() { - const [err] = yield* error; - throw err; - }, - } as Operation, - ]); - // If we reach here, the server bound successfully - } finally { - server.close(); - } - }); - - // R1b: Two consecutive calls return different ports - it("R1b: two consecutive calls return different ports", function* () { - const port1 = yield* findFreePort(); - const port2 = yield* findFreePort(); - // Ports should both be valid — they may or may not be different - // (the OS recycles ports), but both should be valid numbers - expect(typeof port1).toBe("number"); - expect(typeof port2).toBe("number"); - expect(port1).toBeGreaterThan(0); - expect(port2).toBeGreaterThan(0); - }); -}); - -describe("Tier R — Eval module globals", () => { - beforeAll(() => useTempFileCompiler()); - // R6: when is accessible via generated module imports - it("R6: when is accessible in eval sandbox", function* () { - // Verify that a compiled block can reference 'when' — it's imported - // in the generated module via standard imports - const fn = yield* compileBlock("env.hasWhen = typeof when === 'function';", []); - const env: Record = {}; - yield* fn(env); - expect(env["hasWhen"]).toBe(true); - }); - - // R1c: findFreePort is accessible via generated module imports - it("R1c: findFreePort is accessible in eval sandbox", function* () { - const fn = yield* compileBlock("env.hasFindFreePort = typeof findFreePort === 'function';", []); - const env: Record = {}; - yield* fn(env); - expect(env["hasFindFreePort"]).toBe(true); - }); - - // R6b: All expected Effection globals are available in compiled block - it("R6b: expected Effection globals are in sandbox", function* () { - const checks = [ - "sleep", - "spawn", - "call", - "resource", - "useScope", - "createChannel", - "each", - "suspend", - "createSignal", - "when", - "findFreePort", - ]; - const checkCode = checks - .map((name) => `env["has_${name}"] = typeof ${name} === "function";`) - .join("\n"); - const fn = yield* compileBlock(checkCode, []); - const env: Record = {}; - yield* fn(env); - for (const name of checks) { - expect(env[`has_${name}`]).toBe(true); - } - }); -}); - -describe("Tier R — findFreePort in eval blocks", () => { - beforeAll(() => useTempFileCompiler()); - // R1 (integration): findFreePort accessible and returns a port inside eval - it("R1: findFreePort in eval block returns a port number", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "const port = yield* findFreePort();", - "```", - "", - "```bash exec", - "echo port-found", - "```", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // Eval block ran without error, exec block produced output - expect(output).toContain("port-found"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // R2: Port from findFreePort is usable (bindable) inside eval - // Verified by: daemon binding to the port doesn't error - it("R2: port from findFreePort is usable — daemon binds to it", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "const port = yield* findFreePort();", - "```", - "", - // Daemon binds a Node HTTP server to the allocated port - "```bash daemon exec", - "node -e \"require('http').createServer((q,s)=>s.end('ok')).listen({port})\"", - "```", - "", - "done", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // If the port was in use, the daemon would error and we'd see it - expect(output).toContain("done"); - expect(output).not.toContain("EADDRINUSE"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // R3: findFreePort not called on replay — same port restored from journal - it("R3: findFreePort not called on replay — stored port reused", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "const port = yield* findFreePort();", - "```", - "", - "```bash exec", - "echo port-is-{port}", - "```", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - // Golden run - const output1 = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // Replay — durableEval returns stored port, findFreePort not invoked - const output2 = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // Both runs produce the same port (replayed from journal) - expect(output1).toContain("port-is-"); - expect(output2).toContain("port-is-"); - // Extract port values — they should be identical - const port1 = output1.match(/port-is-(\d+)/)?.[1]; - const port2 = output2.match(/port-is-(\d+)/)?.[1]; - expect(port1).toBeTruthy(); - expect(port2).toBeTruthy(); - expect(port1).toBe(port2); - } finally { - cleanup(tmpDir); - } - }); -}); - -describe("Tier R — when in eval blocks", () => { - beforeAll(() => useTempFileCompiler()); - // R4: when accessible in eval block — retries until condition met - it("R4: when accessible in eval block — converges on condition", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "let count = 0;", - "yield* when(function*() {", - " count++;", - " if (count < 3) throw new Error('not yet');", - " return count;", - "});", - "const result = count;", - "```", - "", - "```bash exec", - "echo when-passed", - "```", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // when() converged after 3 retries, block completed - expect(output).toContain("when-passed"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // R5: when retries on throw — inner function throws twice, then succeeds - it("R5: when retries on throw then succeeds", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "let attempts = 0;", - "const stats = yield* when(function*() {", - " attempts++;", - " if (attempts <= 2) throw new Error('retry');", - " return 'converged';", - "});", - "const converged = stats.value;", - "```", - "", - "```bash exec", - "echo result-{converged}", - "```", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // when() retried and converged, binding is available - expect(output).toContain("result-converged"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // R6: when propagates timeout — assertion never succeeds → error - it("R6: when propagates timeout as error", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "doc.md": [ - "```js eval", - "yield* when(function*() {", - " throw new Error('never-ready');", - "}, { timeout: 200 });", - "```", - "", - "done", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = asText( - yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - }), - ), - ); - - // when() timed out — the error should appear in output - expect(output).toContain("never-ready"); - } finally { - cleanup(tmpDir); - } - }); -}); diff --git a/packages/core/tests/provider-integration.test.ts b/packages/core/tests/provider-integration.test.ts deleted file mode 100644 index ba7a0b8e..00000000 --- a/packages/core/tests/provider-integration.test.ts +++ /dev/null @@ -1,992 +0,0 @@ -/** - * Tier S — Provider component pattern integration tests. - * - * Tests the full provider lifecycle: eval → daemon → when → children → cleanup. - * Uses real subprocesses and a Node HTTP server as the daemon. - * - * The provider component pattern (spec §6.7) is: - * 1. eval block allocates port via findFreePort() - * 2. daemon block starts server on that port - * 3. eval block polls readiness via when(fetch(...)) - * 4. expand with server available - * 5. Component scope closes → daemon terminated - */ -import { describe, it, beforeAll } from "@executablemd/test-support/bdd"; -import { useTempFileCompiler } from "../src/temp-file-compiler.ts"; -import { expect } from "@executablemd/test-support/expect"; -import { InMemoryStream } from "@executablemd/durable-streams"; -import { execute } from "../src/execute.ts"; -import { collect } from "../src/collect.ts"; -import { Sample } from "../src/sample-api.ts"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; - -function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "xmd-s-test-")); -} - -function cleanup(dir: string): void { - fs.rmSync(dir, { recursive: true, force: true }); -} - -function writeFiles(dir: string, files: Record): void { - for (const [filePath, content] of Object.entries(files)) { - const fullPath = path.join(dir, filePath); - const fileDir = path.dirname(fullPath); - fs.mkdirSync(fileDir, { recursive: true }); - fs.writeFileSync(fullPath, content); - } -} - -// Note: S3 test installs stub Sample Api middleware via yield* Sample.around(...) -// directly in the test body — no shared fixture needed. - -/** Node one-liner HTTP server that responds "ok" on any request. */ -const NODE_HTTP_SERVER = - "node -e \"require('http').createServer((q,s)=>{s.writeHead(200);s.end('ok')}).listen({port},'127.0.0.1')\""; - -/** - * Build a provider component file with standard eval→daemon→when→children. - * Uses findFreePort, daemon with the Node HTTP server, and when+fetch for readiness. - */ -function providerComponent(name = "TestProvider"): string { - // Note: eval blocks skip {name} interpolation (handled by expand.ts guard), - // so template literals like `${baseUrl}` work correctly. Daemon/exec blocks - // still interpolate, so {port} in the daemon command is substituted. - // fetch().expect() throws HttpError on non-2xx; when() catches and retries. - return [ - "---", - "meta:", - ` componentName: ${name}`, - "---", - "", - "```js eval", - "const port = yield* findFreePort();", - "const baseUrl = 'http://127.0.0.1:' + port;", - "```", - "", - "```bash daemon exec", - NODE_HTTP_SERVER, - "```", - "", - "```js eval", - "yield* when(function*() {", - " yield* fetch(baseUrl + '/health').expect();", - "}, { timeout: 5000, interval: 50 });", - "```", - "", - "", - ].join("\n"); -} - -/** - * Build the Sample component file — uses persist eval to call Sample Api. - */ -function sampleComponent(): string { - return [ - "---", - "meta:", - " componentName: Sample", - "props:", - " type: object", - " properties:", - " prompt:", - " type: string", - ' default: ""', - " model:", - " type: string", - ' default: ""', - " params:", - " type: string", - ' default: ""', - " additionalProperties: false", - "---", - "", - "```js persist eval", - "const childrenOutput = yield* renderChildren();", - "const content = childrenOutput || prompt || '';", - "const sampleResult = yield* Sample.operations.sample({", - " content,", - " params: params || undefined,", - " componentName: 'Sample',", - " model: model || undefined,", - "});", - "return sampleResult;", - "```", - ].join("\n"); -} - -describe( - "Tier S — Provider component pattern", - { sanitizeOps: false, sanitizeResources: false }, - () => { - beforeAll(() => useTempFileCompiler()); - // S1: Full provider golden run - // eval → daemon → when → children → cleanup - it("S1: full provider golden run — children rendered after daemon ready", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": ["", "", "children-rendered", "", ""].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - expect(output).toContain("children-rendered"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S2: Port flows from eval to daemon - // {port} in daemon content matches findFreePort() result. - // Verified by: daemon starts (readiness check passes), which means - // the interpolated port was valid. - it("S2: port flows from eval to daemon — interpolation works", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": ["", "", "port-flowed", "", ""].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Readiness check passed → port was correctly interpolated - expect(output).toContain("port-flowed"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S3: Children can call Sample component after daemon ready - // Uses Sample Api middleware stub to verify the component works - // within the provider's children. - it("S3: children can call Sample component after daemon ready", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "components/Sample.md": sampleComponent(), - "doc.md": [ - "", - "", - "raw-output", - "", - "", - ].join("\n"), - }); - - // Install stub Sample Api middleware — returns "[sampled]" for any call - yield* Sample.around({ - *sample(_args, _next) { - return "[sampled]"; - }, - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // The stub sample middleware replaces content with "[sampled]" - expect(output).toContain("[sampled]"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S4: Daemon terminated after children expand - // After execute completes, the daemon process is not running. - // Verified by: execute returns (doesn't hang), proving structured - // concurrency cleaned up the daemon. - it("S4: daemon terminated after children expand — execute completes", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": ["", "", "expansion-done", "", ""].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // execute returned — daemon was cleaned up by structured concurrency. - // If daemon wasn't terminated, the process would hang indefinitely. - expect(output).toContain("expansion-done"); - } finally { - cleanup(tmpDir); - } - }); - - // S5: Provider crash during when — daemon exits before ready - // Daemon exits immediately (exit 0), the port is never bound, - // when() polls a port that never responds → timeout → error. - it("S5: provider crash during when — daemon exits before ready", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/CrashProvider.md": [ - "---", - "meta:", - " componentName: CrashProvider", - "---", - "", - "```js eval", - "const port = yield* findFreePort();", - "const baseUrl = 'http://127.0.0.1:' + port;", - "```", - "", - // Daemon exits immediately — port is never bound - "```bash daemon exec", - "exit 0", - "```", - "", - "```js eval", - "yield* when(function*() {", - " yield* fetch(baseUrl + '/health').expect();", - "}, { timeout: 500, interval: 50 });", - "```", - "", - "", - ].join("\n"), - "doc.md": ["", "", "should-not-appear", "", ""].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // when() should timeout or get connection errors — error in output - // Children may or may not appear depending on error handling - expect(output).toMatch(/error|Error|ECONNREFUSED|timeout|Timeout/i); - } finally { - cleanup(tmpDir); - } - }); - - // S6: Provider crash during children — daemon exits mid-expansion - // Daemon starts but exits after a short delay while children are - // still expanding. Error should propagate. - it("S6: provider crash during children — error propagated", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/UnstableProvider.md": [ - "---", - "meta:", - " componentName: UnstableProvider", - "---", - "", - "```js eval", - "const port = yield* findFreePort();", - "const baseUrl = `http://127.0.0.1:${port}`;", - "```", - "", - // Daemon serves for 0.2s then exits - "```bash daemon exec", - `node -e "const s=require('http').createServer((q,r)=>{r.writeHead(200);r.end('ok')}).listen({port},'127.0.0.1');setTimeout(()=>process.exit(1),200)"`, - "```", - "", - "```js eval", - "yield* when(function*() {", - " yield* fetch(`${baseUrl}/health`).expect();", - "}, { timeout: 5000, interval: 50 });", - "```", - "", - "", - ].join("\n"), - "doc.md": [ - "", - "", - // Slow child that outlives the daemon - "```bash exec", - "sleep 1 && echo child-done", - "```", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // The daemon crashed during children expansion. - // The output should contain some indication of the error - // (DaemonExitError) or the child output, depending on timing. - // Key property: execute completed (didn't hang). - expect(output).toBeTruthy(); - } finally { - cleanup(tmpDir); - } - }); - - // S7: Nested providers — outer + inner, inner tears down first - // Uses two distinct component names to avoid cycle detection. - it("S7: nested providers — both start, inner tears down first", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/OuterProvider.md": providerComponent("OuterProvider"), - "components/InnerProvider.md": providerComponent("InnerProvider"), - "doc.md": [ - "", - "", - "outer-before", - "", - "", - "", - "inner-content", - "", - "", - "", - "outer-after", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Both providers started and tore down correctly. - // Inner content appeared between outer content. - expect(output).toContain("outer-before"); - expect(output).toContain("inner-content"); - expect(output).toContain("outer-after"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S8: Nested providers, no model specified — innermost handles - // Two stub providers (OuterProvider, InnerProvider) install - // Sample middleware via Sample.around(). A component with - // no model should be handled by the innermost provider. - it("S8: nested providers, no model — innermost handles", function* () { - const tmpDir = makeTempDir(); - - try { - // Provider component body — installs Sample middleware returning "[handled-by-{model}]" - // Uses { at: "min" } so that child (inner) scope middleware runs before - // parent (outer) scope middleware — achieving innermost-wins semantics. - const stubProviderBody = (componentName: string) => - [ - "---", - "meta:", - ` componentName: ${componentName}`, - "props:", - " type: object", - " properties:", - " model:", - " type: string", - " required: [model]", - " additionalProperties: false", - "---", - "", - "```js persist eval", - "yield* Sample.around({", - " *sample([context], next) {", - " if (context.model !== undefined && context.model !== model) {", - " return yield* next(context);", - " }", - " return '[handled-by-' + model + ']';", - " },", - "}, { at: 'min' });", - "```", - "", - "", - ].join("\n"); - - writeFiles(tmpDir, { - "components/OuterProvider.md": stubProviderBody("OuterProvider"), - "components/InnerProvider.md": stubProviderBody("InnerProvider"), - "components/Sample.md": sampleComponent(), - "doc.md": [ - '', - "", - '', - "", - '', - "", - "", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // No model specified → innermost provider handles - expect(output).toContain("[handled-by-inner-model]"); - expect(output).not.toContain("[handled-by-outer-model]"); - } finally { - cleanup(tmpDir); - } - }); - - // S9: Nested providers, explicit model matching outer - // Inner provider passes through via next(), outer handles. - it("S9: nested providers, explicit model matching outer", function* () { - const tmpDir = makeTempDir(); - - try { - const stubProviderBody = (componentName: string) => - [ - "---", - "meta:", - ` componentName: ${componentName}`, - "props:", - " type: object", - " properties:", - " model:", - " type: string", - " required: [model]", - " additionalProperties: false", - "---", - "", - "```js persist eval", - "yield* Sample.around({", - " *sample([context], next) {", - " if (context.model !== undefined && context.model !== model) {", - " return yield* next(context);", - " }", - " return '[handled-by-' + model + ']';", - " },", - "}, { at: 'min' });", - "```", - "", - "", - ].join("\n"); - - writeFiles(tmpDir, { - "components/OuterProvider.md": stubProviderBody("OuterProvider"), - "components/InnerProvider.md": stubProviderBody("InnerProvider"), - "components/Sample.md": sampleComponent(), - "doc.md": [ - '', - "", - '', - "", - '', - "", - "", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Explicit model=outer-model → inner passes through, outer handles - expect(output).toContain("[handled-by-outer-model]"); - expect(output).not.toContain("[handled-by-inner-model]"); - } finally { - cleanup(tmpDir); - } - }); - - // S10: Nested providers, explicit model matching inner - // Inner provider handles regardless of nesting depth. - it("S10: nested providers, explicit model matching inner", function* () { - const tmpDir = makeTempDir(); - - try { - const stubProviderBody = (componentName: string) => - [ - "---", - "meta:", - ` componentName: ${componentName}`, - "props:", - " type: object", - " properties:", - " model:", - " type: string", - " required: [model]", - " additionalProperties: false", - "---", - "", - "```js persist eval", - "yield* Sample.around({", - " *sample([context], next) {", - " if (context.model !== undefined && context.model !== model) {", - " return yield* next(context);", - " }", - " return '[handled-by-' + model + ']';", - " },", - "}, { at: 'min' });", - "```", - "", - "", - ].join("\n"); - - writeFiles(tmpDir, { - "components/OuterProvider.md": stubProviderBody("OuterProvider"), - "components/InnerProvider.md": stubProviderBody("InnerProvider"), - "components/Sample.md": sampleComponent(), - "doc.md": [ - '', - "", - '', - "", - '', - "", - "", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Explicit model=inner-model → inner handles directly - expect(output).toContain("[handled-by-inner-model]"); - expect(output).not.toContain("[handled-by-outer-model]"); - } finally { - cleanup(tmpDir); - } - }); - - // S11: Unmatched model → chain exhausted → error - // No provider handles the requested model → falls through to the - // core Sample handler which throws a descriptive error. - it("S11: unmatched model — descriptive error", function* () { - const tmpDir = makeTempDir(); - - try { - const stubProvider = () => - [ - "---", - "meta:", - " componentName: StubProvider", - "props:", - " type: object", - " properties:", - " model:", - " type: string", - " required: [model]", - " additionalProperties: false", - "---", - "", - "```js persist eval", - "yield* Sample.around({", - " *sample([context], next) {", - " if (context.model !== undefined && context.model !== model) {", - " return yield* next(context);", - " }", - " return '[handled-by-' + model + ']';", - " },", - "}, { at: 'min' });", - "```", - "", - "", - ].join("\n"); - - writeFiles(tmpDir, { - "components/StubProvider.md": stubProvider(), - "components/Sample.md": sampleComponent(), - "doc.md": [ - '', - "", - '', - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Chain exhausted → core handler throws → error in output - expect(output).toMatch(/error|Error|Sample/i); - } finally { - cleanup(tmpDir); - } - }); - - // S12: Full replay of provider component - // All eval journal entries replayed; daemon starts fresh (ephemeral); - // no live HTTP calls on replay. - it("S12: full replay — eval replayed, daemon starts fresh", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": ["", "", "replay-test", "", ""].join("\n"), - }); - - const stream = new InMemoryStream(); - const componentDirs = [path.join(tmpDir, "components"), tmpDir]; - - // Golden run - const output1 = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs, - }), - ); - - expect(output1).toContain("replay-test"); - expect(output1).not.toContain("ERROR"); - - // Replay — eval blocks replay from journal, daemon spawns fresh - const output2 = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs, - }), - ); - - // Replay produces same output - expect(output2).toContain("replay-test"); - expect(output2).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S13: Modified doc with new children — fresh run produces new output - // First run journals. Second run uses a fresh stream and a modified doc - // with an additional child exec block. The provider starts a new daemon - // and the new child runs live. - it("S13: modified doc with new children — fresh run includes new output", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": ["", "", "original-child", "", ""].join("\n"), - }); - - const componentDirs = [path.join(tmpDir, "components"), tmpDir]; - - // Golden run - const stream1 = new InMemoryStream(); - const output1 = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream: stream1, - componentDirs, - }), - ); - - expect(output1).toContain("original-child"); - - // Modify the doc — add a new exec block in children - writeFiles(tmpDir, { - "doc.md": [ - "", - "", - "original-child", - "", - "```bash exec", - "echo new-child", - "```", - "", - "", - ].join("\n"), - }); - - // Fresh stream — provider starts a new daemon, new child runs live - const stream2 = new InMemoryStream(); - const output2 = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream: stream2, - componentDirs, - }), - ); - - // Both original text and new exec block output appear - expect(output2).toContain("original-child"); - expect(output2).toContain("new-child"); - } finally { - cleanup(tmpDir); - } - }); - - // S14: Multiple provider instances — two sibling providers with different ports - it("S14: multiple provider instances — two siblings, different ports", function* () { - const tmpDir = makeTempDir(); - - try { - writeFiles(tmpDir, { - "components/TestProvider.md": providerComponent(), - "doc.md": [ - "", - "", - "first-provider", - "", - "", - "", - "", - "", - "second-provider", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // Both providers expanded successfully with different ports - expect(output).toContain("first-provider"); - expect(output).toContain("second-provider"); - expect(output).not.toContain("ERROR"); - } finally { - cleanup(tmpDir); - } - }); - - // S13: Expression prop from root env visible through provider nesting - // Root document defines `pr` in an eval block, wraps ReviewBody in - // a provider chain. The {pr} expression prop should resolve from the - // root scope, not the provider's scope. - it("S13: expression prop from root env resolves through provider nesting", function* () { - const tmpDir = makeTempDir(); - - try { - // Middleware component — installs Sample middleware and renders - const wrapper = () => - [ - "---", - "meta:", - " componentName: Wrapper", - "props:", - " type: object", - " properties:", - " label:", - " type: string", - " required: [label]", - " additionalProperties: false", - "---", - "", - "```js persist eval", - "yield* Sample.around({", - " *sample([context], next) {", - " return '[wrapped-by-' + label + ']';", - " },", - "}, { at: 'min' });", - "```", - "", - "", - ].join("\n"); - - // Consumer component — receives pr as a prop and renders it - const consumer = () => - [ - "---", - "meta:", - " componentName: Consumer", - "props:", - " type: object", - " properties:", - " data:", - " type: object", - " required: [data]", - " additionalProperties: false", - "---", - "", - "Received: {props.data.value}", - ].join("\n"); - - writeFiles(tmpDir, { - "components/Wrapper.md": wrapper(), - "components/Consumer.md": consumer(), - "doc.md": [ - "```js eval", - "const pr = { value: 'hello-from-root' };", - "```", - "", - '', - "", - "", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - // The expression prop {pr} should resolve from the root env - expect(output).toContain("hello-from-root"); - expect(output).not.toContain("ERROR"); - expect(output).not.toContain("pr is not defined"); - } finally { - cleanup(tmpDir); - } - }); - - // S15: Expression prop resolves through double nesting (Root → W1 → W2 → Consumer) - // This mirrors the code review agent: Root → OllamaProvider → Instruction → ReviewBody - it("S15: expression prop resolves through double-nested wrappers", function* () { - const tmpDir = makeTempDir(); - - try { - const passthrough = (name: string) => - [ - "---", - "meta:", - ` componentName: ${name}`, - "props:", - " type: object", - " properties:", - " label:", - " type: string", - " required: [label]", - " additionalProperties: false", - "---", - "", - "", - ].join("\n"); - - const consumer = () => - [ - "---", - "meta:", - " componentName: Consumer", - "props:", - " type: object", - " properties:", - " data:", - " type: object", - " required: [data]", - " additionalProperties: false", - "---", - "", - "Received: {props.data.value}", - ].join("\n"); - - writeFiles(tmpDir, { - "components/Outer.md": passthrough("Outer"), - "components/Inner.md": passthrough("Inner"), - "components/Consumer.md": consumer(), - "doc.md": [ - "```js eval", - "const pr = { value: 'deep-hello' };", - "```", - "", - '', - '', - "", - "", - "", - ].join("\n"), - }); - - const stream = new InMemoryStream(); - const output = yield* collect( - yield* execute({ - path: path.join(tmpDir, "doc.md"), - stream, - componentDirs: [path.join(tmpDir, "components"), tmpDir], - }), - ); - - expect(output).toContain("deep-hello"); - expect(output).not.toContain("ERROR"); - expect(output).not.toContain("pr is not defined"); - } finally { - cleanup(tmpDir); - } - }); - }, -); diff --git a/packages/runtime/find-free-port.ts b/packages/runtime/find-free-port.ts deleted file mode 100644 index 474cabab..00000000 --- a/packages/runtime/find-free-port.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * findFreePort — find an available TCP port using the OS. - */ - -import { race } from "effection"; -import type { Operation } from "effection"; -import { once } from "@effectionx/node"; -import { createServer } from "node:net"; - -/** - * Find an available TCP port by binding to port 0 and reading the - * OS-assigned port number. - */ -export function* findFreePort(): Operation { - const server = createServer(); - - const listening = once(server, "listening"); - const error = once<[Error]>(server, "error"); - - server.listen(0); - - try { - const rethrowError: Operation = { - *[Symbol.iterator]() { - const [err] = yield* error; - throw err; - }, - } as Operation; - - yield* race([listening, rethrowError]); - - const addr = server.address(); - if (!addr || typeof addr !== "object") { - throw new Error("findFreePort: unexpected address format"); - } - return addr.port; - } finally { - server.close(); - } -} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 911b26b2..ebe07fd4 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -39,7 +39,6 @@ export { compile, } from "./apis.ts"; export type { EvalBlock, ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.ts"; -export { findFreePort } from "./find-free-port.ts"; export { Service, SERVICE_HOSTNAME, diff --git a/packages/testing/tests/smoke.test.ts b/packages/testing/tests/smoke.test.ts index aca48aac..7b788d9a 100644 --- a/packages/testing/tests/smoke.test.ts +++ b/packages/testing/tests/smoke.test.ts @@ -9,6 +9,8 @@ import type { ExecuteOptions } from "@executablemd/core"; import { useTesting } from "../src/use-testing.ts"; import type { TestResult } from "../src/test-api.ts"; import type { Json } from "@executablemd/core"; +import { SERVICE_HOSTNAME } from "@executablemd/runtime"; +import { useStubService } from "@executablemd/runtime/test"; const EMBEDDED_TESTS = [ "Root frontmatter interpolates into the heading", @@ -48,9 +50,10 @@ const EMBEDDED_TESTS = [ "Eval blocks share bindings", "Persist keeps spawned tasks alive across blocks", "Timeout-bounded eval blocks complete", - "findFreePort allocates a free port", "Eval bindings interpolate into exec blocks", - "A daemon serves requests until its scope closes", + "Ephemeral eval reconstructs live bindings without rendering", + "A daemon stays alive until its scope closes", + "A cooperative service publishes a scoped live endpoint", "A standalone Thing's resource outlives it", "An empty paired Thing renders nothing and keeps nothing", "A paired Thing's resource is live only while its content expands", @@ -94,6 +97,7 @@ interface SmokeSession { function* runSmokeSession(options: ExecuteOptions): Operation { return yield* scoped(function* () { + yield* useStubService(Object.freeze({ hostname: SERVICE_HOSTNAME, port: 45_678 })); const tests = yield* useTesting(); const execution = yield* execute(options); const result = yield* execution; diff --git a/packages/workflow/mod.ts b/packages/workflow/mod.ts index 74aa9390..c36be847 100644 --- a/packages/workflow/mod.ts +++ b/packages/workflow/mod.ts @@ -28,6 +28,7 @@ export { Git, GitRevisionError, revParse } from "./src/git.ts"; export type { GitApi } from "./src/git.ts"; export { getWorkflowRun, useWorkflow } from "./src/run.ts"; export type { WorkflowRun } from "./src/run.ts"; +export { useWorkflowServiceDenial, WorkflowServiceDeniedError } from "./src/service-denial.ts"; export { WorkflowRunStorage, WorkflowStorageProviderError } from "./src/storage/api.ts"; export type { diff --git a/packages/workflow/src/service-denial.ts b/packages/workflow/src/service-denial.ts new file mode 100644 index 00000000..81201ebb --- /dev/null +++ b/packages/workflow/src/service-denial.ts @@ -0,0 +1,24 @@ +/** Workflow authority boundary for native service startup. */ + +import type { Operation } from "effection"; +import { API } from "@executablemd/runtime"; + +export class WorkflowServiceDeniedError extends Error { + override name = "WorkflowServiceDeniedError"; + + constructor() { + super("workflow execution is not authorized to start a native service"); + } +} + +export function useWorkflowServiceDenial(): Operation { + return API.Service.around( + { + // deno-lint-ignore require-yield + *start() { + throw new WorkflowServiceDeniedError(); + }, + }, + { at: "min" }, + ); +} diff --git a/packages/workflow/tests/service-denial.test.ts b/packages/workflow/tests/service-denial.test.ts new file mode 100644 index 00000000..9541b75f --- /dev/null +++ b/packages/workflow/tests/service-denial.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import { API, SERVICE_HOSTNAME, startService } from "@executablemd/runtime"; +import { useWorkflowServiceDenial, WorkflowServiceDeniedError } from "../src/service-denial.ts"; + +describe("workflow service denial", () => { + it("blocks an inherited host provider without delegating", function* () { + let hostCalls = 0; + yield* API.Service.around( + { + *start() { + hostCalls += 1; + return { + endpoint: Object.freeze({ hostname: SERVICE_HOSTNAME, port: 41_111 }), + }; + }, + }, + { at: "min" }, + ); + + let failure: unknown; + try { + yield* scoped(function* () { + yield* useWorkflowServiceDenial(); + yield* startService({ command: "must-not-run" }); + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(WorkflowServiceDeniedError); + expect(hostCalls).toBe(0); + + const service = yield* startService({ command: "allowed-outside-workflow" }); + expect(service.endpoint.port).toBe(41_111); + expect(hostCalls).toBe(1); + }); +}); diff --git a/smoke-test/CooperativeProvider.md b/smoke-test/CooperativeProvider.md new file mode 100644 index 00000000..83f7140c --- /dev/null +++ b/smoke-test/CooperativeProvider.md @@ -0,0 +1,19 @@ +--- +meta: + componentName: CooperativeProvider +--- + +```bash service=server exec +node packages/cli/tests/fixtures/cooperative-service.mjs normal smoke +``` + +```js persist ephemeral eval +const endpoint = server; +yield* Sample.around({ + *sample() { + return `cooperative:${endpoint.hostname}:${Object.isFrozen(endpoint)}`; + }, +}); +``` + + diff --git a/smoke-test/Guide/Daemons.md b/smoke-test/Guide/Daemons.md index c1a11ea3..1bd61e70 100644 --- a/smoke-test/Guide/Daemons.md +++ b/smoke-test/Guide/Daemons.md @@ -1,36 +1,33 @@
-The `daemon` modifier starts a long-running process that survives across -subsequent blocks. Combined with `when()` for readiness polling, this -implements the provider pattern: start a service, wait until it's ready, -then run against it. The test below allocates a port, starts a Node HTTP -server as a daemon, polls until it responds, and asserts the response — -when the test's scope closes, structured concurrency terminates the -daemon with no manual cleanup. +The `daemon` modifier starts an arbitrary long-running process with configuration +the document or host already owns. The test below starts a process that remains +alive while the next block runs. When the test's scope closes, structured +concurrency terminates the daemon with no manual cleanup. Cooperative dynamic +services use `service=` instead.
- -```js eval -const daemonPort = yield * findFreePort(); -const daemonUrl = "http://127.0.0.1:" + daemonPort; -``` + ```bash daemon exec -node -e "require('http').createServer((q,s)=>{s.writeHead(200);s.end('daemon-ok')}).listen({daemonPort},'127.0.0.1')" -``` -```js eval -yield * - when( - function* () { - yield* fetch(daemonUrl + "/health").expect(); - }, - { timeout: 5000, interval: 50 }, - ); +node -e "setInterval(() => {}, 1000)" ``` ```bash exec -curl -s http://127.0.0.1:{daemonPort} +echo daemon-ok ``` + + + + + + + + + diff --git a/smoke-test/Guide/Evaluation.md b/smoke-test/Guide/Evaluation.md index 7d3613a6..b7a86715 100644 --- a/smoke-test/Guide/Evaluation.md +++ b/smoke-test/Guide/Evaluation.md @@ -6,9 +6,10 @@ blocks execute in the same process, sharing a binding environment across blocks within a component. Eval blocks produce **no rendered output** — they exist for bindings and side effects. The `persist` modifier extends a block's resource lifetime from the block scope to the component scope, -the `timeout` modifier cancels a block that overruns its duration, the -`findFreePort` VM global allocates a free TCP port, and bare `{name}` -references interpolate eval bindings into other code blocks. +the `timeout` modifier cancels a block that overruns its duration, and bare +`{name}` references interpolate durable eval bindings into other code blocks. +`ephemeral eval` reconstructs invocation-local live bindings without output or +journal events. @@ -58,21 +59,23 @@ const startedAt = Date.now(); - -```js eval -const port = yield * findFreePort(); -``` - - - ```js eval -const port = yield * findFreePort(); +const label = "configured"; ``` ```bash exec -echo "Server would start on port {port}" +echo "Service is {label}" +``` + + + + + + +```js ephemeral eval +const reconstructed = "live"; ``` - + diff --git a/smoke-test/Guide/Summary.md b/smoke-test/Guide/Summary.md index be9e4373..f823d142 100644 --- a/smoke-test/Guide/Summary.md +++ b/smoke-test/Guide/Summary.md @@ -31,10 +31,12 @@ cat <<'TABLE' | persist resource survival | spawn in persist eval + when() converge | | timeout modifier | js timeout=30s eval block | | eval + exec coexistence | Both modifier types in same document | -| findFreePort VM global | yield* findFreePort() in eval block | -| eval binding interpolation| {port} in exec block from eval binding | +| ephemeral eval | live reconstruction without output | +| eval binding interpolation| {label} in exec block from eval binding | | daemon modifier | bash daemon exec starts background proc | -| daemon + when readiness | Daemon server polled until ready | +| daemon fixed configuration| Arbitrary process stays scope-owned | +| service modifier | Cooperative process publishes live endpoint | +| cooperative provider | persist ephemeral eval scopes middleware | | provider pattern | StubProvider installs Sample middleware | | per-component eval scope | Each provider gets isolated middleware | | props in env.values | model prop available in eval blocks | From fac8d452d44cbe01fd2d9424d1df517fb44e9e52 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:05:45 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20Document=20cooperative=20ser?= =?UTF-8?q?vice=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +- architecture.md | 29 ++ site/routes/docs/exec-eval.tsx | 45 ++- site/routes/docs/providers.tsx | 3 - specs/code-review-agent-spec.md | 2 +- specs/executable-mdx-spec.md | 479 +++++++++++++------------------- 6 files changed, 271 insertions(+), 314 deletions(-) diff --git a/README.md b/README.md index 95578b36..008f43de 100644 --- a/README.md +++ b/README.md @@ -168,22 +168,30 @@ Built-in modifiers: - `silent` - execute but suppress rendered output. - `persist` - keep resources created by an eval block alive for the component lifetime. - `timeout=30s` - cancel a long-running block. -- `daemon` - start a long-running subprocess tied to the component scope. +- `daemon` - start an arbitrary fixed-configuration subprocess tied to the component scope. +- `service=name` - start a cooperative service and publish its live loopback endpoint. +- `ephemeral` - reconstruct live eval state without writing a journal event. LLM sampling is not a fence modifier — it happens through the `` component installed by provider middleware (see [Provider components](#provider-components)). ## Eval blocks -`eval` blocks run in a shared VM context and binding environment for the current component. +Plain `eval` blocks run in a shared durable binding environment for the current component. ````md -```ts eval -const port = yield* findFreePort(); -const baseUrl = `http://127.0.0.1:${port}`; +```bash service=server exec +node cooperative-server.js ``` -```bash daemon exec -./server --port {port} +```ts persist ephemeral eval +import { callService } from "./client.ts"; + +const endpoint = server; +yield* Sample.around({ + *sample([request]) { + return yield* callService(endpoint, request); + }, +}); ``` ```` @@ -193,6 +201,8 @@ Highlights: - Bare `{name}` interpolation inside executable block content reads from eval bindings. - `output("...")` lets an eval block render text into the document. - `renderChildren()` and `render(markdown)` let eval blocks render nested content intentionally. +- `ephemeral eval` reruns during live execution and partial replay, exports only invocation-local live bindings, and cannot render output. +- Live service endpoints are available only to `ephemeral eval`; they never enter interpolation, durable effect descriptions, or the journal. ## Provider components @@ -200,11 +210,10 @@ The repo includes reusable markdown components (in `packages/core/components/`) - `AnthropicProvider.md` - `OllamaProvider.md` -- `LlamafileProvider.md` - `Sample.md` - `Instruction.md` -These components combine `eval`, `daemon`, readiness checks, and `Sample` middleware so a document can talk to a cloud or local model server without custom runtime wiring. +These components combine eval and `Sample` middleware so a document can talk to a cloud or already-running local model server without custom runtime wiring. A local process provider uses authenticated cooperative startup through `service=`. [`packages/core/examples/hello-world.md`](packages/core/examples/hello-world.md) shows the pattern combining a cloud model (Claude) and a local model (Ollama). Provider docs currently need the built-in components on the search path: diff --git a/architecture.md b/architecture.md index ef6a1b50..8c3002d8 100644 --- a/architecture.md +++ b/architecture.md @@ -43,6 +43,8 @@ Existing documents and code get aligned to this section retroactively. | suspension | a durable wait: a crash restarts into the same wait | | Workspace | the provider-neutral, run-owned environment that supplies retained filesystem, repository, process and working-directory capabilities to a workflow | | ephemeral | a replay classification for an operation, context or attachment that runs again to reconstruct live execution; its result is not substituted from the journal and it owns no durable workflow state | +| live binding | an execution-owned value reconstructed ephemerally for the current document execution; it is visible only to constructs that explicitly consume the live binding overlay and never enters interpolation or the journal | +| cooperative service | a scoped host process that publishes its authenticated loopback endpoint through the executable.md service-readiness protocol and remains supervised for the lifetime of its acquiring operation | | effect transaction | the single atomic SQLite transaction that publishes one Workspace-local mutation together with that effect's journal result | | external effect | an effect whose provider-owned outcome cannot participate in the Workspace SQLite transaction and therefore requires a stable identity and provider reconciliation | | checkpoint | a completed journal boundary associated with the logical Workspace root visible after that effect | @@ -583,6 +585,29 @@ execution. No middleware sees it; it is never the document's own outcome. journal is parsed, never trusted — an unreadable record is refused, not coerced. +## Replay-safe live services + +A cooperative service belongs to the document execution that acquires it. The +host owns port selection, process spawning, protocol authentication, startup +supervision and teardown. Shared runtime code reaches that behavior through the +provider-neutral `API.Service`; it never imports a host process or networking +API. + +The service publishes a frozen loopback endpoint as a live binding. Live +bindings form an overlay on the component's durable eval bindings: `ephemeral +eval` reads both and may add live bindings atomically, while ordinary `eval`, +code-block interpolation and journal serialization read durable bindings only. +A live binding cannot shadow a durable binding, and a durable value cannot be +replaced by a live one. + +Service acquisition and `ephemeral eval` execute again during partial replay so +the current process and middleware chain are reconstructed. A completed +document replay returns its recorded result without expanding the document and +therefore starts no service. Workflow execution installs a non-delegating +`API.Service` denial provider: a workflow cannot reach an inherited host +adapter, because a run-owned durable service requires stable identity and +reconciliation rather than an execution-owned live process. + ## State ownership All state is scoped to the operation that owns it, so it is torn down when @@ -637,6 +662,10 @@ Status is measured against main. | `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, and retains its identity, state, document executions and filtered journal | built on main | | caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main | +| `API.Service` / `startService()` | acquires an authenticated, supervised loopback process as a scoped provider-neutral resource | built on main | +| `service=` | publishes the acquired endpoint into the live binding overlay for its invocation | built on main | +| `ephemeral eval` | reconstructs live middleware and bindings without a journal entry | built on main | +| workflow service denial | prevents workflow documents from inheriting an ordinary host service adapter | built on main | | `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI | defined in `specs/workflow-workspace-spec.md`, unbuilt; the lookup it resumes through is built | | implicit workflow Workspace | retains provider-neutral filesystem, repository and attachment state by run ID | defined in `specs/workflow-workspace-spec.md`, unbuilt (#218) | | Repository / Worktree / transactional Git effects | compose named checkouts and publish local mutations with their journal result | defined in `specs/workflow-workspace-spec.md`, unbuilt | diff --git a/site/routes/docs/exec-eval.tsx b/site/routes/docs/exec-eval.tsx index 92c4c380..5d99b639 100644 --- a/site/routes/docs/exec-eval.tsx +++ b/site/routes/docs/exec-eval.tsx @@ -4,13 +4,17 @@ import { NextCard } from "../../components/NextCard.tsx"; const CHAIN = "```bash silent timeout=30s exec\ngit diff --stat\n```"; -const EVAL = `\`\`\`ts eval -const port = yield* findFreePort(); -const baseUrl = \`http://127.0.0.1:\${port}\`; +const EVAL = `\`\`\`bash service=server exec +node cooperative-server.js \`\`\` -\`\`\`bash daemon exec -./server --port {port} +\`\`\`ts persist ephemeral eval +import { callService } from "./client.ts"; + +const endpoint = server; +yield* Sample.around({ + *sample([request]) { return yield* callService(endpoint, request); }, +}); \`\`\``; export default define.page(function ExecEval() { @@ -48,7 +52,17 @@ export default define.page(function ExecEval() {
  • daemon{" "} - — start a long-running subprocess tied to the component invocation. + — start an arbitrary fixed-configuration subprocess tied to the + component invocation. +
  • +
  • + service=name{" "} + — start a cooperative service and publish its invocation-local + endpoint. +
  • +
  • + ephemeral{" "} + — reconstruct live eval state without writing a journal event.
  • @@ -59,13 +73,17 @@ export default define.page(function ExecEval() { installed by provider middleware.

    -

    Eval blocks share bindings

    +

    Durable and live bindings

    - eval{" "} - blocks run in a shared binding environment for the current component. - Top-level bindings export automatically to later blocks, and bare{" "} - {"{name}"}{" "} + Plain eval{" "} + blocks run in a shared durable binding environment for the current + component. Top-level bindings export automatically to later blocks, and + bare {"{name}"}{" "} interpolation inside any executable block reads from them. + ephemeral eval{" "} + reruns during partial replay and can also read invocation-local live + bindings such as service endpoints; those bindings are never + interpolated or journaled.

    {EVAL} @@ -86,8 +104,9 @@ export default define.page(function ExecEval() { daemon exec{" "} starts a long-lived process, returns control immediately, and is torn down by structured concurrency when the component invocation completes — - no manual cleanup. Combined with readiness polling, this is how provider - components run local model servers. + no manual cleanup. It remains the primitive for processes whose fixed + configuration the document or host manages explicitly. Dynamic service + endpoints use the authenticated service=name protocol.

    diff --git a/site/routes/docs/providers.tsx b/site/routes/docs/providers.tsx index 8ecbd5fe..d4bfbd06 100644 --- a/site/routes/docs/providers.tsx +++ b/site/routes/docs/providers.tsx @@ -43,9 +43,6 @@ export default define.page(function Providers() { OllamaProvider.md{" "} — local models via a running Ollama server. -
  • - LlamafileProvider.md — local models via llamafile. -
  • Sample.md — the sampling call itself.
  • diff --git a/specs/code-review-agent-spec.md b/specs/code-review-agent-spec.md index 9376e8c8..53b9f01c 100644 --- a/specs/code-review-agent-spec.md +++ b/specs/code-review-agent-spec.md @@ -61,7 +61,7 @@ All executable.md core changes and the full agent implementation are complete: - **Simplified `SampleContext`** to `{content, model?, params?, system?, componentName?}` (PR #35) - **Removed `sample` modifier** — all LLM calls via `` component (PR #35) - **Renamed `Instruction.md` input** `text` → `system` for clarity -- **Fixed broken providers** — `OllamaProvider`, `LlamafileProvider`, +- **Fixed broken providers** — `OllamaProvider`, `AnthropicProvider` updated to use direct `fetch()` calls - **Component resolution** — review components resolved via `--component-dir .reviews/components --component-dir packages/core/components` diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 5c2977ee..dbfa5990 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -484,6 +484,8 @@ registry.set("eval", evalFactory); registry.set("persist", persistFactory); registry.set("timeout", timeoutFactory); registry.set("daemon", daemonFactory); +registry.set("ephemeral", ephemeralFactory); +registry.set("service", serviceFactory); ``` Custom factories can be provided via `ExecuteOptions.modifiers`. @@ -538,6 +540,35 @@ Observable behavior of an `eval` block: - The block's rendered output is the `output()` text, or the coerced return value when `output()` was not called (§4.7). +**`ephemeral eval`** evaluates the compiled block without a durable operation +or journal entry. It reconstructs execution-owned state during partial replay, +so it reads the component's durable bindings plus its live binding overlay and +publishes new live bindings atomically after successful completion. It accepts +only a nullish return value, and calling `output()` is an error. The modifier is +valid only directly around the terminal `eval`; `persist ephemeral eval` keeps +installed middleware in the invocation eval scope. + +**`service=`** is a terminal service-acquisition modifier. The code +block content is the shell command passed to `startService()`, and the required +parameter is the live binding that receives the frozen +`{ hostname: "127.0.0.1", port }` endpoint. Binding syntax and collisions are +validated before process acquisition. The modifier produces no output or +journal entry, and the acquired service remains supervised until the component +invocation closes. + +````markdown +```bash service=server exec +./server --cooperative +``` + +```ts persist ephemeral eval +import { callProvider } from "./client.ts"; + +const endpoint = server; +yield* Sample.around({ /* provider middleware using endpoint */ }); +``` +```` + **`daemon`** — spawns a long-running subprocess and immediately returns control to the document. The process is alive for the duration of the component invocation and killed when that invocation's @@ -560,6 +591,10 @@ The `exec` modifier appears in the chain but is never invoked — the info string is purely syntactic: it satisfies the detection rule and signals to readers that this block runs a command. +`daemon` is for fixed-configuration background processes. It does not allocate +or publish a dynamic endpoint and does not establish readiness; cooperative +network services use `service=`. + | Property | `exec` | `daemon` | |---|---|---| | Waits for exit | Yes | No | @@ -745,8 +780,8 @@ const Sample = createApi("Sample", { ``` Sample Api calls route through the `EvalScope` so that middleware -installed by `persist eval` blocks (e.g., `LlamafileProvider`'s -`Sample.around()`) is visible — `evalScope.eval()` runs the operation in +installed by `persist ephemeral eval` blocks in provider components is +visible — `evalScope.eval()` runs the operation in the same spawned task where the middleware was installed. #### Sample middleware examples @@ -1096,13 +1131,11 @@ import { sleep, spawn, call, resource, useScope, createChannel, each, suspend, c import { when } from "@effectionx/converge"; import { fetch } from "@effectionx/fetch"; import { Sample } from "@executablemd/core"; -import { findFreePort } from "@executablemd/runtime"; ``` These imports resolve through Deno's import map (`deno.json`). `@executablemd/core` re-exports executable.md-specific APIs from its root -barrel (`packages/core/mod.ts`); `findFreePort` comes from `@executablemd/runtime` -(and is also re-exported by `packages/core/mod.ts`). +barrel (`packages/core/mod.ts`). `useContent` is **not** among them. It projects content, and a projection settles its errors under the error mode of the block that started it, which the @@ -1119,73 +1152,54 @@ of the same name cannot collide. The exact list lives in the `STANDARD_IMPORTS` constant, which both compilers share (`src/data-uri-compiler.ts`, `src/temp-file-compiler.ts`). -#### `findFreePort` +#### Cooperative service API -`findFreePort` is available in eval blocks as a standard import -(`@executablemd/runtime`). It is an Effection -`Operation`. It binds a `node:net` TCP server to -port 0 (OS-assigned), reads the port number, and closes the server. -It uses Effection's structured concurrency primitives (`once` from -`@effectionx/node` for event bridging, `race` for error handling): +`@executablemd/runtime` exports provider-neutral `API.Service` under the stable +context-api name `runtime.service`, and its ordinary operation as +`startService()`: ```typescript -import { race } from "effection"; -import { once } from "@effectionx/node"; -import { createServer } from "node:net"; - -export function* findFreePort(): Operation { - const server = createServer(); +interface ServiceEndpoint { + readonly hostname: string; + readonly port: number; +} - const listening = once(server, "listening"); - const error = once<[Error]>(server, "error"); +interface ServiceStartOptions { + readonly command: string; + readonly cwd?: string; + readonly startupTimeout?: number; +} - server.listen(0); +interface ServiceResource { + readonly endpoint: Readonly; +} - try { - const rethrowError: Operation = { - *[Symbol.iterator]() { - const [err] = yield* error; - throw err; - }, - } as Operation; - - yield* race([listening, rethrowError]); - - const addr = server.address(); - if (!addr || typeof addr !== "object") { - throw new Error("findFreePort: unexpected address format"); - } - return addr.port; - } finally { - server.close(); - } +interface ServiceHandler { + start(options: ServiceStartOptions): Operation; } ``` -The returned port number is a JSON-serializable primitive. When used in an -eval block, it is exported to `env.values` and included in that block's -diagnostic result. Each new run calls `findFreePort()` again. - -There is a small race window between closing the server and the -caller binding the port — acceptable in practice, since daemon -processes are expected to bind immediately after allocation. +The endpoint is exactly a newly constructed frozen `{ hostname, port }` object. +The terminal handler throws `ServiceProviderError`; shared runtime code never +detects a host or silently starts a native process. Runtime-named CLI adapters +provide the authenticated loopback implementation described in §6.7. #### `when` `when` from `@effectionx/converge` retries an inner operation with -backoff until it completes without throwing. It is the idiomatic way -to poll a readiness endpoint: +backoff until it completes without throwing. It is useful for transient +application-level conditions after an endpoint already exists: ```typescript yield* when(function* () { - yield* fetch(`http://127.0.0.1:${port}/health`).expect(); + yield* fetch(`${baseUrl}/health`).expect(); }); ``` -`fetch().expect()` from `@effectionx/fetch` throws `HttpError` on -non-2xx responses. Network-level errors (connection refused before the -daemon is listening) throw natively. `when` catches both and retries -until the assertion passes or the timeout expires. +`fetch().expect()` from `@effectionx/fetch` throws `HttpError` on non-2xx +responses. `when` catches it and retries until the assertion passes or the +timeout expires. Cooperative-service startup readiness is established by the +host protocol, not by polling from a document. #### Compiling blocks @@ -1224,7 +1238,7 @@ The env preamble (`const { x, y } = env;`) is already in the Each run compiles and imports the current transformed source. -### 4.3 Binding environment +### 4.3 Durable and live binding environments ```typescript // src/types.ts @@ -1241,6 +1255,26 @@ scope-locally around each component body, so eval blocks within a component share bindings without leaking into parent or sibling components. +`EvalEnv.values` is the **durable binding environment**. A private live overlay +belongs to the same component environment but is not part of the public +`EvalEnv` shape. The overlay holds service endpoints and values exported by +`ephemeral eval`. Content projection switches back to the caller's environment, +so a component's live overlay remains isolated from its parent, siblings and +projected caller content. + +| Consumer | Durable bindings | Live bindings | +| --- | --- | --- | +| ordinary `eval` | yes | no | +| `ephemeral eval` | yes | yes | +| code-block and prose interpolation | yes | no | +| journal serialization and replay restore | yes | no | + +The two namespaces may not overlap. `service=` validates the binding +name and checks both environments before acquiring a process. `ephemeral eval` +validates all exports against durable and existing live names before committing +any of them. A failed block publishes nothing. Values from the live overlay are +never substituted into a durable effect's source and never serialized. + **Each evaluation runs against a snapshot, and commits its exports.** A block receives a plain object holding the bindings as they stood when it started. Its declared exports are published to the shared record once it completes @@ -1456,6 +1490,7 @@ run but are absent from the diagnostic trace. | `src/expansion.ts` | `Expansion`, `getExpansion()` — what an executable element knows about its own expansion (§5.6) | | `src/projection.ts` | `ProjectionHandle`, `ProjectionRequest`, `ActiveProjection` — content projection (§6.3) | | `src/eval-env.ts` | `evaluationEnv()`, `commitExports()` — per-evaluation binding snapshot and commit (§4.3) | +| `src/live-env.ts` | execution-owned live binding overlay, collision validation and atomic export commit (§4.3) | | `src/errors.ts` | `ErrorMode`, `settle()`, `DocumentationError`, `ContentError` — the error-mode decision (§6.9) and the function-content failure boundary (§5.1.2) | | `packages/test-support/bdd.ts` | Cross-runtime Effection BDD adapter — drives `@std/testing/bdd`, `node:test`, and `bun:test` | | `src/eval-handler.ts` | `evalFactory` | @@ -1463,15 +1498,20 @@ run but are absent from the diagnostic trace. | `src/modifiers/persist.ts` | `persistFactory` | | `src/modifiers/timeout.ts` | `timeoutFactory`, `parseDuration()` | | `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier | +| `src/modifiers/ephemeral.ts` | `ephemeralFactory` — replay-safe live eval wrapper | +| `src/modifiers/service.ts` | `serviceFactory` — scoped cooperative-service acquisition | | `src/sample-api.ts` | `Sample` Api definition (§3.4) — LLM middleware surface | -| `packages/runtime/find-free-port.ts` | `findFreePort()` — OS port allocation via `node:net` (separate `runtime` workspace package) | +| `packages/runtime/service.ts` | provider-neutral `API.Service`, readiness protocol types and `startService()` resource | | `src/api.ts` | Document Output Api definition, exports `output` (§9.2) | | `src/collect.ts` | `collect()` — stream consumption helper, returns `Result` | | `src/output/mod.ts` | Barrel export for output middleware | | `src/output/normalize.ts` | `useNormalizedOutput()` — whitespace normalization middleware (§9.4) | | `src/output/terminal.ts` | `useTerminalOutput()` — terminal ANSI formatting middleware (§9.5) | | `packages/cli/src/cli.ts` | Runtime-neutral CLI (separate `cli` workspace package) with `--verbose`, `--journal`, and `--raw` flags; Output Api stream consumption (§9.6) | -| `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs its `API.Env.command` adapter and compiler, then calls `runXmd` | +| `packages/cli/src/service-host.ts` | shared authenticated readiness observer and supervised host-process adapter | +| `packages/cli/src/{deno,node,bun,compiled}-service.ts` | runtime-named service adapters for token, environment and stdio behavior | +| `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs matching `API.Env` and `API.Service` adapters, then calls `runXmd` | +| `packages/workflow/src/service-denial.ts` | non-delegating workflow service denial middleware | | `packages/cli/src/file-stream.ts` | `FileStream` — JSONL-backed `DurableStream` implementation | Dependencies: `@effectionx/scope-eval`, `@effectionx/timebox`, @@ -2982,14 +3022,14 @@ into surrounding prose naturally: ````markdown ```ts eval -const port = yield* findFreePort(); -const baseUrl = `http://127.0.0.1:${port}`; +const environment = "staging"; +const dashboard = "https://status.example.test/staging"; ``` -Server running at {baseUrl} on port {port}. +{environment} status: {dashboard}. ```` -Renders: `Server running at http://127.0.0.1:49821 on port 49821.` +Renders: `staging status: https://status.example.test/staging.` **Precedence:** `{meta.*}` and `{props.*}` resolve first because they are the component's declared interface. If a component declares @@ -3653,15 +3693,15 @@ segment interpolation pipeline). ````markdown ```ts eval -const port = yield* findFreePort(); +const outputDirectory = "./build"; ``` -```bash daemon exec -./server --port {port} +```bash exec +mkdir -p {outputDirectory} ``` ```` -`{port}` resolves to the number exported by the first block. The +`{outputDirectory}` resolves to the string exported by the first block. The substituted content is used to build the subprocess command. #### Interpolation syntax and precedence @@ -3738,221 +3778,75 @@ and strings. ### 6.7 Provider component pattern -A **provider component** is a regular markdown component whose body -follows a structured pattern that manages background process lifecycle -for its subtree. It composes `eval` + `daemon` + `eval` (readiness) -+ `eval` (middleware install) + `` into a reusable -component — no framework-level configuration, no `ExecuteOptions` -changes. +A **provider component** is a regular markdown component whose body acquires a +cooperative service and installs middleware for its subtree. It composes +`service=` + `persist ephemeral eval` + ``; the host, not +the document, owns endpoint allocation and authenticated readiness. #### Structure -1. An `eval` block that allocates resources and exports bindings - (port, URLs). -2. A `daemon` block that starts the background process using those - bindings. -3. An `eval` block that polls for readiness using `when`. -4. An `eval` block that installs Sample Api middleware, closing over - `baseUrl` and `model`. -5. `` — the subtree that uses the running process. - -#### `LlamafileProvider.md` — standard library component - -**File:** `components/LlamafileProvider.md` +1. A `service=` block starts the cooperative command and waits for an + authenticated readiness record. +2. A `persist ephemeral eval` block reads the live endpoint and installs + provider middleware in the component eval scope. +3. `` expands the subtree while the supervised process and + middleware are active. -This file is part of the executable.md standard library and is distributed -alongside the executable.md package. It is a regular markdown component — no -code changes to the executable.md runtime are required to add it. +#### Example ````markdown --- props: type: object properties: - model: - type: string - description: > - Model identifier. Serves two purposes: it is passed as the `model` field - in every /v1/chat/completions request, and it is the routing key that - sample calls use to target this provider. Must be unique among all - LlamafileProvider instances active simultaneously in the same document run. - Example: "phi3-mini", "qwen3-0.6b" command: type: string - description: > - Shell command to start the llamafile or llama.cpp server. - {port} is substituted with the allocated port number before execution. - Example: "./phi3-mini.llamafile --nobrowser" - required: [model, command] + required: [command] additionalProperties: false --- -```ts eval -const port = yield* findFreePort(); -const baseUrl = `http://127.0.0.1:${port}`; -``` - -```bash daemon exec -{command} --port {port} -``` - -```ts eval -yield* when(function* () { - yield* (yield* fetch(`${baseUrl}/health`)).expect(); -}); +```bash service=server exec +{command} ``` -```ts eval -// Install Sample Api middleware on the current component scope. -// baseUrl and model are closed over here — no context lookup at call time. -// Routing: if context.model matches our model (or is unspecified), handle it. -// Otherwise pass through to the next handler (an outer provider or the default). -const scope = yield* useScope(); -scope.around(Sample, function* ([context], next) { - if (context.model !== undefined && context.model !== model) { - return yield* next(context); - } - - const messages = []; - if (context.system) { - messages.push({ role: "system", content: context.system }); - } - messages.push({ role: "user", content: context.content }); - - const result = yield* fetch(`${baseUrl}/v1/chat/completions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model, messages, temperature: 0, max_tokens: 2048 }), - }) - .expect() - .json(); - - return result.choices[0].message.content; +```ts persist ephemeral eval +const endpoint = server; +yield* Sample.around({ + *sample([context], next) { + if (context.model !== undefined && context.model !== "local") { + return yield* next(context); + } + return yield* callProvider(endpoint, context); + }, }); ``` - + ```` -#### Prop-to-binding requirement (DEC-EX-09) - -Code block content uses bare `{name}` binding interpolation from -`env.values` (§6.6). `{command}` in the daemon block and `model` in -the middleware eval block must be present in `env.values` when those -blocks run. - -Both are declared props, not eval results — so they are not -automatically in `env.values`. The expansion engine must pre-populate -`env.values` with all declared prop values at component invocation -time, before any block executes: - -```typescript -// In expandComponent(), before block execution: -const componentEnv: EvalEnv = { values: { ...validatedProps } }; -``` - -This makes all props available as bare bindings without any explicit -capture step in the component body. It is consistent with how -`findFreePort()` results enter `env.values`. - -#### Execution sequence - -**Block 1 — resource allocation:** -`findFreePort()` is available as a VM global. The eval block exports -`port` and `baseUrl` to `env.values`. The eval operation journals the result. - -**Block 2 — daemon spawn:** -`{port}` is substituted from `env.values` into the command content -before `buildCommand` runs. `{command}` is also substituted from -`env.values` (populated from props via DEC-EX-09). The resulting -command is forked into the eval scope. Control returns immediately. -No journal entry. - -**Block 3 — readiness:** -`when` polls with retries until the server responds. The eval operation -journals the result. - -**Block 4 — middleware install:** -`Sample` and `fetch` are standard imports in the generated -eval module (via `@executablemd/core` and `@effectionx/fetch`, §4.2). The -middleware closes over `baseUrl` and `model` at install time and issues the -`/v1/chat/completions` request inline. Routing: -if `context.model` matches the provider's model (or is unspecified), -handle it; otherwise pass through via `next()`. - -**``:** -Child expansion runs with the server alive and ready. `sample` calls -in children reach the server at `baseUrl`. - -**Component scope closes:** -The eval scope closes. The daemon task is cancelled. The subprocess -is terminated. - -#### Usage examples - -**Single provider:** - -```markdown - - - -``` - -**Multiple models, sequential:** - -```markdown - - - - - - - - -``` - -Each provider spawns its own process on its own port and executes -sequentially — the second provider's process is not started until the -first provider's scope closes. - -**Multiple models, simultaneous (nested):** - -```markdown - - - - - -``` - -Both processes are alive simultaneously during `` -expansion. Sample calls route by `model`: - -````markdown - - -```bash sample exec -classify this output -``` - +The executable receives `XMD_SERVICE_HOST`, `XMD_SERVICE_PORT` and a +cryptographically random `XMD_SERVICE_TOKEN`. It binds exactly the supplied +loopback host and port, then writes one newline-terminated readiness record to +stdout: -```bash sample[model=phi3-mini] exec -summarize this +```text +XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":43210} ``` - -```bash sample[model=qwen3-0.6b] exec -extract entities -``` - -```` +The host installs its byte-level stdout observer before spawn. It suppresses +the matching record, forwards every other stdout byte unchanged, and accepts +readiness only when the JSON object has exactly those fields and matches the +expected version, token, host and port. Malformed, forged, duplicate or late +records fail the service without exposing the token or raw protocol line. +Startup races readiness against process exit, protocol failure and the +contextual startup timeout. After readiness the host continues supervising +process exit and duplicate records until acquisition ends. -Routing works because the inner provider's middleware is installed -later and therefore sits higher in the middleware chain (traversed -first). When `context.model` is `"phi3-mini"`, the inner handler -accepts it. When `context.model` is `"qwen3-0.6b"`, the inner handler -calls `next()` and the outer handler accepts it. When `context.model` -is undefined, the innermost accepting handler wins. +The service binding is live, so only the `ephemeral eval` block can read it. +The block installs middleware in the invocation scope through `persist`; plain +eval, interpolation and the journal cannot observe the endpoint. Partial replay +runs both blocks again to reconstruct a current process and middleware chain. +A completed replay expands nothing and starts no process. #### Nesting providers @@ -3960,15 +3854,16 @@ Provider components nest naturally — each establishes its own eval scope boundary: ```markdown - + - + ``` -Both providers' scopes are nested — the inner provider is torn down -before the outer, in standard structured concurrency order. +Each acquisition receives a distinct host-selected endpoint and token. Both +services remain live while the nested report expands; the inner service tears +down before the outer in standard structured-concurrency order. ### 6.8 Sample component @@ -5634,9 +5529,11 @@ export function* useTerminalOutput(): Operation { **File:** `packages/cli/src/cli.ts` (separate `cli` workspace package) `cli.ts` makes no host-specific decision about **how this xmd is re-invoked, -how an eval block compiles, or which runtime it is on**. A runtime-named +how an eval block compiles, how a service process is hosted, or which runtime +it is on**. A runtime-named entrypoint — `deno.ts`, `node.ts`, `bun.ts`, `compiled.ts` — installs its -`API.Env` providers with `{ at: "min" }` and then calls `runXmd(args)`: +`API.Env` providers with `{ at: "min" }` and passes the matching service +installer to `runXmd(args, installService)`: ```typescript yield* API.Env.around( @@ -5651,9 +5548,15 @@ yield* API.Env.around( }, { at: "min" }, ); -yield* runXmd(args); +yield* runXmd(args, useDenoService); ``` +The installer is invoked only for `xmd run` and `xmd test`, immediately before +`execute()`. Help, inspection and agent-worker paths never install or acquire a +service. Each adapter supplies host randomness, inherited environment and +stdout/stderr writers to the shared service host; production adapters reject a +non-loopback requested host before spawning. + Each entrypoint owns its own argument order; there is no shared builder for them to forward to. `cli.ts` still reaches the host directly for terminal and journal I/O (`process.stdout`, `node:fs/promises`); routing those through @@ -5784,9 +5687,9 @@ Given a document: ```markdown # Title - + - + ## Footer ``` @@ -6231,7 +6134,7 @@ visible warning blocks, gather into a separate error report). |---|------|--------| | H1 | Missing-provider printed errors | `importComponent`, `applyModifiers`, `codeBlock`, and `content` report clear missing-provider errors when no provider is installed | | H2 | Effection globals available | `sleep`, `spawn`, `createChannel` accessible in compiled block via standard imports | -| H3 | executable.md globals available | `findFreePort`, `Sample`, `when` accessible in compiled block via `@executablemd/core` | +| H3 | executable.md globals available | `Sample` and `when` accessible in compiled block via `@executablemd/core` | | H5 | `compileBlock` returns generator function | `yield* compileBlock(code, [])` returns a callable generator function | | H6 | Distinct modules per block | Each `compileBlock` call produces a separate module — no shared state between blocks | | H7 | `data:` URI encoding | Module source with special characters is correctly URI-encoded | @@ -6713,20 +6616,20 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | Q6 | Process terminated on component error | If child expansion throws, process still terminated | | Q7 | Root cancellation resolves promptly | Cancelling the root resolves instead of waiting on the daemon's blocks; invocation teardown order is covered by Tier O | | Q8 | Premature exit propagates as error | Process exits during expansion → `daemon()` throws → `ErrorSegment` in output | -| Q9 | `{port}` interpolation in daemon content | Binding from preceding `eval` block substituted into command | +| Q9 | Durable interpolation in daemon content | Binding from preceding `eval` block substituted into fixed command configuration | | Q10 | `daemon` without eval scope | No eval scope in scope → clear error | | Q11 | Modifier chain: `bash daemon exec` | `daemon` is outermost terminal; `exec` present but never called | | Q12 | Repeated run: daemon starts and stops | Process is spawned and terminated again | -| Q13 | Repeated run: current port used | Eval allocates a current port; daemon binds it | +| Q13 | Repeated run: process restarts | Fixed daemon command is spawned on each document execution | | Q14 | Projected daemon terminated with the invocation | A daemon the caller wrote and the component only projected is gone in a block after the component; inside `` it is signalled while the directory still exists | -### Tier R — VM globals +### Tier R — VM globals and live eval | # | Test | Verify | |---|------|--------| -| R1 | `findFreePort` accessible in eval block | `yield* findFreePort()` succeeds, returns a number | -| R2 | `findFreePort` returns usable port | Returned port is bindable (no EADDRINUSE) | -| R3 | `findFreePort` called on each run | No port is restored from an earlier trace | +| R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble | +| R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string | +| R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry | | R4 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds | | R5 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves | | R6 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit | @@ -6735,20 +6638,20 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | # | Test | Verify | |---|------|--------| -| S1 | Full provider golden run | eval → daemon → when → children → cleanup | -| S2 | Port flows from eval to daemon | `{port}` in daemon content matches `findFreePort()` result | -| S3 | Children can call sample after daemon ready | `sample` calls in children reach daemon endpoint | -| S4 | Daemon terminated after children expand | After `execute` completes, process not running | -| S5 | Provider crash during `when` | Daemon exits before ready → `when` fails → `ErrorSegment` | -| S6 | Provider crash during children | Daemon exits mid-child-expansion → error propagated | +| S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup | +| S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval | +| S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint | +| S4 | Service terminated after children expand | After `execute` completes, process is not running | +| S5 | Process exits before readiness | Startup fails with a dedicated exit-before-ready error | +| S6 | Provider crashes during children | Supervision failure propagates and teardown completes | | S7 | Nested providers | Outer + inner provider → both start, inner tears down first | | S8 | Nested providers, no model | Innermost provider handles sample call | | S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles | | S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth | | S11 | Unmatched model | Chain exhausted → descriptive error naming the model | -| S12 | Repeated provider run | Eval, daemon, readiness, and HTTP calls execute again | -| S13 | Interrupted provider run | Partial diagnostic trace is not accepted as resume input | -| S14 | Multiple provider instances | Two provider siblings → two processes, different ports | +| S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix | +| S13 | Completed replay | Completed document returns without process spawn or token allocation | +| S14 | Multiple provider instances | Two provider siblings → two processes, distinct endpoints and tokens | ### Tier EO — eval output() function @@ -7132,28 +7035,28 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 38 | `daemon` produces no journal entry | The process is an ephemeral resource and starts on every run | | 39 | Eval binding interpolation uses bare `{name}` syntax | Distinct from `{meta.key}` and `{props.key}` namespaces; local eval bindings are local variables, not namespaced data; regex excludes names containing `.` to avoid conflicts | | 40 | Eval binding interpolation runs in the expansion engine, not inside modifier factories | Modifiers transform execution results — they are not responsible for preparing source text; one interpolation site in `expandSegments` is consistent with how text segment interpolation already works, and keeps modifier factories free of knowledge about the binding environment | -| 41 | `findFreePort` is a standalone VM global using `node:net` | Port allocation is platform I/O; the function uses Effection's `once` + `race` for event handling and `try/finally` for guaranteed cleanup; exposed in the eval sandbox alongside other Effection globals | -| 42 | `findFreePort` result journaled with its eval block | The port number is a scalar export; no separate journal-entry type is needed | +| 41 | Service allocation belongs to a host adapter | Holding an OS-selected port across spawn is host process and networking behavior; shared runtime and document code use provider-neutral `API.Service` | +| 42 | Service endpoints are live bindings | The endpoint identifies an execution-owned process and is reconstructed during partial replay, so it cannot enter durable eval, interpolation or the journal | | 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed | | 44 | Provider lifecycle expressed as a component, not an `ExecuteOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required | -| 45 | Readiness check is a separate `eval` block, not internal to `daemon` | Auditable — strategy visible in the document; replaceable — different daemons have different readiness signals; composable with `when`'s configurable backoff | -| 46 | Sample middleware reads `baseUrl` from `env.values` | Avoids a dedicated inference server context key; the binding environment is already the shared state carrier for within-component coordination; scope-correct because a fresh environment is provided per component expansion | +| 45 | Cooperative readiness is an authenticated stdout protocol | The host observes from before spawn, verifies version/token/host/port exactly and supervises the process continuously without a close-and-rebind race | +| 46 | Provider middleware reads an endpoint from the live overlay | The current endpoint is available to `ephemeral eval` while remaining invisible to durable effects and interpolation | | 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation | | 48 | `output()` is a plain function, not `yield*` | Output is a synchronous side effect (mutating a ref), not an Effection operation; making it a function keeps the API simple and avoids requiring generator context just to set output text | | 49 | `__output` stored alongside exports in journal | Avoids a separate journal entry; `__output` is extracted before merging into `env.values` to prevent namespace pollution | | 50 | `renderChildren`/`render` are closures in `env.values`, not an Api | A Render Api would require middleware installation per component; closures are simpler and capture the expansion context at the injection point; they are non-serializable and silently omitted from the journal | | 51 | `renderChildren`/`render` install the caller's environment and `parentEvalScope` as scope-local providers | Children are caller-provided content and expand in the caller's scope context; the component's `childEvalScope` sequential channel is for its own `persist eval` blocks, not for expanding caller content; children may create resources (nested components, daemons) but their lifecycle is bound by their place in the expansion tree; installing providers inside the closure ensures the correct context is visible regardless of which task it runs in | -| 52 | `durableSample` routes through `EvalScope` | Sample Api middleware installed by `persist eval` blocks (e.g., `LlamafileProvider`'s `Sample.around()`) lives in the eval scope's task hierarchy; routing through `evalScope.eval()` ensures the middleware chain is found | +| 52 | `durableSample` routes through `EvalScope` | Sample Api middleware installed by provider components with `persist ephemeral eval` lives in the eval scope's task hierarchy; routing through `evalScope.eval()` ensures the middleware chain is found | | 53 | Sample component calls `Sample.operations.sample()` directly | The enclosing eval operation journals the complete block result | | 54 | Sample component props default to empty string, not undefined | `validateProps` omits optional props with no default from `env.values`, causing `ReferenceError` in eval blocks; empty-string defaults ensure the variables exist; `model \|\| undefined` converts empty to undefined for routing semantics | | 55 | `daemon()` uses `shell: true` | Matches `bash exec` block semantics — the same command string passed to `bash -c` is passed to the shell; handles shell expansions and PATH lookups correctly | -| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `execute()` would execute in the outer scope at call time, where the binding environment has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active | +| 56 | Provider installs middleware inside its invocation | Middleware closes over the current live endpoint and remains lexically scoped to the subtree that owns the service | | 57 | Routing key is `model`, not a separate `name` prop | Model identity is the natural key — it unifies "which server to route to" with "which model to request"; a separate `name` prop would require keeping two values in sync with no added expressiveness | | 58 | `context.model === undefined` routes to innermost provider | Omitting a model is the common case for single-provider documents; innermost-wins matches how middleware chains work — handlers installed later sit higher in the chain and are traversed first | -| 59 | `callLlamafile()` is a standard import in generated eval modules | Provider components are markdown files — eval blocks are compiled into `data:` URI modules that import executable.md globals from `@executablemd/core`; functions like `callLlamafile`, `callOllama`, `callAnthropic`, `Sample`, `findFreePort`, and `useContent` are available via this import | +| 59 | Provider components use ordinary generated-module imports | Provider-specific client functions may be imported explicitly; executable.md supplies `Sample`, `when`, `fetch` and the contextual document bindings | | 60 | Props pre-populated into `env.values` at component invocation | Code block content uses bare `{name}` binding interpolation from `env.values`; props must enter `env.values` at invocation time to be accessible in code blocks; consistent with how eval bindings work | -| 61 | `callLlamafile()` uses `@effectionx/fetch` | The HTTP call is an Effection operation executed once per document run | -| 62 | `LlamafileProvider.md` hardcodes `/health` endpoint | All major llamafile/llama.cpp-compatible servers use `/health`; the hardcoded path covers the supported targets | +| 61 | Provider HTTP calls use `@effectionx/fetch` | Calls remain Effection operations under structured cancellation and the provider's lexical middleware | +| 62 | Protocol readiness and application health are separate | The readiness record proves the cooperative process owns the assigned endpoint; an application may still use `when` for a later domain-specific condition | | 63 | `stdio: "inherit"` is the default for `daemon()` | During development, seeing server logs in the terminal is valuable; production deployments can pass `stdio: "ignore"`; the executable.md `daemonFactory` passes no stdio option, defaulting to `"inherit"` | | 64 | `DocumentOutput` Api with single `output` operation | Extensible to progress/printed errors; middleware-composable via `scope.around`; single Api surface for all output concerns | | 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call | @@ -7180,7 +7083,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 89 | `{meta.*}` / `{props.*}` resolve before bare `{name}` | Component contract (frontmatter) takes precedence over internal eval state; dotted vs bare syntax prevents actual collisions | | 90 | `\{` escaping applies to both passes | Consistent escaping behavior regardless of which pass would match; pre-existing gap in §6.6 fixed for both code blocks and text segments | | 85 | Eval block `return` as rendered output | Eval blocks can produce output via `return "text"` in addition to `output("text")`; `output()` wins if both used; null/undefined returns produce no output; lets a component's whole body be one conditional expression | -| 86 | `sample` modifier removed | All LLM calls go through the `` component; removes `sampleFactory`, `durableSample`, `callLlamafile`, `callOllama`, `callAnthropic`; simplifies the modifier chain to pure exec/eval concerns | +| 86 | `sample` modifier removed | All LLM calls go through the `` component; provider-specific call helpers are not built-in modifier behavior | | 87 | `SampleContext` simplified to content-centric shape | Changed from exec-centric `{stdout, stderr, exitCode, command, language}` to content-centric `{content, model?, params?, system?, componentName?}`; providers build their own messages directly instead of relying on `buildDefaultMessages` | | 91 | Projected children carry caller's eval env | Children substituted via `` are tagged with `projectedEnv`. Expression props on projected children resolve against merged env (caller + component), with component bindings taking precedence. Follows React's lexical scoping model. | | 92 | Multi-level projection env propagation | When `expandComponent` receives `projectedEnv`, it merges it with the current context env before tagging the next level's children. Creates a cumulative chain: Root → Provider → Instruction → ReviewBody all carry root bindings. Innermost-wins on collision. | From 006a4cbee7d734e874e41f83d8d307563cabdade Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:39:40 -0400 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20cooperative=20service?= =?UTF-8?q?=20lifecycle=20blockers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/service-host.ts | 173 ++++++++--- .../tests/fixtures/cooperative-service.mjs | 14 + packages/cli/tests/service-document.test.ts | 231 ++++++++++++++- packages/cli/tests/service-host.test.ts | 278 +++++++++++++++++- packages/core/src/eval-handler.ts | 4 + packages/core/src/live-env.ts | 10 + packages/core/tests/ephemeral-service.test.ts | 200 ++++++++++++- packages/durable-streams/effect.ts | 26 +- specs/executable-mdx-spec.md | 55 ++-- 9 files changed, 909 insertions(+), 82 deletions(-) diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts index e128ca95..1174461f 100644 --- a/packages/cli/src/service-host.ts +++ b/packages/cli/src/service-host.ts @@ -1,7 +1,7 @@ /** Shared mechanics for the runtime-named cooperative service adapters. */ -import { ensure, race, resource, withResolvers, type Operation } from "effection"; -import { daemon, Stdio } from "@effectionx/process"; +import { ensure, race, resource, scoped, withResolvers, type Operation } from "effection"; +import { daemon, Stdio, type Daemon } from "@effectionx/process"; import { timebox } from "@effectionx/timebox"; import { API, @@ -11,6 +11,7 @@ import { ServiceProtocolDuplicateError, ServiceProtocolMalformedError, ServiceStartupTimeoutError, + ServiceTeardownError, ServiceUnexpectedExitError, parseServiceReadyRecord, timeout, @@ -24,47 +25,33 @@ interface HostServiceAdapter { stderr(bytes: Uint8Array): void; } -interface ProtocolObserver { +export interface ProtocolObserver { stdout(bytes: Uint8Array): Operation; flush(): Operation; } const encoder = new TextEncoder(); const prefixBytes = encoder.encode(SERVICE_READY_PREFIX); +const MAX_PROTOCOL_RECORD_BYTES = 1_024; -function concat(left: Uint8Array, right: Uint8Array): Uint8Array { - const joined = new Uint8Array(left.byteLength + right.byteLength); - joined.set(left); - joined.set(right, left.byteLength); - return joined; -} - -function startsWithPrefix(line: Uint8Array): boolean { - if (line.byteLength < prefixBytes.byteLength) { - return false; - } - for (let index = 0; index < prefixBytes.byteLength; index += 1) { - if (line[index] !== prefixBytes[index]) { - return false; - } - } - return true; -} - -function createProtocolObserver(options: { +export function createProtocolObserver(options: { token: string; ready(endpoint: ServiceEndpoint): void; fail(error: Error): void; forward(bytes: Uint8Array): void; }): ProtocolObserver { - let pending: Uint8Array = new Uint8Array(); + let state: "prefix" | "ordinary" | "protocol" | "suppressed" = "prefix"; + let possiblePrefix: number[] = []; + let protocolRecord: number[] = []; let readinessSeen = false; - function consume(line: Uint8Array): void { - if (!startsWithPrefix(line)) { - options.forward(line); - return; - } + function beginLine(): void { + state = "prefix"; + possiblePrefix = []; + protocolRecord = []; + } + + function consumeProtocolRecord(): void { if (readinessSeen) { options.fail(new ServiceProtocolDuplicateError()); return; @@ -73,7 +60,7 @@ function createProtocolObserver(options: { let payload: string; try { payload = new TextDecoder("utf-8", { fatal: true }).decode( - line.subarray(prefixBytes.byteLength, line.byteLength - 1), + Uint8Array.from(protocolRecord.slice(prefixBytes.byteLength)), ); } catch { options.fail(new ServiceProtocolMalformedError()); @@ -89,27 +76,91 @@ function createProtocolObserver(options: { } } + function appendProtocol(bytes: Uint8Array, start: number, end: number): boolean { + if (protocolRecord.length + end - start > MAX_PROTOCOL_RECORD_BYTES) { + protocolRecord = []; + state = "suppressed"; + options.fail(new ServiceProtocolMalformedError()); + return false; + } + for (let index = start; index < end; index += 1) { + protocolRecord.push(bytes[index]!); + } + return true; + } + return { *stdout(bytes: Uint8Array): Operation { - pending = concat(pending, bytes); - let newline = pending.indexOf(10); - while (newline !== -1) { - const line = pending.slice(0, newline + 1); - pending = pending.slice(newline + 1); - consume(line); - newline = pending.indexOf(10); + let index = 0; + while (index < bytes.byteLength) { + if (state === "prefix") { + const byte = bytes[index]!; + if (byte === prefixBytes[possiblePrefix.length]) { + possiblePrefix.push(byte); + index += 1; + if (possiblePrefix.length === prefixBytes.byteLength) { + protocolRecord = [...possiblePrefix]; + possiblePrefix = []; + state = "protocol"; + } + continue; + } + + if (possiblePrefix.length > 0) { + options.forward(Uint8Array.from(possiblePrefix)); + possiblePrefix = []; + } + state = "ordinary"; + continue; + } + + if (state === "ordinary") { + const newline = bytes.indexOf(10, index); + if (newline === -1) { + options.forward(bytes.slice(index)); + index = bytes.byteLength; + } else { + options.forward(bytes.slice(index, newline + 1)); + index = newline + 1; + beginLine(); + } + continue; + } + + if (state === "protocol") { + const newline = bytes.indexOf(10, index); + const end = newline === -1 ? bytes.byteLength : newline; + if (!appendProtocol(bytes, index, end)) { + index = end; + continue; + } + if (newline === -1) { + index = bytes.byteLength; + } else { + consumeProtocolRecord(); + index = newline + 1; + beginLine(); + } + continue; + } + + const newline = bytes.indexOf(10, index); + if (newline === -1) { + index = bytes.byteLength; + } else { + index = newline + 1; + beginLine(); + } } }, *flush(): Operation { - if (pending.byteLength > 0) { - if (startsWithPrefix(pending)) { - pending = new Uint8Array(); - throw new ServiceProtocolMalformedError(); - } else { - options.forward(pending); - pending = new Uint8Array(); - } + if (state === "prefix" && possiblePrefix.length > 0) { + options.forward(Uint8Array.from(possiblePrefix)); + } else if (state === "protocol") { + beginLine(); + throw new ServiceProtocolMalformedError(); } + beginLine(); }, }; } @@ -157,6 +208,32 @@ function* waitForStartup(options: { return result.value; } +function serviceProcess(options: { + command: string; + cwd?: string; + environment: Record; +}): Operation { + return resource(function* (provide) { + let published = false; + try { + yield* scoped(function* () { + const process = yield* daemon(options.command, { + shell: true, + cwd: options.cwd, + env: options.environment, + }); + published = true; + yield* provide(process); + }); + } catch (error) { + if (!published || error instanceof ServiceTeardownError) { + throw error; + } + throw new ServiceTeardownError({ cause: error }); + } + }); +} + function startHostService( options: ServiceStartOptions, adapter: HostServiceAdapter, @@ -196,10 +273,10 @@ function startHostService( XMD_SERVICE_HOST: SERVICE_HOSTNAME, XMD_SERVICE_PORT: "0", }; - const process = yield* daemon(options.command, { - shell: true, + const process = yield* serviceProcess({ + command: options.command, cwd: options.cwd, - env: environment, + environment, }); const endpoint = yield* waitForStartup({ ready: ready.operation, diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs index 25eeca41..aa4089c0 100644 --- a/packages/cli/tests/fixtures/cooperative-service.mjs +++ b/packages/cli/tests/fixtures/cooperative-service.mjs @@ -18,7 +18,16 @@ if (mode === "non-cooperative") { process.stderr.write("service stderr before readiness\n"); const server = createServer((_request, response) => { + process.stderr.write(`service request:${nonce}\n`); response.end(`service:${nonce}`); + if (mode === "exit-on-request") { + setTimeout(() => process.exit(19), 10); + } + }); + + process.on("SIGTERM", () => { + process.stderr.write(`service stopping:${nonce}\n`); + server.close(() => process.exit(0)); }); server.listen(requestedPort, host, () => { @@ -74,6 +83,11 @@ if (mode === "non-cooperative") { process.stdout.write("service stdout after readiness\n"); process.stderr.write("service stderr after readiness\n"); + if (mode === "unterminated-live-output") { + process.stdout.write("unterminated-live-output"); + process.stderr.write("unterminated live output written\n"); + } + if (mode === "duplicate") { setTimeout(() => process.stdout.write(line), 10); } diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts index 47d60060..fd75d112 100644 --- a/packages/cli/tests/service-document.test.ts +++ b/packages/cli/tests/service-document.test.ts @@ -1,17 +1,22 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { when } from "@effectionx/converge"; import { once } from "@effectionx/node/events"; -import { race, resource, scoped, type Operation } from "effection"; +import { timebox } from "@effectionx/timebox"; +import { race, resource, scoped, sleep, type Operation } from "effection"; import { createServer } from "node:http"; import process from "node:process"; import { InMemoryStream } from "@executablemd/durable-streams"; -import { collect, execute, useTempFileCompiler } from "@executablemd/core"; -import { SERVICE_HOSTNAME } from "@executablemd/runtime"; +import { collect, execute, InvocationTeardownError, useTempFileCompiler } from "@executablemd/core"; +import { SERVICE_HOSTNAME, ServiceUnexpectedExitError } from "@executablemd/runtime"; import { useStubFs } from "@executablemd/runtime/test"; import { inheritedEnvironment, installHostService } from "../src/service-host.ts"; const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname; -const command = `node ${JSON.stringify(fixture)} normal document`; + +function command(mode: string, nonce: string): string { + return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`; +} const SAMPLE = `--- meta: @@ -34,7 +39,7 @@ meta: --- \`\`\`bash service=server exec -${command} +${command("normal", "document")} \`\`\` \`\`\`js persist ephemeral eval @@ -93,6 +98,43 @@ function occupy(port: number): Operation { }); } +function fixturePids(stderr: string[]): number[] { + return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1])); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function* expectGone(pids: number[]): Operation { + const result = yield* timebox(2_000, () => + when(function* () { + if (pids.some(isAlive)) { + throw new Error("service child has not exited yet"); + } + }), + ); + expect(result.timeout).toBe(false); +} + +function hasUnexpectedExit(error: unknown): boolean { + if (error instanceof ServiceUnexpectedExitError) { + return true; + } + if (error instanceof AggregateError) { + return error.errors.some(hasUnexpectedExit); + } + if (error instanceof InvocationTeardownError) { + return error.causes.some(hasUnexpectedExit); + } + return error instanceof Error && error.cause !== undefined && hasUnexpectedExit(error.cause); +} + describe("cooperative service document integration", () => { beforeAll(() => useTempFileCompiler()); @@ -142,4 +184,183 @@ describe("cooperative service document integration", () => { expect(tokenCalls).toBe(2); }); }); + + it("supervises a ready service that exits during projected content without restarting it", function* () { + const stderr: string[] = []; + let tokenCalls = 0; + yield* scoped(function* () { + yield* installHostService({ + token() { + tokenCalls += 1; + return tokenCalls.toString(16).padStart(64, "0"); + }, + environment: () => inheritedEnvironment(process.env), + stdout() {}, + stderr(bytes) { + stderr.push(new TextDecoder().decode(bytes)); + }, + }); + yield* useStubFs({ + "doc.md": "\n", + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`bash service=server exec +${command("exit-on-request", "projected-exit")} +\`\`\` + +\`\`\`js persist ephemeral eval +const endpoint = server; +yield* Sample.around({ + *sample() { + const response = yield* fetch(\`http://\${endpoint.hostname}:\${endpoint.port}\`).expect(); + const text = yield* response.text(); + yield* sleep(100); + return text; + }, +}); +\`\`\` + + +`, + "components/Sample.md": SAMPLE, + }); + + let failure: unknown; + try { + yield* runDocument(new InMemoryStream()); + } catch (error) { + failure = error; + } + + expect(stderr.join("")).toContain("service request:projected-exit"); + expect(hasUnexpectedExit(failure)).toBe(true); + expect(tokenCalls).toBe(1); + }); + yield* expectGone(fixturePids(stderr)); + }); + + it("fails projected content promptly, tears down retained services, and does not restart", function* () { + const stderr: string[] = []; + let tokenCalls = 0; + yield* scoped(function* () { + yield* installHostService({ + token() { + tokenCalls += 1; + return tokenCalls.toString(16).padStart(64, "0"); + }, + environment: () => inheritedEnvironment(process.env), + stdout() {}, + stderr(bytes) { + stderr.push(new TextDecoder().decode(bytes)); + }, + }); + yield* useStubFs({ + "doc.md": "\n", + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`bash service=firstServer exec +${command("normal", "first-retained")} +\`\`\` + +\`\`\`bash service=secondServer exec +${command("normal", "second-retained")} +\`\`\` + + +`, + "components/Broken.md": `--- +meta: + componentName: Broken +--- + +\`\`\`js eval +throw new Error("projected content failure"); +\`\`\` +`, + }); + + const outcome = yield* timebox(2_000, function* () { + try { + yield* runDocument(new InMemoryStream()); + } catch (error) { + return error; + } + return undefined; + }); + expect(outcome.timeout).toBe(false); + if (outcome.timeout) { + throw new Error("document failure did not complete promptly"); + } + expect(String(outcome.value)).toContain("projected content failure"); + expect(tokenCalls).toBe(2); + yield* sleep(50); + expect(tokenCalls).toBe(2); + }); + + const pids = fixturePids(stderr); + expect(pids).toHaveLength(2); + expect(stderr.join("")).toContain("service stopping:first-retained"); + expect(stderr.join("")).toContain("service stopping:second-retained"); + yield* expectGone(pids); + }); + + it("tears down nested provider services from inner lifetime to outer lifetime", function* () { + const stderr: string[] = []; + let tokenCalls = 0; + yield* scoped(function* () { + yield* installHostService({ + token() { + tokenCalls += 1; + return tokenCalls.toString(16).padStart(64, "0"); + }, + environment: () => inheritedEnvironment(process.env), + stdout() {}, + stderr(bytes) { + stderr.push(new TextDecoder().decode(bytes)); + }, + }); + yield* useStubFs({ + "doc.md": "nested content\n", + "components/Outer.md": `--- +meta: + componentName: Outer +--- + +\`\`\`bash service=outerServer exec +${command("normal", "outer")} +\`\`\` + + +`, + "components/Inner.md": `--- +meta: + componentName: Inner +--- + +\`\`\`bash service=innerServer exec +${command("normal", "inner")} +\`\`\` + + +`, + }); + + const output = yield* runDocument(new InMemoryStream()); + expect(output).toContain("nested content"); + expect(tokenCalls).toBe(2); + }); + + const log = stderr.join(""); + expect(log.indexOf("service stopping:inner")).toBeGreaterThan(-1); + expect(log.indexOf("service stopping:outer")).toBeGreaterThan( + log.indexOf("service stopping:inner"), + ); + yield* expectGone(fixturePids(stderr)); + }); }); diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts index 836cc204..921d6954 100644 --- a/packages/cli/tests/service-host.test.ts +++ b/packages/cli/tests/service-host.test.ts @@ -1,8 +1,21 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, spawn, suspend, until, withResolvers, type Operation } from "effection"; +import { + createSignal, + race, + resource, + scoped, + spawn, + suspend, + until, + withResolvers, + type Operation, +} from "effection"; import { when } from "@effectionx/converge"; +import { once } from "@effectionx/node/events"; import { timebox } from "@effectionx/timebox"; +import { ProcessApi, Stdio, type Daemon } from "@effectionx/process"; +import { withInvocation, InvocationTeardownError } from "@executablemd/core"; import { ServiceProcessExitBeforeReadyError, ServiceProtocolDuplicateError, @@ -11,10 +24,16 @@ import { ServiceProtocolMalformedError, ServiceProtocolTokenMismatchError, ServiceStartupTimeoutError, + ServiceTeardownError, ServiceUnexpectedExitError, startService, } from "@executablemd/runtime"; -import { inheritedEnvironment, installHostService } from "../src/service-host.ts"; +import { + createProtocolObserver, + inheritedEnvironment, + installHostService, +} from "../src/service-host.ts"; +import { createServer } from "node:http"; import process from "node:process"; const TOKEN = "12".repeat(32); @@ -62,31 +81,172 @@ function* expectGone(pids: number[]): Operation { expect(result.timeout).toBe(false); } +function occupy(port: number): Operation { + return resource(function* (provide) { + const server = createServer((_request, response) => response.end("foreign")); + const listening = once(server, "listening"); + const failed = once<[Error]>(server, "error"); + server.listen(port, "127.0.0.1"); + yield* race([ + listening, + (function* () { + const [error] = yield* failed; + throw error; + })(), + ]); + try { + yield* provide(); + } finally { + server.close(); + } + }); +} + +function* useTeardownFailure(failure: Error): Operation { + yield* ProcessApi.around({ + *daemon() { + return yield* resource(function* (provide) { + const stdout = createSignal(); + const stderr = createSignal(); + const exited = withResolvers<{ code?: number; signal?: string }>(); + const process = { + pid: 42, + stdin: { send(_data: string) {} }, + stdout, + stderr, + *join() { + return yield* exited.operation; + }, + *expect() { + return yield* exited.operation; + }, + *around(...args: Parameters): ReturnType { + return yield* Stdio.around(...args); + }, + *[Symbol.iterator]() { + yield* suspend(); + }, + } satisfies Daemon; + const record = JSON.stringify({ + version: 1, + token: TOKEN, + hostname: "127.0.0.1", + port: 41_234, + }); + yield* Stdio.operations.stdout(new TextEncoder().encode(`XMD_SERVICE_READY:${record}\n`)); + try { + yield* provide(process); + } finally { + stdout.close(); + stderr.close(); + throw failure; + } + }); + }, + }); +} + describe("cooperative host service adapter", () => { - it("starts real isolated services, forwards live output, and suppresses readiness", function* () { + it("forwards split ordinary bytes immediately and suppresses split protocol records", function* () { + const forwarded: number[] = []; + const endpoints: Array<{ hostname: string; port: number }> = []; + const failures: Error[] = []; + const observer = createProtocolObserver({ + token: TOKEN, + ready: (endpoint) => endpoints.push(endpoint), + fail: (error) => failures.push(error), + forward: (bytes) => forwarded.push(...bytes), + }); + const ordinary = Uint8Array.from([88, 77, 111, 114, 100, 105, 110, 97, 114, 121, 0, 255]); + yield* observer.stdout(ordinary.slice(0, 2)); + yield* observer.stdout(ordinary.slice(2, 7)); + yield* observer.stdout(ordinary.slice(7)); + expect(forwarded).toEqual([...ordinary]); + + forwarded.length = 0; + const record = new TextEncoder().encode( + `\nXMD_SERVICE_READY:${JSON.stringify({ + version: 1, + token: TOKEN, + hostname: "127.0.0.1", + port: 41_235, + })}\n`, + ); + yield* observer.stdout(record.slice(0, 5)); + yield* observer.stdout(record.slice(5, 19)); + yield* observer.stdout(record.slice(19, 47)); + yield* observer.stdout(record.slice(47)); + + expect(forwarded).toEqual([10]); + expect(endpoints).toEqual([{ hostname: "127.0.0.1", port: 41_235 }]); + expect(failures).toEqual([]); + + yield* observer.stdout(record.slice(1)); + expect(failures).toHaveLength(1); + expect(failures[0]).toBeInstanceOf(ServiceProtocolDuplicateError); + expect(forwarded).toEqual([10]); + }); + + it("bounds and suppresses an invalid protocol candidate", function* () { + const forwarded: number[] = []; + const failures: Error[] = []; + const observer = createProtocolObserver({ + token: TOKEN, + ready() {}, + fail: (error) => failures.push(error), + forward: (bytes) => forwarded.push(...bytes), + }); + const secret = `${TOKEN}${"x".repeat(1_024)}`; + const bytes = new TextEncoder().encode(`XMD_SERVICE_READY:${secret}\nordinary`); + + yield* observer.stdout(bytes); + + expect(failures).toHaveLength(1); + expect(failures[0]).toBeInstanceOf(ServiceProtocolMalformedError); + expect(new TextDecoder().decode(Uint8Array.from(forwarded))).toBe("ordinary"); + expect(String(failures[0])).not.toContain(TOKEN); + }); + + it("starts genuinely concurrent isolated services and suppresses readiness", function* () { const stdout: string[] = []; const stderr: string[] = []; yield* scoped(function* () { yield* installHostService(adapter(stdout, stderr)); - const first = yield* startService({ command: command("normal", "first") }); - const second = yield* startService({ command: command("normal", "second") }); + const release = withResolvers(); + const firstReady = withResolvers<{ hostname: string; port: number }>(); + const secondReady = withResolvers<{ hostname: string; port: number }>(); + const firstOwner = yield* spawn(function* () { + const service = yield* startService({ command: command("normal", "first") }); + firstReady.resolve(service.endpoint); + yield* release.operation; + }); + const secondOwner = yield* spawn(function* () { + const service = yield* startService({ command: command("normal", "second") }); + secondReady.resolve(service.endpoint); + yield* release.operation; + }); + const first = yield* firstReady.operation; + const second = yield* secondReady.operation; - expect(first.endpoint.port).not.toBe(second.endpoint.port); - expect(Object.isFrozen(first.endpoint)).toBe(true); + expect(first.port).not.toBe(second.port); + expect(Object.isFrozen(first)).toBe(true); const firstResponse = yield* until( globalThis - .fetch(`http://${first.endpoint.hostname}:${first.endpoint.port}`) + .fetch(`http://${first.hostname}:${first.port}`) .then((response) => response.text()), ); const secondResponse = yield* until( globalThis - .fetch(`http://${second.endpoint.hostname}:${second.endpoint.port}`) + .fetch(`http://${second.hostname}:${second.port}`) .then((response) => response.text()), ); expect(firstResponse).toBe("service:first"); expect(secondResponse).toBe("service:second"); + release.resolve(); + yield* firstOwner; + yield* secondOwner; }); expect(stdout.join("")).toContain("service stdout before readiness"); @@ -158,6 +318,106 @@ describe("cooperative host service adapter", () => { yield* expectGone([pid]); }); + it("cancels after readiness and releases the child listener", function* () { + const stderr: string[] = []; + const ready = withResolvers<{ hostname: string; port: number }>(); + let endpoint = { hostname: "127.0.0.1", port: 0 }; + + yield* scoped(function* () { + yield* installHostService(adapter([], stderr)); + const owner = yield* spawn(function* () { + const service = yield* startService({ command: command("normal", "cancel-ready") }); + ready.resolve(service.endpoint); + yield* suspend(); + }); + endpoint = yield* ready.operation; + const response = yield* until( + globalThis + .fetch(`http://${endpoint.hostname}:${endpoint.port}`) + .then((result) => result.text()), + ); + expect(response).toBe("service:cancel-ready"); + yield* owner.halt(); + yield* expectGone(fixturePids(stderr)); + yield* scoped(() => occupy(endpoint.port)); + }); + }); + + it("forwards unterminated ordinary stdout while the service is still active", function* () { + const stdout: string[] = []; + const stderr: string[] = []; + + yield* scoped(function* () { + yield* installHostService(adapter(stdout, stderr)); + yield* startService({ command: command("unterminated-live-output") }); + const observed = yield* timebox(2_000, () => + when(function* () { + if (!stderr.join("").includes("unterminated live output written")) { + throw new Error("fixture has not written its unterminated stdout yet"); + } + }), + ); + expect(observed.timeout).toBe(false); + expect(stdout.join("")).toContain("unterminated-live-output"); + }); + }); + + it("translates an observable process teardown failure", function* () { + const planted = new Error("injected process teardown failure"); + let failure: unknown; + try { + yield* scoped(function* () { + yield* installHostService(adapter([], [])); + yield* useTeardownFailure(planted); + yield* startService({ command: "injected-service" }); + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(ServiceTeardownError); + if (!(failure instanceof ServiceTeardownError)) { + throw new Error("expected ServiceTeardownError"); + } + expect(failure.cause).toBe(planted); + }); + + it("preserves an execution failure beside a translated teardown failure", function* () { + const execution = new Error("active execution failure"); + const teardown = new Error("injected process teardown failure"); + let failure: unknown; + try { + yield* scoped(function* () { + yield* installHostService(adapter([], [])); + yield* useTeardownFailure(teardown); + yield* withInvocation(function* () { + yield* startService({ command: "injected-service" }); + throw execution; + }); + }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(AggregateError); + if (!(failure instanceof AggregateError)) { + throw new Error("expected AggregateError"); + } + expect(failure.errors[0]).toBe(execution); + expect(failure.errors[1]).toBeInstanceOf(InvocationTeardownError); + const invocationTeardown = failure.errors[1]; + if (!(invocationTeardown instanceof InvocationTeardownError)) { + throw new Error("expected InvocationTeardownError"); + } + expect(invocationTeardown.causes).toHaveLength(1); + expect(invocationTeardown.causes[0]).toBeInstanceOf(ServiceTeardownError); + const serviceTeardown = invocationTeardown.causes[0]; + if (!(serviceTeardown instanceof ServiceTeardownError)) { + throw new Error("expected ServiceTeardownError"); + } + expect(serviceTeardown.cause).toBe(teardown); + }); + it("fails the owning scope when a ready process exits or repeats readiness", function* () { const cases: Array<[string, { prototype: Error }]> = [ ["exit-after", ServiceUnexpectedExitError], diff --git a/packages/core/src/eval-handler.ts b/packages/core/src/eval-handler.ts index 91a50a23..ede9e05f 100644 --- a/packages/core/src/eval-handler.ts +++ b/packages/core/src/eval-handler.ts @@ -21,6 +21,7 @@ import { transformBlock, serializeExports } from "./eval-transform.ts"; import { commitLiveExports, liveEnvironment, + validateDurableExports, validateLiveExports, validateLiveOverlay, } from "./live-env.ts"; @@ -208,6 +209,9 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => return { value: exports as unknown as Json } as Json; }, + { + validate: () => validateDurableExports(transformed.exports, liveEnvironment(evalEnv)), + }, )) as unknown as { value: Json }; if (result.value && typeof result.value === "object") { diff --git a/packages/core/src/live-env.ts b/packages/core/src/live-env.ts index a4cff45d..dbc1896c 100644 --- a/packages/core/src/live-env.ts +++ b/packages/core/src/live-env.ts @@ -127,6 +127,16 @@ export function validateLiveExports(exports: string[], durable: EvalEnv): void { } } +/** Refuse a durable operation before it can execute or restore a colliding export. */ +export function validateDurableExports(exports: string[], live: LiveEnv): void { + const collision = exports.find((name) => name in live.values); + if (collision !== undefined) { + throw new LiveBindingCollisionError( + `durable eval export "${collision}" collides with a live binding`, + ); + } +} + /** Refuse a durable value added after a live binding of the same name. */ export function validateLiveOverlay(durable: EvalEnv, live: LiveEnv): void { const collision = Object.keys(live.values).find((name) => name in durable.values); diff --git a/packages/core/tests/ephemeral-service.test.ts b/packages/core/tests/ephemeral-service.test.ts index 401465a1..d6859b29 100644 --- a/packages/core/tests/ephemeral-service.test.ts +++ b/packages/core/tests/ephemeral-service.test.ts @@ -1,7 +1,11 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { resource, scoped, type Operation } from "effection"; -import { InMemoryStream } from "@executablemd/durable-streams"; +import { + InMemoryStream, + parseDurableEvent, + serializeDurableEvent, +} from "@executablemd/durable-streams"; import { API, SERVICE_HOSTNAME } from "@executablemd/runtime"; import type { ServiceEndpoint } from "@executablemd/runtime"; import { useStubFs } from "@executablemd/runtime/test"; @@ -262,4 +266,198 @@ done expect(lifecycle.stops).toBe(expectedStarts); } }); + + it("rejects a durable eval export that collides with an existing live binding", function* () { + const lifecycle = { starts: 0, stops: 0 }; + const stream = new InMemoryStream(); + yield* useServiceStub(Object.freeze({ hostname: SERVICE_HOSTNAME, port: 40_100 }), lifecycle); + yield* useStubFs({ + "doc.md": ` + +\`\`\`bash service=server exec +cooperative-server +\`\`\` + +\`\`\`js eval +const partial = "must-not-commit"; +const server = "must-not-execute"; +\`\`\` + + +`, + }); + + let failure: unknown; + try { + yield* scoped(function* () { + yield* collect(yield* execute({ path: "doc.md", stream })); + }); + } catch (error) { + failure = error; + } + + expect(String(failure)).toContain("collides with a live binding"); + expect( + stream + .snapshot() + .some((event) => event.type === "yield" && event.description.type === "eval"), + ).toBe(false); + expect(lifecycle).toEqual({ starts: 1, stops: 1 }); + }); + + it("rejects a colliding durable export before partial replay restoration", function* () { + const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 40_101 }); + const lifecycle = { starts: 0, stops: 0 }; + const legacy = new InMemoryStream(); + yield* useServiceStub(endpoint, lifecycle); + yield* useStubFs({ + "doc.md": ` + +\`\`\`bash service=other exec +cooperative-server +\`\`\` + +\`\`\`js eval +const server = "legacy-durable-value"; +\`\`\` + +tail + + +`, + }); + yield* scoped(function* () { + yield* collect(yield* execute({ path: "doc.md", stream: legacy })); + }); + + const events = legacy.snapshot().map((event) => { + const parsed = parseDurableEvent( + serializeDurableEvent(event).replaceAll("service=other", "service=server"), + ); + if (!parsed.ok) { + throw parsed.error; + } + return parsed.value; + }); + const evalYield = events.findIndex( + (event) => event.type === "yield" && event.description.type === "eval", + ); + expect(evalYield).toBeGreaterThan(-1); + const partial = new InMemoryStream(events.slice(0, evalYield + 1)); + const beforeEvalEvents = partial + .snapshot() + .filter((event) => event.type === "yield" && event.description.type === "eval").length; + yield* useStubFs({ + "doc.md": ` + +\`\`\`bash service=server exec +cooperative-server +\`\`\` + +\`\`\`js eval +const server = "legacy-durable-value"; +\`\`\` + +tail + + +`, + }); + + let failure: unknown; + try { + yield* scoped(function* () { + yield* collect(yield* execute({ path: "doc.md", stream: partial })); + }); + } catch (error) { + failure = error; + } + + expect(String(failure)).toContain("collides with a live binding"); + expect( + partial + .snapshot() + .filter((event) => event.type === "yield" && event.description.type === "eval").length, + ).toBe(beforeEvalEvents); + expect(lifecycle).toEqual({ starts: 2, stops: 2 }); + }); + + it("rejects ephemeral exports that collide with durable names before execution", function* () { + const stream = new InMemoryStream(); + yield* useStubFs({ + "doc.md": ` + +\`\`\`js eval +const shared = "durable"; +\`\`\` + +\`\`\`js ephemeral eval +const partial = "must-not-commit"; +const shared = "must-not-execute"; +\`\`\` + + +`, + }); + + let failure: unknown; + try { + yield* scoped(function* () { + yield* collect(yield* execute({ path: "doc.md", stream })); + }); + } catch (error) { + failure = error; + } + + expect(String(failure)).toContain("collides with a durable binding"); + expect( + stream + .snapshot() + .filter((event) => event.type === "yield" && event.description.type === "eval"), + ).toHaveLength(1); + }); + + it("allows a later ephemeral eval to update an existing live binding", function* () { + const stream = new InMemoryStream(); + yield* useStubFs({ + "doc.md": `\n`, + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`js ephemeral eval +const live = "first"; +\`\`\` + +\`\`\`js ephemeral eval +const live = "second"; +\`\`\` + +\`\`\`js persist ephemeral eval +const captured = live; +yield* Sample.around({ + *sample() { return captured; }, +}); +\`\`\` + + +`, + "components/Sample.md": SAMPLE, + }); + + const output = String( + yield* scoped(function* () { + return yield* collect( + yield* execute({ + path: "doc.md", + stream, + componentDirs: ["components", "."], + }), + ); + }), + ); + + expect(output).toContain("second"); + }); }); diff --git a/packages/durable-streams/effect.ts b/packages/durable-streams/effect.ts index 2455251d..3883d830 100644 --- a/packages/durable-streams/effect.ts +++ b/packages/durable-streams/effect.ts @@ -81,6 +81,7 @@ function checkReplay( resolve: Resolve>, routine: CoroutineView, ctx: DurableContext, + validate?: () => void, ): ReplayResult { const entry = ctx.replayIndex.peekYield(ctx.coroutineId); @@ -144,6 +145,16 @@ function checkReplay( // All guards approved — consume the entry and advance cursor ctx.replayIndex.consumeYield(ctx.coroutineId); + try { + validate?.(); + } catch (error) { + resolve({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + return { path: "replayed", teardown: (exit) => exit(VOID_OK) }; + } + // Feed stored result synchronously resolve(protocolToEffection(entry.result)); return { path: "replayed", teardown: (exit) => exit(VOID_OK) }; @@ -293,10 +304,13 @@ export function createDurableEffect( * * @param desc Structured description for the journal and divergence detection * @param execute Returns an Operation to run during live execution + * @param options.validate Runs before live execution or replay restoration; a + * thrown error is not persisted as this operation's result */ export function createDurableOperation( desc: EffectDescription, execute: () => Operation, + options: { validate?: () => void } = {}, ): DurableEffect { return { description: `${desc.type}(${desc.name})`, @@ -307,12 +321,22 @@ export function createDurableOperation( routine, ): (resolve: Resolve>) => void { const ctx = routine.scope.expect(DurableCtx); - const replay = checkReplay(desc, resolve, routine, ctx); + const replay = checkReplay(desc, resolve, routine, ctx, options.validate); if (replay.path === "replayed") { return replay.teardown; } // ── LIVE PATH ── + try { + options.validate?.(); + } catch (error) { + resolve({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + return (exit) => exit(VOID_OK); + } + // Run the entire execute → capture → persist → resolve sequence // as a structured operation in the routine's scope. routine.scope.run(function* () { diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index dbfa5990..c179c1fb 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1270,10 +1270,13 @@ projected caller content. | journal serialization and replay restore | yes | no | The two namespaces may not overlap. `service=` validates the binding -name and checks both environments before acquiring a process. `ephemeral eval` -validates all exports against durable and existing live names before committing -any of them. A failed block publishes nothing. Values from the live overlay are -never substituted into a durable effect's source and never serialized. +name and checks both environments before acquiring a process. Ordinary durable +eval validates its declared exports against live names before execution or +replay restoration and before appending an eval event. `ephemeral eval` +validates its exports against durable names before execution; it may atomically +replace an existing live binding. A failed block publishes none of its exports. +Values from the live overlay are never substituted into a durable effect's +source and never serialized. **Each evaluation runs against a snapshot, and commits its exports.** A block receives a plain object holding the bindings as they stood when it started. Its @@ -3833,14 +3836,22 @@ stdout: XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":43210} ``` -The host installs its byte-level stdout observer before spawn. It suppresses -the matching record, forwards every other stdout byte unchanged, and accepts -readiness only when the JSON object has exactly those fields and matches the -expected version, token, host and port. Malformed, forged, duplicate or late -records fail the service without exposing the token or raw protocol line. +The host installs its byte-level stdout observer before spawn. At each line +start it retains only bytes that can still match `XMD_SERVICE_READY:`; on the +first mismatch it forwards those bytes and all subsequent ordinary bytes +immediately, without waiting for a newline. Only an actual protocol candidate +is buffered, under a finite bound. The observer suppresses valid and invalid +protocol records and accepts readiness only when the JSON object has exactly +those fields and matches the expected version, token, host and port. Malformed, +forged, duplicate or late records fail the service without exposing the token +or raw protocol line. Startup races readiness against process exit, protocol failure and the contextual startup timeout. After readiness the host continues supervising -process exit and duplicate records until acquisition ends. +process exit and duplicate records until acquisition ends. An observable host +process teardown failure becomes `ServiceTeardownError`; when execution is +already failing, the invocation teardown aggregate preserves that execution +failure first and keeps the service failure reachable through its teardown +member. The service binding is live, so only the `ephemeral eval` block can read it. The block installs middleware in the invocation scope through `persist`; plain @@ -6630,9 +6641,13 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble | | R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string | | R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry | -| R4 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds | -| R5 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves | -| R6 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit | +| R4 | Service publication collision | A durable or live binding with the requested name refuses acquisition before spawn | +| R5 | Durable export collides with a service | Live execution and partial replay both reject before execution or restoration and append no eval event | +| R6 | Ephemeral export collides with durable state | The block is rejected before execution and publishes no partial export | +| R7 | Ephemeral update of a live binding | A later ephemeral block may atomically replace an existing live name | +| R8 | `when` accessible in eval block | `yield* when(fn)` retries until fn succeeds | +| R9 | `when` retries on throw | Inner function throws twice, then succeeds → `when` resolves | +| R10 | `when` propagates timeout | Inner function never succeeds → `when` throws after limit | ### Tier S — Provider component pattern (integration) @@ -6641,17 +6656,21 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup | | S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval | | S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint | -| S4 | Service terminated after children expand | After `execute` completes, process is not running | -| S5 | Process exits before readiness | Startup fails with a dedicated exit-before-ready error | -| S6 | Provider crashes during children | Supervision failure propagates and teardown completes | -| S7 | Nested providers | Outer + inner provider → both start, inner tears down first | +| S4 | Cancellation after readiness | The child exits and its listener can be rebound after its owning task is halted | +| S5 | Startup failures | Exit, timeout and invalid readiness records produce dedicated errors without leaking protocol data | +| S6 | Provider exits during projected content | The projected request reaches the ready process, its unexpected exit fails the document, and it is not restarted | +| S7 | Nested real providers | Outer + inner processes both start and inner teardown finishes before outer teardown | | S8 | Nested providers, no model | Innermost provider handles sample call | | S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles | | S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth | | S11 | Unmatched model | Chain exhausted → descriptive error naming the model | | S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix | | S13 | Completed replay | Completed document returns without process spawn or token allocation | -| S14 | Multiple provider instances | Two provider siblings → two processes, distinct endpoints and tokens | +| S14 | Concurrent service acquisitions | Two owners acquire at the same time and receive distinct live endpoints | +| S15 | Incremental ordinary stdout | Unterminated and chunk-split ordinary bytes are forwarded before teardown, byte for byte | +| S16 | Incremental protocol records | Split readiness is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed | +| S17 | Service teardown failures | A lone observable process teardown failure becomes `ServiceTeardownError`; an active execution failure remains first in the invocation aggregate | +| S18 | Projected failure cleanup | A prompt projected-content failure tears down both retained real services and starts neither again | ### Tier EO — eval output() function From 010637c49d35b4ff4065e3f7518f8efd5d5db05c Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:24:00 -0400 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85=20Add=20cooperative=20ping-pong?= =?UTF-8?q?=20smoke=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/fixtures/cooperative-service.mjs | 45 ++++- packages/cli/tests/service-document.test.ts | 157 ++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/cooperative-service.mjs index aa4089c0..74d66cf6 100644 --- a/packages/cli/tests/fixtures/cooperative-service.mjs +++ b/packages/cli/tests/fixtures/cooperative-service.mjs @@ -1,4 +1,4 @@ -import { createServer } from "node:http"; +import { createServer, get } from "node:http"; const [mode = "normal", nonce = "none"] = process.argv.slice(2); const host = process.env.XMD_SERVICE_HOST; @@ -17,8 +17,48 @@ if (mode === "non-cooperative") { process.stdout.write("service stdout before readiness\n"); process.stderr.write("service stderr before readiness\n"); - const server = createServer((_request, response) => { + const server = createServer((request, response) => { process.stderr.write(`service request:${nonce}\n`); + + if (mode === "ping-pong") { + const requestUrl = new URL(request.url ?? "/", `http://${host}`); + const peerHostname = requestUrl.searchParams.get("peerHostname"); + const peerPort = Number(requestUrl.searchParams.get("peerPort")); + const origin = requestUrl.searchParams.get("origin"); + + if (peerHostname && Number.isInteger(peerPort) && peerPort > 0 && origin) { + const peerRequest = get( + { + hostname: peerHostname, + port: peerPort, + path: `/?origin=${encodeURIComponent(origin)}`, + }, + (peerResponse) => { + let peerBody = ""; + peerResponse.setEncoding("utf8"); + peerResponse.on("data", (chunk) => { + peerBody += chunk; + }); + peerResponse.on("end", () => response.end(`${nonce}→${peerBody}`)); + }, + ); + peerRequest.on("error", (error) => { + response.statusCode = 502; + response.end(`peer request failed: ${error.message}`); + }); + return; + } + + if (origin) { + response.end(`${nonce}→${origin}`); + return; + } + + response.statusCode = 400; + response.end("missing ping-pong peer"); + return; + } + response.end(`service:${nonce}`); if (mode === "exit-on-request") { setTimeout(() => process.exit(19), 10); @@ -42,6 +82,7 @@ if (mode === "non-cooperative") { hostname: host, port: address.port, }; + process.stderr.write(`service endpoint:${nonce}:${host}:${address.port}\n`); if (mode === "malformed") { process.stdout.write(`XMD_SERVICE_READY:{not-json}\n`); return; diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts index fd75d112..a2a86322 100644 --- a/packages/cli/tests/service-document.test.ts +++ b/packages/cli/tests/service-document.test.ts @@ -102,6 +102,60 @@ function fixturePids(stderr: string[]): number[] { return [...stderr.join("").matchAll(/service pid:(\d+)/g)].map((match) => Number(match[1])); } +function fixtureEndpoints(stderr: string[]): Array<{ + nonce: string; + hostname: string; + port: number; +}> { + return [...stderr.join("").matchAll(/service endpoint:([^:\n]+):([^:\n]+):(\d+)/g)].map( + (match) => { + const [, nonce, hostname, port] = match; + if (nonce === undefined || hostname === undefined || port === undefined) { + throw new Error("malformed cooperative-service endpoint log"); + } + return { nonce, hostname, port: Number(port) }; + }, + ); +} + +function endpointAt( + endpoints: Array<{ nonce: string; hostname: string; port: number }>, + index: number, +): { nonce: string; hostname: string; port: number } { + const endpoint = endpoints[index]; + if (endpoint === undefined) { + throw new Error(`missing cooperative-service endpoint at index ${index}`); + } + return endpoint; +} + +function expectPingPongJournal( + stream: InMemoryStream, + tokens: string[], + endpoints: Array<{ nonce: string; hostname: string; port: number }>, +): void { + const journal = JSON.stringify(stream.snapshot()); + expect(journal).toContain("ping→pong→ping"); + for (const forbidden of [ + "XMD_SERVICE_READY", + "service pid:", + "service endpoint:", + "service request:", + "service stdout", + "service stderr", + "service stopping:", + ]) { + expect(journal).not.toContain(forbidden); + } + for (const token of tokens) { + expect(journal).not.toContain(token); + } + for (const endpoint of endpoints) { + expect(journal).not.toContain(String(endpoint.port)); + expect(journal).not.toContain(`${endpoint.hostname}:${endpoint.port}`); + } +} + function isAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -185,6 +239,109 @@ describe("cooperative service document integration", () => { }); }); + it("keeps a two-service ping-pong chain live across partial replay", function* () { + const full = new InMemoryStream(); + const stderr: string[] = []; + const tokens: string[] = []; + + yield* scoped(function* () { + yield* installHostService({ + token() { + const token = (tokens.length + 1).toString(16).padStart(64, "0"); + tokens.push(token); + return token; + }, + environment: () => inheritedEnvironment(process.env), + stdout() {}, + stderr(bytes) { + stderr.push(new TextDecoder().decode(bytes)); + }, + }); + yield* useStubFs({ + "doc.md": "\n", + "components/Provider.md": `--- +meta: + componentName: Provider +--- + +\`\`\`bash service=ping exec +${command("ping-pong", "ping")} +\`\`\` + +\`\`\`bash service=pong exec +${command("ping-pong", "pong")} +\`\`\` + +\`\`\`js persist ephemeral eval +const pingEndpoint = ping; +const pongEndpoint = pong; +if (pingEndpoint.port === pongEndpoint.port) { + throw new Error("ping and pong must have distinct endpoints"); +} +yield* Sample.around({ + *sample() { + const peerHostname = encodeURIComponent(pongEndpoint.hostname); + const peerPort = encodeURIComponent(String(pongEndpoint.port)); + const response = yield* fetch( + \`http://\${pingEndpoint.hostname}:\${pingEndpoint.port}/?peerHostname=\${peerHostname}&peerPort=\${peerPort}&origin=ping\`, + ).expect(); + return yield* response.text(); + }, +}); +\`\`\` + + +`, + "components/Sample.md": SAMPLE, + }); + + const first = yield* runDocument(full); + expect(first).toContain("ping→pong→ping"); + expect(tokens).toHaveLength(2); + const firstEndpoints = fixtureEndpoints(stderr); + expect(firstEndpoints).toHaveLength(2); + expect(firstEndpoints.map(({ nonce }) => nonce)).toEqual(["ping", "pong"]); + const firstPing = endpointAt(firstEndpoints, 0); + const firstPong = endpointAt(firstEndpoints, 1); + expect(firstPing.port).not.toBe(firstPong.port); + expect(stderr.join("")).toContain("service request:ping"); + expect(stderr.join("")).toContain("service request:pong"); + yield* expectGone(fixturePids(stderr)); + + expectPingPongJournal(full, tokens, firstEndpoints); + + yield* occupy(firstPing.port); + yield* occupy(firstPong.port); + const events = full.snapshot(); + const firstYield = events.findIndex((event) => event.type === "yield"); + const partial = new InMemoryStream(events.slice(0, firstYield + 1)); + const resumed = yield* runDocument(partial); + + expect(resumed).toBe(first); + expect(tokens).toHaveLength(4); + const allEndpoints = fixtureEndpoints(stderr); + expect(allEndpoints).toHaveLength(4); + const resumedEndpoints = allEndpoints.slice(2); + expect(resumedEndpoints.map(({ nonce }) => nonce)).toEqual(["ping", "pong"]); + const resumedPing = endpointAt(resumedEndpoints, 0); + const resumedPong = endpointAt(resumedEndpoints, 1); + expect(resumedPing.port).not.toBe(resumedPong.port); + expect(resumedEndpoints.map(({ port }) => port)).not.toContain(firstPing.port); + expect(resumedEndpoints.map(({ port }) => port)).not.toContain(firstPong.port); + expect(stderr.join("").match(/service request:ping/g)).toHaveLength(2); + expect(stderr.join("").match(/service request:pong/g)).toHaveLength(2); + yield* expectGone(fixturePids(stderr)); + + expectPingPongJournal(partial, tokens, allEndpoints); + + const completed = yield* runDocument(full); + expect(completed).toBe(first); + expect(tokens).toHaveLength(4); + expect(fixtureEndpoints(stderr)).toHaveLength(4); + expect(fixturePids(stderr)).toHaveLength(4); + }); + }); + it("supervises a ready service that exits during projected content without restarting it", function* () { const stderr: string[] = []; let tokenCalls = 0; From e8114ae36b1f140ae52f19063db89398f2c3993b Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:52:14 -0400 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20Add=20attached-service=20compil?= =?UTF-8?q?ed=20smoke=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 7 ++ README.md | 8 +- architecture.md | 18 ++-- packages/cli/src/service-host.ts | 74 ++++++++------- ...ative-service.mjs => attached-service.mjs} | 10 +- packages/cli/tests/service-document.test.ts | 47 ++++++--- packages/cli/tests/service-host.test.ts | 30 +++--- packages/core/src/execute.ts | 2 +- packages/core/src/modifiers/service.ts | 16 ++-- packages/core/tests/ephemeral-service.test.ts | 18 ++-- packages/runtime/apis.ts | 4 +- packages/runtime/mod.ts | 4 +- packages/runtime/service.ts | 35 +++---- packages/runtime/test/README.md | 6 +- packages/runtime/test/mod.ts | 2 +- packages/runtime/test/stubs.ts | 2 +- packages/testing/tests/smoke.test.ts | 2 +- packages/workflow/src/service-denial.ts | 4 +- site/routes/docs/exec-eval.tsx | 9 +- smoke-test/AttachedPingPongProvider.md | 35 +++++++ smoke-test/AttachedServiceProvider.md | 19 ++++ smoke-test/CooperativeProvider.md | 19 ---- smoke-test/Guide/Daemons.md | 16 ++-- smoke-test/Guide/Summary.md | 4 +- smoke-test/attached-service-ping-pong.test.md | 10 ++ specs/executable-mdx-spec.md | 95 ++++++++++--------- 26 files changed, 291 insertions(+), 205 deletions(-) rename packages/cli/tests/fixtures/{cooperative-service.mjs => attached-service.mjs} (93%) create mode 100644 smoke-test/AttachedPingPongProvider.md create mode 100644 smoke-test/AttachedServiceProvider.md delete mode 100644 smoke-test/CooperativeProvider.md create mode 100644 smoke-test/attached-service-ping-pong.test.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 102ae2a5..3cdffcba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,13 @@ jobs: --component-dir packages/core/components \ --raw + - name: Smoke test attached-service ping-pong with the compiled binary + run: | + ./dist/xmd test smoke-test/attached-service-ping-pong.test.md \ + --component-dir smoke-test \ + --component-dir packages/core/components \ + --raw + # The script installs a second copy of core beside a repository component. # The declaration must cross into the bundled engine so the failure prints # and execution continues. diff --git a/README.md b/README.md index 008f43de..2ceeec84 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Built-in modifiers: - `persist` - keep resources created by an eval block alive for the component lifetime. - `timeout=30s` - cancel a long-running block. - `daemon` - start an arbitrary fixed-configuration subprocess tied to the component scope. -- `service=name` - start a cooperative service and publish its live loopback endpoint. +- `service=name` - start an attached service and publish its live loopback endpoint. - `ephemeral` - reconstruct live eval state without writing a journal event. LLM sampling is not a fence modifier — it happens through the `` component installed by provider middleware (see [Provider components](#provider-components)). @@ -180,7 +180,7 @@ Plain `eval` blocks run in a shared durable binding environment for the current ````md ```bash service=server exec -node cooperative-server.js +node handshake-compatible-server.js ``` ```ts persist ephemeral eval @@ -202,7 +202,7 @@ Highlights: - `output("...")` lets an eval block render text into the document. - `renderChildren()` and `render(markdown)` let eval blocks render nested content intentionally. - `ephemeral eval` reruns during live execution and partial replay, exports only invocation-local live bindings, and cannot render output. -- Live service endpoints are available only to `ephemeral eval`; they never enter interpolation, durable effect descriptions, or the journal. +- Attached-service endpoints are available only to `ephemeral eval`; they never enter interpolation, durable effect descriptions, or the journal. ## Provider components @@ -213,7 +213,7 @@ The repo includes reusable markdown components (in `packages/core/components/`) - `Sample.md` - `Instruction.md` -These components combine eval and `Sample` middleware so a document can talk to a cloud or already-running local model server without custom runtime wiring. A local process provider uses authenticated cooperative startup through `service=`. +These components combine eval and `Sample` middleware so a document can talk to a cloud or already-running local model server without custom runtime wiring. A local process provider attaches a handshake-compatible command through `service=` and authenticates it with the XMD service handshake protocol. [`packages/core/examples/hello-world.md`](packages/core/examples/hello-world.md) shows the pattern combining a cloud model (Claude) and a local model (Ollama). Provider docs currently need the built-in components on the search path: diff --git a/architecture.md b/architecture.md index 8c3002d8..3ca34a7e 100644 --- a/architecture.md +++ b/architecture.md @@ -44,7 +44,7 @@ Existing documents and code get aligned to this section retroactively. | Workspace | the provider-neutral, run-owned environment that supplies retained filesystem, repository, process and working-directory capabilities to a workflow | | ephemeral | a replay classification for an operation, context or attachment that runs again to reconstruct live execution; its result is not substituted from the journal and it owns no durable workflow state | | live binding | an execution-owned value reconstructed ephemerally for the current document execution; it is visible only to constructs that explicitly consume the live binding overlay and never enters interpolation or the journal | -| cooperative service | a scoped host process that publishes its authenticated loopback endpoint through the executable.md service-readiness protocol and remains supervised for the lifetime of its acquiring operation | +| attached service | a scoped host process that publishes its authenticated loopback endpoint through the XMD service handshake protocol and remains supervised for the lifetime of its service attachment | | effect transaction | the single atomic SQLite transaction that publishes one Workspace-local mutation together with that effect's journal result | | external effect | an effect whose provider-owned outcome cannot participate in the Workspace SQLite transaction and therefore requires a stable identity and provider reconciliation | | checkpoint | a completed journal boundary associated with the logical Workspace root visible after that effect | @@ -587,11 +587,11 @@ execution. No middleware sees it; it is never the document's own outcome. ## Replay-safe live services -A cooperative service belongs to the document execution that acquires it. The -host owns port selection, process spawning, protocol authentication, startup -supervision and teardown. Shared runtime code reaches that behavior through the -provider-neutral `API.Service`; it never imports a host process or networking -API. +An attached service belongs to the document execution that attaches it. The +host owns port selection, process spawning, XMD service handshake +authentication, supervision and teardown. Shared runtime code reaches that +behavior through the provider-neutral `API.Service`; it never imports a host +process or networking API. The service publishes a frozen loopback endpoint as a live binding. Live bindings form an overlay on the component's durable eval bindings: `ephemeral @@ -600,7 +600,7 @@ code-block interpolation and journal serialization read durable bindings only. A live binding cannot shadow a durable binding, and a durable value cannot be replaced by a live one. -Service acquisition and `ephemeral eval` execute again during partial replay so +Service attachment and `ephemeral eval` execute again during partial replay so the current process and middleware chain are reconstructed. A completed document replay returns its recorded result without expanding the document and therefore starts no service. Workflow execution installs a non-delegating @@ -662,8 +662,8 @@ Status is measured against main. | `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, and retains its identity, state, document executions and filtered journal | built on main | | caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main | -| `API.Service` / `startService()` | acquires an authenticated, supervised loopback process as a scoped provider-neutral resource | built on main | -| `service=` | publishes the acquired endpoint into the live binding overlay for its invocation | built on main | +| `API.Service` / `startService()` | creates an authenticated, supervised loopback service attachment through a provider-neutral operation | built on main | +| `service=` | publishes the attachment's endpoint into the live binding overlay for its invocation | built on main | | `ephemeral eval` | reconstructs live middleware and bindings without a journal entry | built on main | | workflow service denial | prevents workflow documents from inheriting an ordinary host service adapter | built on main | | `xmd workflow start` / `xmd workflow resume` | starts or resumes a workflow run from the CLI | defined in `specs/workflow-workspace-spec.md`, unbuilt; the lookup it resumes through is built | diff --git a/packages/cli/src/service-host.ts b/packages/cli/src/service-host.ts index 1174461f..eda90222 100644 --- a/packages/cli/src/service-host.ts +++ b/packages/cli/src/service-host.ts @@ -1,4 +1,4 @@ -/** Shared mechanics for the runtime-named cooperative service adapters. */ +/** Shared mechanics for the runtime-named attached-service adapters. */ import { ensure, race, resource, scoped, withResolvers, type Operation } from "effection"; import { daemon, Stdio, type Daemon } from "@effectionx/process"; @@ -16,7 +16,11 @@ import { parseServiceReadyRecord, timeout, } from "@executablemd/runtime"; -import type { ServiceEndpoint, ServiceResource, ServiceStartOptions } from "@executablemd/runtime"; +import type { + ServiceAttachment, + ServiceEndpoint, + ServiceStartOptions, +} from "@executablemd/runtime"; interface HostServiceAdapter { token(): string; @@ -25,34 +29,34 @@ interface HostServiceAdapter { stderr(bytes: Uint8Array): void; } -export interface ProtocolObserver { +export interface HandshakeObserver { stdout(bytes: Uint8Array): Operation; flush(): Operation; } const encoder = new TextEncoder(); const prefixBytes = encoder.encode(SERVICE_READY_PREFIX); -const MAX_PROTOCOL_RECORD_BYTES = 1_024; +const MAX_HANDSHAKE_RECORD_BYTES = 1_024; export function createProtocolObserver(options: { token: string; ready(endpoint: ServiceEndpoint): void; fail(error: Error): void; forward(bytes: Uint8Array): void; -}): ProtocolObserver { - let state: "prefix" | "ordinary" | "protocol" | "suppressed" = "prefix"; +}): HandshakeObserver { + let state: "prefix" | "ordinary" | "handshake" | "suppressed" = "prefix"; let possiblePrefix: number[] = []; - let protocolRecord: number[] = []; - let readinessSeen = false; + let handshakeRecord: number[] = []; + let handshakeSeen = false; function beginLine(): void { state = "prefix"; possiblePrefix = []; - protocolRecord = []; + handshakeRecord = []; } - function consumeProtocolRecord(): void { - if (readinessSeen) { + function consumeHandshakeRecord(): void { + if (handshakeSeen) { options.fail(new ServiceProtocolDuplicateError()); return; } @@ -60,7 +64,7 @@ export function createProtocolObserver(options: { let payload: string; try { payload = new TextDecoder("utf-8", { fatal: true }).decode( - Uint8Array.from(protocolRecord.slice(prefixBytes.byteLength)), + Uint8Array.from(handshakeRecord.slice(prefixBytes.byteLength)), ); } catch { options.fail(new ServiceProtocolMalformedError()); @@ -69,22 +73,22 @@ export function createProtocolObserver(options: { try { const endpoint = parseServiceReadyRecord(payload, options.token); - readinessSeen = true; + handshakeSeen = true; options.ready(endpoint); } catch (error) { options.fail(error instanceof Error ? error : new ServiceProtocolMalformedError()); } } - function appendProtocol(bytes: Uint8Array, start: number, end: number): boolean { - if (protocolRecord.length + end - start > MAX_PROTOCOL_RECORD_BYTES) { - protocolRecord = []; + function appendHandshake(bytes: Uint8Array, start: number, end: number): boolean { + if (handshakeRecord.length + end - start > MAX_HANDSHAKE_RECORD_BYTES) { + handshakeRecord = []; state = "suppressed"; options.fail(new ServiceProtocolMalformedError()); return false; } for (let index = start; index < end; index += 1) { - protocolRecord.push(bytes[index]!); + handshakeRecord.push(bytes[index]!); } return true; } @@ -99,9 +103,9 @@ export function createProtocolObserver(options: { possiblePrefix.push(byte); index += 1; if (possiblePrefix.length === prefixBytes.byteLength) { - protocolRecord = [...possiblePrefix]; + handshakeRecord = [...possiblePrefix]; possiblePrefix = []; - state = "protocol"; + state = "handshake"; } continue; } @@ -127,17 +131,17 @@ export function createProtocolObserver(options: { continue; } - if (state === "protocol") { + if (state === "handshake") { const newline = bytes.indexOf(10, index); const end = newline === -1 ? bytes.byteLength : newline; - if (!appendProtocol(bytes, index, end)) { + if (!appendHandshake(bytes, index, end)) { index = end; continue; } if (newline === -1) { index = bytes.byteLength; } else { - consumeProtocolRecord(); + consumeHandshakeRecord(); index = newline + 1; beginLine(); } @@ -156,7 +160,7 @@ export function createProtocolObserver(options: { *flush(): Operation { if (state === "prefix" && possiblePrefix.length > 0) { options.forward(Uint8Array.from(possiblePrefix)); - } else if (state === "protocol") { + } else if (state === "handshake") { beginLine(); throw new ServiceProtocolMalformedError(); } @@ -167,7 +171,7 @@ export function createProtocolObserver(options: { function validTimeout(value: number): number { if (!Number.isFinite(value) || value <= 0) { - throw new Error("service startup timeout must be a positive finite number"); + throw new Error("attached service startup timeout must be a positive finite number"); } return value; } @@ -182,11 +186,11 @@ function exitFacts(status: { code?: number; signal?: string }): { }; } -function* waitForStartup(options: { +function* waitForHandshake(options: { ready: Operation; - protocolFailure: Operation; + handshakeFailure: Operation; process: { join(): Operation<{ code?: number; signal?: string }> }; - observer: ProtocolObserver; + observer: HandshakeObserver; startupTimeout: number; }): Operation { const result = yield* timebox(options.startupTimeout, () => @@ -198,7 +202,7 @@ function* waitForStartup(options: { throw new ServiceProcessExitBeforeReadyError(exitFacts(status)); })(), (function* (): Operation { - return yield* options.protocolFailure; + return yield* options.handshakeFailure; })(), ]), ); @@ -237,19 +241,19 @@ function serviceProcess(options: { function startHostService( options: ServiceStartOptions, adapter: HostServiceAdapter, -): Operation { +): Operation { return resource(function* (provide) { const token = adapter.token(); if (!/^[0-9a-f]{64}$/.test(token)) { - throw new Error("host service adapter returned an invalid authentication token"); + throw new Error("attached service host returned an invalid handshake token"); } const startupTimeout = validTimeout(options.startupTimeout ?? (yield* timeout)); const ready = withResolvers(); - const protocolFailure = withResolvers(); + const handshakeFailure = withResolvers(); const observer = createProtocolObserver({ token, ready: ready.resolve, - fail: protocolFailure.reject, + fail: handshakeFailure.reject, forward: adapter.stdout, }); @@ -278,9 +282,9 @@ function startHostService( cwd: options.cwd, environment, }); - const endpoint = yield* waitForStartup({ + const endpoint = yield* waitForHandshake({ ready: ready.operation, - protocolFailure: protocolFailure.operation, + handshakeFailure: handshakeFailure.operation, process, observer, startupTimeout, @@ -293,7 +297,7 @@ function startHostService( yield* observer.flush(); throw new ServiceUnexpectedExitError(exitFacts(status)); })(), - protocolFailure.operation, + handshakeFailure.operation, ]); }); } diff --git a/packages/cli/tests/fixtures/cooperative-service.mjs b/packages/cli/tests/fixtures/attached-service.mjs similarity index 93% rename from packages/cli/tests/fixtures/cooperative-service.mjs rename to packages/cli/tests/fixtures/attached-service.mjs index 74d66cf6..1a28f91a 100644 --- a/packages/cli/tests/fixtures/cooperative-service.mjs +++ b/packages/cli/tests/fixtures/attached-service.mjs @@ -11,11 +11,11 @@ if (mode === "exit-before") { process.exit(17); } -if (mode === "non-cooperative") { +if (mode === "not-handshake-compatible") { setInterval(() => {}, 1_000); } else { - process.stdout.write("service stdout before readiness\n"); - process.stderr.write("service stderr before readiness\n"); + process.stdout.write("service stdout before handshake\n"); + process.stderr.write("service stderr before handshake\n"); const server = createServer((request, response) => { process.stderr.write(`service request:${nonce}\n`); @@ -121,8 +121,8 @@ if (mode === "non-cooperative") { const line = `XMD_SERVICE_READY:${JSON.stringify(ready)}\n`; process.stdout.write(line); - process.stdout.write("service stdout after readiness\n"); - process.stderr.write("service stderr after readiness\n"); + process.stdout.write("service stdout after handshake\n"); + process.stderr.write("service stderr after handshake\n"); if (mode === "unterminated-live-output") { process.stdout.write("unterminated-live-output"); diff --git a/packages/cli/tests/service-document.test.ts b/packages/cli/tests/service-document.test.ts index a2a86322..2c6c6e4d 100644 --- a/packages/cli/tests/service-document.test.ts +++ b/packages/cli/tests/service-document.test.ts @@ -12,7 +12,7 @@ import { SERVICE_HOSTNAME, ServiceUnexpectedExitError } from "@executablemd/runt import { useStubFs } from "@executablemd/runtime/test"; import { inheritedEnvironment, installHostService } from "../src/service-host.ts"; -const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname; +const fixture = new URL("./fixtures/attached-service.mjs", import.meta.url).pathname; function command(mode: string, nonce: string): string { return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`; @@ -111,7 +111,7 @@ function fixtureEndpoints(stderr: string[]): Array<{ (match) => { const [, nonce, hostname, port] = match; if (nonce === undefined || hostname === undefined || port === undefined) { - throw new Error("malformed cooperative-service endpoint log"); + throw new Error("malformed attached-service endpoint log"); } return { nonce, hostname, port: Number(port) }; }, @@ -124,11 +124,32 @@ function endpointAt( ): { nonce: string; hostname: string; port: number } { const endpoint = endpoints[index]; if (endpoint === undefined) { - throw new Error(`missing cooperative-service endpoint at index ${index}`); + throw new Error(`missing attached-service endpoint at index ${index}`); } return endpoint; } +function containsEndpoint(value: unknown, endpoint: { hostname: string; port: number }): boolean { + if (typeof value === "string") { + return value.includes(`${endpoint.hostname}:${endpoint.port}`); + } + if (Array.isArray(value)) { + return value.some((member) => containsEndpoint(member, endpoint)); + } + if (typeof value !== "object" || value === null) { + return false; + } + if ( + "hostname" in value && + "port" in value && + value.hostname === endpoint.hostname && + value.port === endpoint.port + ) { + return true; + } + return Object.values(value).some((member) => containsEndpoint(member, endpoint)); +} + function expectPingPongJournal( stream: InMemoryStream, tokens: string[], @@ -151,8 +172,7 @@ function expectPingPongJournal( expect(journal).not.toContain(token); } for (const endpoint of endpoints) { - expect(journal).not.toContain(String(endpoint.port)); - expect(journal).not.toContain(`${endpoint.hostname}:${endpoint.port}`); + expect(containsEndpoint(stream.snapshot(), endpoint)).toBe(false); } } @@ -189,10 +209,10 @@ function hasUnexpectedExit(error: unknown): boolean { return error instanceof Error && error.cause !== undefined && hasUnexpectedExit(error.cause); } -describe("cooperative service document integration", () => { +describe("attached service document integration", () => { beforeAll(() => useTempFileCompiler()); - it("reconstructs a real service on partial replay and skips completed replay", function* () { + it("reconstructs a real attached service on partial replay and skips completed replay", function* () { const full = new InMemoryStream(); let tokenCalls = 0; @@ -239,7 +259,7 @@ describe("cooperative service document integration", () => { }); }); - it("keeps a two-service ping-pong chain live across partial replay", function* () { + it("keeps a two-attachment ping-pong chain live across partial replay", function* () { const full = new InMemoryStream(); const stderr: string[] = []; const tokens: string[] = []; @@ -275,7 +295,10 @@ ${command("ping-pong", "pong")} \`\`\`js persist ephemeral eval const pingEndpoint = ping; const pongEndpoint = pong; -if (pingEndpoint.port === pongEndpoint.port) { +if ( + pingEndpoint.hostname === pongEndpoint.hostname && + pingEndpoint.port === pongEndpoint.port +) { throw new Error("ping and pong must have distinct endpoints"); } yield* Sample.around({ @@ -342,7 +365,7 @@ yield* Sample.around({ }); }); - it("supervises a ready service that exits during projected content without restarting it", function* () { + it("supervises an attached service that exits during projected content without restarting it", function* () { const stderr: string[] = []; let tokenCalls = 0; yield* scoped(function* () { @@ -399,7 +422,7 @@ yield* Sample.around({ yield* expectGone(fixturePids(stderr)); }); - it("fails projected content promptly, tears down retained services, and does not restart", function* () { + it("fails projected content promptly, tears down retained service attachments, and does not restart", function* () { const stderr: string[] = []; let tokenCalls = 0; yield* scoped(function* () { @@ -467,7 +490,7 @@ throw new Error("projected content failure"); yield* expectGone(pids); }); - it("tears down nested provider services from inner lifetime to outer lifetime", function* () { + it("tears down nested service attachments from inner lifetime to outer lifetime", function* () { const stderr: string[] = []; let tokenCalls = 0; yield* scoped(function* () { diff --git a/packages/cli/tests/service-host.test.ts b/packages/cli/tests/service-host.test.ts index 921d6954..612380bb 100644 --- a/packages/cli/tests/service-host.test.ts +++ b/packages/cli/tests/service-host.test.ts @@ -37,7 +37,7 @@ import { createServer } from "node:http"; import process from "node:process"; const TOKEN = "12".repeat(32); -const fixture = new URL("./fixtures/cooperative-service.mjs", import.meta.url).pathname; +const fixture = new URL("./fixtures/attached-service.mjs", import.meta.url).pathname; function command(mode: string, nonce = "nonce"): string { return `node ${JSON.stringify(fixture)} ${mode} ${nonce}`; @@ -146,8 +146,8 @@ function* useTeardownFailure(failure: Error): Operation { }); } -describe("cooperative host service adapter", () => { - it("forwards split ordinary bytes immediately and suppresses split protocol records", function* () { +describe("attached service host adapter", () => { + it("forwards split ordinary bytes immediately and suppresses split handshake records", function* () { const forwarded: number[] = []; const endpoints: Array<{ hostname: string; port: number }> = []; const failures: Error[] = []; @@ -187,7 +187,7 @@ describe("cooperative host service adapter", () => { expect(forwarded).toEqual([10]); }); - it("bounds and suppresses an invalid protocol candidate", function* () { + it("bounds and suppresses an invalid handshake candidate", function* () { const forwarded: number[] = []; const failures: Error[] = []; const observer = createProtocolObserver({ @@ -207,7 +207,7 @@ describe("cooperative host service adapter", () => { expect(String(failures[0])).not.toContain(TOKEN); }); - it("starts genuinely concurrent isolated services and suppresses readiness", function* () { + it("starts genuinely concurrent service attachments and suppresses handshake records", function* () { const stdout: string[] = []; const stderr: string[] = []; @@ -249,17 +249,17 @@ describe("cooperative host service adapter", () => { yield* secondOwner; }); - expect(stdout.join("")).toContain("service stdout before readiness"); - expect(stdout.join("")).toContain("service stdout after readiness"); - expect(stderr.join("")).toContain("service stderr before readiness"); - expect(stderr.join("")).toContain("service stderr after readiness"); + expect(stdout.join("")).toContain("service stdout before handshake"); + expect(stdout.join("")).toContain("service stdout after handshake"); + expect(stderr.join("")).toContain("service stderr before handshake"); + expect(stderr.join("")).toContain("service stderr after handshake"); expect(stdout.join("")).not.toContain("XMD_SERVICE_READY"); expect(stdout.join("")).not.toContain(TOKEN); expect(fixturePids(stderr)).toHaveLength(2); yield* expectGone(fixturePids(stderr)); }); - it("categorizes startup failures without exposing protocol records", function* () { + it("categorizes startup failures without exposing handshake records", function* () { const cases: Array<[string, { prototype: Error }, number]> = [ ["exit-before", ServiceProcessExitBeforeReadyError, 2_000], ["malformed", ServiceProtocolMalformedError, 2_000], @@ -269,7 +269,7 @@ describe("cooperative host service adapter", () => { ["wrong-host", ServiceProtocolHostnameMismatchError, 2_000], ["extra-member", ServiceProtocolMalformedError, 2_000], ["partial-record", ServiceProtocolMalformedError, 2_000], - ["non-cooperative", ServiceStartupTimeoutError, 75], + ["not-handshake-compatible", ServiceStartupTimeoutError, 75], ]; for (const [mode, ErrorType, startupTimeout] of cases) { @@ -293,7 +293,7 @@ describe("cooperative host service adapter", () => { } }); - it("cancels startup and releases the child before readiness", function* () { + it("cancels startup and releases the child before the handshake", function* () { const pidPublished = withResolvers(); const decoder = new TextDecoder(); let pid = 0; @@ -318,7 +318,7 @@ describe("cooperative host service adapter", () => { yield* expectGone([pid]); }); - it("cancels after readiness and releases the child listener", function* () { + it("cancels after the handshake and releases the child listener", function* () { const stderr: string[] = []; const ready = withResolvers<{ hostname: string; port: number }>(); let endpoint = { hostname: "127.0.0.1", port: 0 }; @@ -343,7 +343,7 @@ describe("cooperative host service adapter", () => { }); }); - it("forwards unterminated ordinary stdout while the service is still active", function* () { + it("forwards unterminated ordinary stdout while the attached service is active", function* () { const stdout: string[] = []; const stderr: string[] = []; @@ -418,7 +418,7 @@ describe("cooperative host service adapter", () => { expect(serviceTeardown.cause).toBe(teardown); }); - it("fails the owning scope when a ready process exits or repeats readiness", function* () { + it("fails the owning scope when an attached process exits or repeats the handshake", function* () { const cases: Array<[string, { prototype: Error }]> = [ ["exit-after", ServiceUnexpectedExitError], ["duplicate", ServiceProtocolDuplicateError], diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 81936880..96037b22 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -811,7 +811,7 @@ function* executeDocument(options: ExecuteOptions): Operation }, }); - // The discard provider is the base so a cooperative-service observer can + // The discard provider is the base so an attached-service observer can // authenticate and forward its own process output without unsilencing // unrelated document subprocesses. yield* Stdio.around( diff --git a/packages/core/src/modifiers/service.ts b/packages/core/src/modifiers/service.ts index 142c3f8b..5bb8196f 100644 --- a/packages/core/src/modifiers/service.ts +++ b/packages/core/src/modifiers/service.ts @@ -1,4 +1,4 @@ -/** Cooperative service terminal modifier. */ +/** Attached service terminal modifier. */ import { ephemeral } from "@executablemd/durable-streams"; import { unbox } from "@effectionx/scope-eval"; @@ -18,27 +18,29 @@ export const serviceFactory: ModifierFactory = (params) => (_args, _next) => *[Symbol.iterator]() { const durable = yield* env; if (!durable) { - throw new Error("service requires a component binding environment; none is in scope."); + throw new Error( + "attached service requires a component binding environment; none is in scope.", + ); } const live = liveEnvironment(durable); const binding = validateServiceBinding(params, durable, live); const scope = yield* evalScope; if (!scope) { - throw new Error("service requires a component eval scope; none is in scope."); + throw new Error("attached service requires a component eval scope; none is in scope."); } const directory = yield* cwd(); const startupTimeout = yield* timeout; - const acquired = yield* scope.eval(function* () { - const service = yield* startService({ + const attachment = yield* scope.eval(function* () { + const serviceAttachment = yield* startService({ command: ctx.content, cwd: directory, startupTimeout, }); - return service.endpoint; + return serviceAttachment.endpoint; }); - live.values[binding] = unbox(acquired); + live.values[binding] = unbox(attachment); return { output: "", exitCode: 0, stderr: "" }; }, }; diff --git a/packages/core/tests/ephemeral-service.test.ts b/packages/core/tests/ephemeral-service.test.ts index d6859b29..ba40e846 100644 --- a/packages/core/tests/ephemeral-service.test.ts +++ b/packages/core/tests/ephemeral-service.test.ts @@ -49,7 +49,7 @@ function* useServiceStub( ); } -describe("ephemeral eval and service bindings", () => { +describe("ephemeral eval and attached-service bindings", () => { beforeAll(() => useTempFileCompiler()); it("reconstructs live bindings without exposing them to durable consumers", function* () { @@ -102,7 +102,7 @@ yield* Sample.around({ expect(evalEvents).toHaveLength(2); }); - it("publishes the exact frozen service endpoint only to ephemeral eval", function* () { + it("publishes the exact frozen attached-service endpoint only to ephemeral eval", function* () { const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 43_210 }); const lifecycle = { starts: 0, stops: 0 }; const stream = new InMemoryStream(); @@ -124,7 +124,7 @@ meta: --- \`\`\`bash service=server exec -cooperative-server +handshake-compatible-server \`\`\` endpoint:{server.port} @@ -164,13 +164,13 @@ yield* Sample.around({ ).toBe(false); }); - it("completed replay starts neither services nor ephemeral eval", function* () { + it("completed replay starts neither attached services nor ephemeral eval", function* () { const endpoint = Object.freeze({ hostname: SERVICE_HOSTNAME, port: 41_001 }); const lifecycle = { starts: 0, stops: 0 }; const stream = new InMemoryStream(); const files = { "doc.md": `\`\`\`bash service=server exec -cooperative-server +handshake-compatible-server \`\`\` \`\`\`js ephemeral eval @@ -229,7 +229,7 @@ done } }); - it("validates service binding collisions before spawning", function* () { + it("validates attached-service binding collisions before spawning", function* () { const cases: Array<[string, string, number]> = [ ["service", "requires a binding name", 0], ["service=bad-name", "must be a valid JavaScript identifier", 0], @@ -275,7 +275,7 @@ done "doc.md": ` \`\`\`bash service=server exec -cooperative-server +handshake-compatible-server \`\`\` \`\`\`js eval @@ -314,7 +314,7 @@ const server = "must-not-execute"; "doc.md": ` \`\`\`bash service=other exec -cooperative-server +handshake-compatible-server \`\`\` \`\`\`js eval @@ -351,7 +351,7 @@ tail "doc.md": ` \`\`\`bash service=server exec -cooperative-server +handshake-compatible-server \`\`\` \`\`\`js eval diff --git a/packages/runtime/apis.ts b/packages/runtime/apis.ts index 1dfd263a..2e02f311 100644 --- a/packages/runtime/apis.ts +++ b/packages/runtime/apis.ts @@ -42,8 +42,8 @@ * use `.around()` to mock platform/env for deterministic replay; an * entrypoint installs its `command` and `compile` with `{ at: "min" }` so * ordinary middleware can wrap them. - * - **Service** — scoped cooperative-service acquisition. Its terminal handler - * requires an explicit host provider and never detects or imports a runtime. + * - **Service** — scoped service attachment. Its terminal handler requires an + * explicit host provider and never detects or imports a runtime. * * ## Middleware * diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index ebe07fd4..8e468bee 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -13,7 +13,7 @@ * - `API.Env` — the host: variables, platform info, the command that invokes * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) - * - `API.Service` — scoped cooperative service startup (`startService`) + * - `API.Service` — scoped attached service startup (`startService`) * - `Config` — shared execution config (`timeout`) * * See `apis.ts` for architecture rationale. @@ -59,7 +59,7 @@ export { export type { ServiceEndpoint, ServiceHandler, - ServiceResource, + ServiceAttachment, ServiceStartOptions, } from "./service.ts"; export { Config, timeout } from "./config.ts"; diff --git a/packages/runtime/service.ts b/packages/runtime/service.ts index 2a6891fe..8011166b 100644 --- a/packages/runtime/service.ts +++ b/packages/runtime/service.ts @@ -1,8 +1,9 @@ /** - * Provider-neutral cooperative service lifecycle. + * Provider-neutral attached-service lifecycle. * - * The shared runtime owns the protocol shape and validation. A runtime-named - * host adapter supplies process startup through `API.Service` middleware. + * The shared runtime owns the XMD service handshake shape and validation. A + * runtime-named host adapter supplies process startup through `API.Service` + * middleware. */ import { type Api, createApi, type Operations } from "@effectionx/context-api"; @@ -22,12 +23,12 @@ export interface ServiceStartOptions { readonly startupTimeout?: number; } -export interface ServiceResource { +export interface ServiceAttachment { readonly endpoint: Readonly; } export interface ServiceHandler { - start(options: ServiceStartOptions): Operation; + start(options: ServiceStartOptions): Operation; } export class ServiceProviderError extends Error { @@ -35,7 +36,7 @@ export class ServiceProviderError extends Error { constructor() { super( - "service startup requires a host provider; install runtime.service middleware before execution", + "attached service startup requires a host provider; install runtime.service middleware before execution", ); } } @@ -44,7 +45,7 @@ export class ServiceProtocolMalformedError extends Error { override name = "ServiceProtocolMalformedError"; constructor() { - super("service emitted a malformed cooperative readiness record"); + super("attached service emitted a malformed XMD service handshake record"); } } @@ -52,7 +53,7 @@ export class ServiceProtocolIncompatibleError extends Error { override name = "ServiceProtocolIncompatibleError"; constructor() { - super("service emitted an incompatible cooperative readiness record"); + super("attached service emitted an incompatible XMD service handshake record"); } } @@ -60,7 +61,7 @@ export class ServiceProtocolTokenMismatchError extends Error { override name = "ServiceProtocolTokenMismatchError"; constructor() { - super("service readiness authentication failed"); + super("XMD service handshake authentication failed"); } } @@ -68,7 +69,7 @@ export class ServiceProtocolHostnameMismatchError extends Error { override name = "ServiceProtocolHostnameMismatchError"; constructor() { - super("service readiness hostname is not authorized"); + super("XMD service handshake hostname is not authorized"); } } @@ -76,7 +77,7 @@ export class ServiceProtocolDuplicateError extends Error { override name = "ServiceProtocolDuplicateError"; constructor() { - super("service emitted more than one cooperative readiness record"); + super("attached service emitted more than one XMD service handshake record"); } } @@ -84,7 +85,7 @@ export class ServiceStartupTimeoutError extends Error { override name = "ServiceStartupTimeoutError"; constructor(timeout: number) { - super(`service did not become ready within ${timeout}ms`); + super(`attached service handshake did not complete within ${timeout}ms`); } } @@ -107,7 +108,7 @@ export class ServiceProcessExitBeforeReadyError extends Error { override name = "ServiceProcessExitBeforeReadyError"; constructor(status: ServiceExitStatus) { - super(`service process exited before readiness with ${exitDescription(status)}`); + super(`attached service process exited before handshake with ${exitDescription(status)}`); } } @@ -115,7 +116,7 @@ export class ServiceUnexpectedExitError extends Error { override name = "ServiceUnexpectedExitError"; constructor(status: ServiceExitStatus) { - super(`service process exited after readiness with ${exitDescription(status)}`); + super(`attached service process exited after handshake with ${exitDescription(status)}`); } } @@ -123,7 +124,7 @@ export class ServiceTeardownError extends Error { override name = "ServiceTeardownError"; constructor(options?: { cause?: unknown }) { - super("service process failed to terminate cleanly", options); + super("attached service process failed to terminate cleanly", options); } } @@ -142,7 +143,7 @@ function hasExactMembers(record: Record): boolean { ); } -/** Parse and authenticate one prefix-stripped v1 readiness payload. */ +/** Parse and authenticate one prefix-stripped v1 handshake payload. */ export function parseServiceReadyRecord(payload: string, expectedToken: string): ServiceEndpoint { let parsed: unknown; try { @@ -177,7 +178,7 @@ export function parseServiceReadyRecord(payload: string, expectedToken: string): export const Service: Api = createApi("runtime.service", { // deno-lint-ignore require-yield - *start(_options: ServiceStartOptions): Operation { + *start(_options: ServiceStartOptions): Operation { throw new ServiceProviderError(); }, }); diff --git a/packages/runtime/test/README.md b/packages/runtime/test/README.md index 41fcef0b..484ddbd3 100644 --- a/packages/runtime/test/README.md +++ b/packages/runtime/test/README.md @@ -12,7 +12,7 @@ Use these helpers when a test needs: - an in-memory filesystem instead of real files - a simple `exec` stub for `echo`-style command output - a predictable failing `exec` for error-path assertions -- a scoped cooperative-service endpoint without a real host process +- a scoped service attachment without a real host process Use raw `API.*.around()` directly when a test needs custom behavior that the shared helpers do not provide. @@ -81,8 +81,8 @@ yield * useFailingExec(127, "command not found"); Installs a provider-neutral scoped `API.Service` stub. It reconstructs an exact frozen loopback endpoint and rejects any non-loopback hostname or invalid port. -Use the production host adapters when testing protocol, process, output or -teardown behavior. +Use the production host adapters when testing the XMD service handshake, +process, output or teardown behavior. ## Composition diff --git a/packages/runtime/test/mod.ts b/packages/runtime/test/mod.ts index 98a3361b..a6610dc5 100644 --- a/packages/runtime/test/mod.ts +++ b/packages/runtime/test/mod.ts @@ -6,7 +6,7 @@ * - `useStubFs(files)` — in-memory filesystem * - `useEchoExec()` — simple echo-based exec * - `useFailingExec(exitCode, stderr)` — always-failing exec - * - `useStubService(endpoint)` — scoped provider-neutral service endpoint + * - `useStubService(endpoint)` — scoped provider-neutral service attachment */ export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.ts"; diff --git a/packages/runtime/test/stubs.ts b/packages/runtime/test/stubs.ts index 807880db..2048d1d2 100644 --- a/packages/runtime/test/stubs.ts +++ b/packages/runtime/test/stubs.ts @@ -26,7 +26,7 @@ import type { StatResult } from "../apis.ts"; import { SERVICE_HOSTNAME } from "../service.ts"; import type { ServiceEndpoint } from "../service.ts"; -/** Install a provider-neutral scoped service endpoint stub. */ +/** Install a provider-neutral scoped service attachment stub. */ export function* useStubService(endpoint: ServiceEndpoint): Operation { if (endpoint.hostname !== SERVICE_HOSTNAME) { throw new Error("stub service endpoint must use 127.0.0.1"); diff --git a/packages/testing/tests/smoke.test.ts b/packages/testing/tests/smoke.test.ts index 7b788d9a..fa73f971 100644 --- a/packages/testing/tests/smoke.test.ts +++ b/packages/testing/tests/smoke.test.ts @@ -53,7 +53,7 @@ const EMBEDDED_TESTS = [ "Eval bindings interpolate into exec blocks", "Ephemeral eval reconstructs live bindings without rendering", "A daemon stays alive until its scope closes", - "A cooperative service publishes a scoped live endpoint", + "An attached service publishes a scoped live endpoint", "A standalone Thing's resource outlives it", "An empty paired Thing renders nothing and keeps nothing", "A paired Thing's resource is live only while its content expands", diff --git a/packages/workflow/src/service-denial.ts b/packages/workflow/src/service-denial.ts index 81201ebb..335cbee0 100644 --- a/packages/workflow/src/service-denial.ts +++ b/packages/workflow/src/service-denial.ts @@ -1,4 +1,4 @@ -/** Workflow authority boundary for native service startup. */ +/** Workflow authority boundary for native service attachment. */ import type { Operation } from "effection"; import { API } from "@executablemd/runtime"; @@ -7,7 +7,7 @@ export class WorkflowServiceDeniedError extends Error { override name = "WorkflowServiceDeniedError"; constructor() { - super("workflow execution is not authorized to start a native service"); + super("workflow execution is not authorized to attach a native service"); } } diff --git a/site/routes/docs/exec-eval.tsx b/site/routes/docs/exec-eval.tsx index 5d99b639..1c8e5a83 100644 --- a/site/routes/docs/exec-eval.tsx +++ b/site/routes/docs/exec-eval.tsx @@ -5,7 +5,7 @@ import { NextCard } from "../../components/NextCard.tsx"; const CHAIN = "```bash silent timeout=30s exec\ngit diff --stat\n```"; const EVAL = `\`\`\`bash service=server exec -node cooperative-server.js +node handshake-compatible-server.js \`\`\` \`\`\`ts persist ephemeral eval @@ -57,8 +57,7 @@ export default define.page(function ExecEval() {
  • service=name{" "} - — start a cooperative service and publish its invocation-local - endpoint. + — start an attached service and publish its invocation-local endpoint.
  • ephemeral{" "} @@ -106,7 +105,9 @@ export default define.page(function ExecEval() { down by structured concurrency when the component invocation completes — no manual cleanup. It remains the primitive for processes whose fixed configuration the document or host manages explicitly. Dynamic service - endpoints use the authenticated service=name protocol. + endpoints use service=name{" "} + with a handshake-compatible command and the XMD service handshake + protocol.

    diff --git a/smoke-test/AttachedPingPongProvider.md b/smoke-test/AttachedPingPongProvider.md new file mode 100644 index 00000000..205519a8 --- /dev/null +++ b/smoke-test/AttachedPingPongProvider.md @@ -0,0 +1,35 @@ +--- +meta: + componentName: AttachedPingPongProvider +--- + +```bash service=ping exec +node packages/cli/tests/fixtures/attached-service.mjs ping-pong ping +``` + +```bash service=pong exec +node packages/cli/tests/fixtures/attached-service.mjs ping-pong pong +``` + +```js persist ephemeral eval +const pingEndpoint = ping; +const pongEndpoint = pong; +if ( + pingEndpoint.hostname === pongEndpoint.hostname && + pingEndpoint.port === pongEndpoint.port +) { + throw new Error("ping and pong attachments must have distinct endpoints"); +} +yield* Sample.around({ + *sample() { + const peerHostname = encodeURIComponent(pongEndpoint.hostname); + const peerPort = encodeURIComponent(String(pongEndpoint.port)); + const response = yield* fetch( + `http://${pingEndpoint.hostname}:${pingEndpoint.port}/?peerHostname=${peerHostname}&peerPort=${peerPort}&origin=ping`, + ).expect(); + return yield* response.text(); + }, +}); +``` + + diff --git a/smoke-test/AttachedServiceProvider.md b/smoke-test/AttachedServiceProvider.md new file mode 100644 index 00000000..7d3d36e9 --- /dev/null +++ b/smoke-test/AttachedServiceProvider.md @@ -0,0 +1,19 @@ +--- +meta: + componentName: AttachedServiceProvider +--- + +```bash service=server exec +node packages/cli/tests/fixtures/attached-service.mjs normal smoke +``` + +```js persist ephemeral eval +const endpoint = server; +yield* Sample.around({ + *sample() { + return `attached:${endpoint.hostname}:${Object.isFrozen(endpoint)}`; + }, +}); +``` + + diff --git a/smoke-test/CooperativeProvider.md b/smoke-test/CooperativeProvider.md deleted file mode 100644 index 83f7140c..00000000 --- a/smoke-test/CooperativeProvider.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -meta: - componentName: CooperativeProvider ---- - -```bash service=server exec -node packages/cli/tests/fixtures/cooperative-service.mjs normal smoke -``` - -```js persist ephemeral eval -const endpoint = server; -yield* Sample.around({ - *sample() { - return `cooperative:${endpoint.hostname}:${Object.isFrozen(endpoint)}`; - }, -}); -``` - - diff --git a/smoke-test/Guide/Daemons.md b/smoke-test/Guide/Daemons.md index 1bd61e70..5c56b389 100644 --- a/smoke-test/Guide/Daemons.md +++ b/smoke-test/Guide/Daemons.md @@ -3,8 +3,8 @@ The `daemon` modifier starts an arbitrary long-running process with configuration the document or host already owns. The test below starts a process that remains alive while the next block runs. When the test's scope closes, structured -concurrency terminates the daemon with no manual cleanup. Cooperative dynamic -services use `service=` instead. +concurrency terminates the daemon with no manual cleanup. Attached services use +`service=` instead. @@ -20,14 +20,14 @@ echo daemon-ok - - - + + + - + diff --git a/smoke-test/Guide/Summary.md b/smoke-test/Guide/Summary.md index f823d142..14ce853c 100644 --- a/smoke-test/Guide/Summary.md +++ b/smoke-test/Guide/Summary.md @@ -35,8 +35,8 @@ cat <<'TABLE' | eval binding interpolation| {label} in exec block from eval binding | | daemon modifier | bash daemon exec starts background proc | | daemon fixed configuration| Arbitrary process stays scope-owned | -| service modifier | Cooperative process publishes live endpoint | -| cooperative provider | persist ephemeral eval scopes middleware | +| service modifier | Attached service publishes live endpoint | +| attached-service provider | persist ephemeral eval scopes middleware | | provider pattern | StubProvider installs Sample middleware | | per-component eval scope | Each provider gets isolated middleware | | props in env.values | model prop available in eval blocks | diff --git a/smoke-test/attached-service-ping-pong.test.md b/smoke-test/attached-service-ping-pong.test.md new file mode 100644 index 00000000..0de01bee --- /dev/null +++ b/smoke-test/attached-service-ping-pong.test.md @@ -0,0 +1,10 @@ +# Attached service ping-pong + + + + + + + + + diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index c179c1fb..b1e2894a 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -548,17 +548,17 @@ only a nullish return value, and calling `output()` is an error. The modifier is valid only directly around the terminal `eval`; `persist ephemeral eval` keeps installed middleware in the invocation eval scope. -**`service=`** is a terminal service-acquisition modifier. The code +**`service=`** is a terminal service-attachment modifier. The code block content is the shell command passed to `startService()`, and the required parameter is the live binding that receives the frozen `{ hostname: "127.0.0.1", port }` endpoint. Binding syntax and collisions are -validated before process acquisition. The modifier produces no output or -journal entry, and the acquired service remains supervised until the component -invocation closes. +validated before process attachment. The modifier produces no output or +journal entry, and the service attachment remains supervised until the +component invocation closes. ````markdown ```bash service=server exec -./server --cooperative +./handshake-compatible-server ``` ```ts persist ephemeral eval @@ -592,8 +592,8 @@ the info string is purely syntactic: it satisfies the detection rule and signals to readers that this block runs a command. `daemon` is for fixed-configuration background processes. It does not allocate -or publish a dynamic endpoint and does not establish readiness; cooperative -network services use `service=`. +or publish a dynamic endpoint and does not perform the XMD service handshake; +attached network services use `service=`. | Property | `exec` | `daemon` | |---|---|---| @@ -1152,7 +1152,7 @@ of the same name cannot collide. The exact list lives in the `STANDARD_IMPORTS` constant, which both compilers share (`src/data-uri-compiler.ts`, `src/temp-file-compiler.ts`). -#### Cooperative service API +#### Attached service API `@executablemd/runtime` exports provider-neutral `API.Service` under the stable context-api name `runtime.service`, and its ordinary operation as @@ -1170,12 +1170,12 @@ interface ServiceStartOptions { readonly startupTimeout?: number; } -interface ServiceResource { +interface ServiceAttachment { readonly endpoint: Readonly; } interface ServiceHandler { - start(options: ServiceStartOptions): Operation; + start(options: ServiceStartOptions): Operation; } ``` @@ -1198,8 +1198,8 @@ yield* when(function* () { `fetch().expect()` from `@effectionx/fetch` throws `HttpError` on non-2xx responses. `when` catches it and retries until the assertion passes or the -timeout expires. Cooperative-service startup readiness is established by the -host protocol, not by polling from a document. +timeout expires. Attached-service startup is established by the XMD service +handshake protocol, not by polling from a document. #### Compiling blocks @@ -1270,7 +1270,7 @@ projected caller content. | journal serialization and replay restore | yes | no | The two namespaces may not overlap. `service=` validates the binding -name and checks both environments before acquiring a process. Ordinary durable +name and checks both environments before attaching a process. Ordinary durable eval validates its declared exports against live names before execution or replay restoration and before appending an eval event. `ephemeral eval` validates its exports against durable names before execution; it may atomically @@ -1502,16 +1502,16 @@ run but are absent from the diagnostic trace. | `src/modifiers/timeout.ts` | `timeoutFactory`, `parseDuration()` | | `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier | | `src/modifiers/ephemeral.ts` | `ephemeralFactory` — replay-safe live eval wrapper | -| `src/modifiers/service.ts` | `serviceFactory` — scoped cooperative-service acquisition | +| `src/modifiers/service.ts` | `serviceFactory` — scoped service attachment | | `src/sample-api.ts` | `Sample` Api definition (§3.4) — LLM middleware surface | -| `packages/runtime/service.ts` | provider-neutral `API.Service`, readiness protocol types and `startService()` resource | +| `packages/runtime/service.ts` | provider-neutral `API.Service`, XMD service handshake types and `startService()` attachment | | `src/api.ts` | Document Output Api definition, exports `output` (§9.2) | | `src/collect.ts` | `collect()` — stream consumption helper, returns `Result` | | `src/output/mod.ts` | Barrel export for output middleware | | `src/output/normalize.ts` | `useNormalizedOutput()` — whitespace normalization middleware (§9.4) | | `src/output/terminal.ts` | `useTerminalOutput()` — terminal ANSI formatting middleware (§9.5) | | `packages/cli/src/cli.ts` | Runtime-neutral CLI (separate `cli` workspace package) with `--verbose`, `--journal`, and `--raw` flags; Output Api stream consumption (§9.6) | -| `packages/cli/src/service-host.ts` | shared authenticated readiness observer and supervised host-process adapter | +| `packages/cli/src/service-host.ts` | shared XMD service handshake observer and supervised host-process adapter | | `packages/cli/src/{deno,node,bun,compiled}-service.ts` | runtime-named service adapters for token, environment and stdio behavior | | `packages/cli/src/{deno,node,bun,compiled}.ts` | Entrypoints — each installs matching `API.Env` and `API.Service` adapters, then calls `runXmd` | | `packages/workflow/src/service-denial.ts` | non-delegating workflow service denial middleware | @@ -3781,15 +3781,16 @@ and strings. ### 6.7 Provider component pattern -A **provider component** is a regular markdown component whose body acquires a -cooperative service and installs middleware for its subtree. It composes +A **provider component** is a regular markdown component whose body starts an +attached service and installs middleware for its subtree. It composes `service=` + `persist ephemeral eval` + ``; the host, not -the document, owns endpoint allocation and authenticated readiness. +the document, owns endpoint allocation and the authenticated XMD service +handshake. #### Structure -1. A `service=` block starts the cooperative command and waits for an - authenticated readiness record. +1. A `service=` block starts the handshake-compatible command and waits + for an authenticated handshake record. 2. A `persist ephemeral eval` block reads the live endpoint and installs provider middleware in the component eval scope. 3. `` expands the subtree while the supervised process and @@ -3829,7 +3830,7 @@ yield* Sample.around({ The executable receives `XMD_SERVICE_HOST`, `XMD_SERVICE_PORT` and a cryptographically random `XMD_SERVICE_TOKEN`. It binds exactly the supplied -loopback host and port, then writes one newline-terminated readiness record to +loopback host and port, then writes one newline-terminated handshake record to stdout: ```text @@ -3839,21 +3840,21 @@ XMD_SERVICE_READY:{"version":1,"token":"","hostname":"127.0.0.1","port":4 The host installs its byte-level stdout observer before spawn. At each line start it retains only bytes that can still match `XMD_SERVICE_READY:`; on the first mismatch it forwards those bytes and all subsequent ordinary bytes -immediately, without waiting for a newline. Only an actual protocol candidate +immediately, without waiting for a newline. Only an actual handshake candidate is buffered, under a finite bound. The observer suppresses valid and invalid -protocol records and accepts readiness only when the JSON object has exactly -those fields and matches the expected version, token, host and port. Malformed, -forged, duplicate or late records fail the service without exposing the token -or raw protocol line. -Startup races readiness against process exit, protocol failure and the -contextual startup timeout. After readiness the host continues supervising -process exit and duplicate records until acquisition ends. An observable host +handshake records and accepts the handshake only when the JSON object has +exactly those fields and matches the expected version, token, host and port. +Malformed, forged, duplicate or late records fail the attached service without +exposing the token or raw handshake line. +Startup races the handshake against process exit, handshake failure and the +contextual startup timeout. After the handshake the host continues supervising +process exit and duplicate records until the attachment ends. An observable host process teardown failure becomes `ServiceTeardownError`; when execution is already failing, the invocation teardown aggregate preserves that execution failure first and keeps the service failure reachable through its teardown member. -The service binding is live, so only the `ephemeral eval` block can read it. +The attached-service binding is live, so only the `ephemeral eval` block can read it. The block installs middleware in the invocation scope through `persist`; plain eval, interpolation and the journal cannot observe the endpoint. Partial replay runs both blocks again to reconstruct a current process and middleware chain. @@ -3872,7 +3873,7 @@ scope boundary: ``` -Each acquisition receives a distinct host-selected endpoint and token. Both +Each service attachment receives a distinct host-selected endpoint and token. Both services remain live while the nested report expands; the inner service tears down before the outer in standard structured-concurrency order. @@ -3959,9 +3960,10 @@ All three props are optional with empty-string defaults: #### Repeated-run behavior of the provider pattern -Every run allocates a current free port, starts the daemon, performs readiness -polling and child operations, then terminates the daemon when the component -closes. A previous diagnostic trace does not suppress any of these actions. +Every run allocates a current free port, creates the service attachment, +performs the XMD service handshake and child operations, then terminates the +attached service when the component closes. A previous diagnostic trace does +not suppress any of these actions. ### 6.8.1 When a function component fails @@ -5563,7 +5565,7 @@ yield* runXmd(args, useDenoService); ``` The installer is invoked only for `xmd run` and `xmd test`, immediately before -`execute()`. Help, inspection and agent-worker paths never install or acquire a +`execute()`. Help, inspection and agent-worker paths never install or attach a service. Each adapter supplies host randomness, inherited environment and stdout/stderr writers to the shared service host; production adapters reject a non-loopback requested host before spawning. @@ -5698,7 +5700,7 @@ Given a document: ```markdown # Title - + @@ -6641,7 +6643,7 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. | R1 | Live overlay hidden from plain eval | A service binding is absent from the ordinary eval preamble | | R2 | Live overlay hidden from interpolation | `{server}` remains literal rather than becoming an endpoint string | | R3 | `ephemeral eval` executes during partial replay | Live bindings and middleware are reconstructed without a journal entry | -| R4 | Service publication collision | A durable or live binding with the requested name refuses acquisition before spawn | +| R4 | Service publication collision | A durable or live binding with the requested name refuses attachment before spawn | | R5 | Durable export collides with a service | Live execution and partial replay both reject before execution or restoration and append no eval event | | R6 | Ephemeral export collides with durable state | The block is rejected before execution and publishes no partial export | | R7 | Ephemeral update of a live binding | A later ephemeral block may atomically replace an existing live name | @@ -6655,22 +6657,23 @@ Defined in [Workflow runs](./workflow-spec.md) §9.5–§9.6. |---|------|--------| | S1 | Full provider golden run | service → persistent ephemeral middleware → children → cleanup | | S2 | Endpoint flows to ephemeral middleware | `server` is an exact frozen loopback endpoint available only to ephemeral eval | -| S3 | Children can call sample after protocol readiness | `sample` calls in children reach the acquired endpoint | -| S4 | Cancellation after readiness | The child exits and its listener can be rebound after its owning task is halted | -| S5 | Startup failures | Exit, timeout and invalid readiness records produce dedicated errors without leaking protocol data | +| S3 | Children can call sample after the handshake | `sample` calls in children reach the attached-service endpoint | +| S4 | Cancellation after handshake | The child exits and its listener can be rebound after its owning task is halted | +| S5 | Startup failures | Exit, timeout and invalid handshake records produce dedicated errors without leaking handshake data | | S6 | Provider exits during projected content | The projected request reaches the ready process, its unexpected exit fails the document, and it is not restarted | | S7 | Nested real providers | Outer + inner processes both start and inner teardown finishes before outer teardown | | S8 | Nested providers, no model | Innermost provider handles sample call | | S9 | Nested providers, explicit model matching outer | Inner passes through, outer handles | | S10 | Nested providers, explicit model matching inner | Inner handles regardless of nesting depth | | S11 | Unmatched model | Chain exhausted → descriptive error naming the model | -| S12 | Partial replay | Service acquisition and ephemeral middleware execute again after the recorded prefix | +| S12 | Partial replay | Service attachment and ephemeral middleware execute again after the recorded prefix | | S13 | Completed replay | Completed document returns without process spawn or token allocation | -| S14 | Concurrent service acquisitions | Two owners acquire at the same time and receive distinct live endpoints | +| S14 | Concurrent service attachments | Two owners attach at the same time and receive distinct live endpoints | | S15 | Incremental ordinary stdout | Unterminated and chunk-split ordinary bytes are forwarded before teardown, byte for byte | -| S16 | Incremental protocol records | Split readiness is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed | +| S16 | Incremental handshake records | A split handshake is suppressed, duplicate supervision remains active, and invalid candidates are bounded and suppressed | | S17 | Service teardown failures | A lone observable process teardown failure becomes `ServiceTeardownError`; an active execution failure remains first in the invocation aggregate | | S18 | Projected failure cleanup | A prompt projected-content failure tears down both retained real services and starts neither again | +| S19 | Compiled-binary attached-service ping-pong | A smoke document attaches two real services, closes both endpoints into ephemeral middleware, journals only the ordinary filtered `Sample` result and completes `ping→pong→ping` | ### Tier EO — eval output() function @@ -7058,7 +7061,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 42 | Service endpoints are live bindings | The endpoint identifies an execution-owned process and is reconstructed during partial replay, so it cannot enter durable eval, interpolation or the journal | | 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed | | 44 | Provider lifecycle expressed as a component, not an `ExecuteOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required | -| 45 | Cooperative readiness is an authenticated stdout protocol | The host observes from before spawn, verifies version/token/host/port exactly and supervises the process continuously without a close-and-rebind race | +| 45 | The XMD service handshake protocol is authenticated stdout | The host observes from before spawn, verifies version/token/host/port exactly and supervises the attached service continuously without a close-and-rebind race | | 46 | Provider middleware reads an endpoint from the live overlay | The current endpoint is available to `ephemeral eval` while remaining invisible to durable effects and interpolation | | 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation | | 48 | `output()` is a plain function, not `yield*` | Output is a synchronous side effect (mutating a ref), not an Effection operation; making it a function keeps the API simple and avoids requiring generator context just to set output text | @@ -7075,7 +7078,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 59 | Provider components use ordinary generated-module imports | Provider-specific client functions may be imported explicitly; executable.md supplies `Sample`, `when`, `fetch` and the contextual document bindings | | 60 | Props pre-populated into `env.values` at component invocation | Code block content uses bare `{name}` binding interpolation from `env.values`; props must enter `env.values` at invocation time to be accessible in code blocks; consistent with how eval bindings work | | 61 | Provider HTTP calls use `@effectionx/fetch` | Calls remain Effection operations under structured cancellation and the provider's lexical middleware | -| 62 | Protocol readiness and application health are separate | The readiness record proves the cooperative process owns the assigned endpoint; an application may still use `when` for a later domain-specific condition | +| 62 | The XMD service handshake and application health are separate | The handshake record proves the attached service owns the assigned endpoint; an application may still use `when` for a later domain-specific condition | | 63 | `stdio: "inherit"` is the default for `daemon()` | During development, seeing server logs in the terminal is valuable; production deployments can pass `stdio: "ignore"`; the executable.md `daemonFactory` passes no stdio option, defaulting to `"inherit"` | | 64 | `DocumentOutput` Api with single `output` operation | Extensible to progress/printed errors; middleware-composable via `scope.around`; single Api surface for all output concerns | | 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call |