From dfc1880cb7512dd60e2a22664d5e71ec83574fe4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:13:38 -0700 Subject: [PATCH 01/23] apps: scaffold plugin package with five seam interfaces and QuickJS sandbox backing --- DESIGN.md | 105 +++++++ bun.lock | 35 ++- packages/plugins/apps/package.json | 60 ++++ .../apps/src/backing/git-artifact-store.ts | 284 ++++++++++++++++++ .../src/backing/in-process-live-channel.ts | 34 +++ .../apps/src/backing/libsql-scope-db.ts | 184 ++++++++++++ .../src/backing/quickjs-tool-sandbox.test.ts | 125 ++++++++ .../apps/src/backing/quickjs-tool-sandbox.ts | 252 ++++++++++++++++ packages/plugins/apps/src/pipeline/bundle.ts | 212 +++++++++++++ .../plugins/apps/src/seams/artifact-store.ts | 60 ++++ packages/plugins/apps/src/seams/index.ts | 8 + .../plugins/apps/src/seams/live-channel.ts | 36 +++ packages/plugins/apps/src/seams/scope-db.ts | 58 ++++ .../plugins/apps/src/seams/tool-sandbox.ts | 105 +++++++ .../plugins/apps/src/seams/workflow-runner.ts | 139 +++++++++ .../plugins/apps/src/testing/daily-brief.ts | 197 ++++++++++++ packages/plugins/apps/tsconfig.json | 23 ++ packages/plugins/apps/tsup.config.ts | 15 + packages/plugins/apps/vitest.config.ts | 9 + 19 files changed, 1940 insertions(+), 1 deletion(-) create mode 100644 DESIGN.md create mode 100644 packages/plugins/apps/package.json create mode 100644 packages/plugins/apps/src/backing/git-artifact-store.ts create mode 100644 packages/plugins/apps/src/backing/in-process-live-channel.ts create mode 100644 packages/plugins/apps/src/backing/libsql-scope-db.ts create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts create mode 100644 packages/plugins/apps/src/pipeline/bundle.ts create mode 100644 packages/plugins/apps/src/seams/artifact-store.ts create mode 100644 packages/plugins/apps/src/seams/index.ts create mode 100644 packages/plugins/apps/src/seams/live-channel.ts create mode 100644 packages/plugins/apps/src/seams/scope-db.ts create mode 100644 packages/plugins/apps/src/seams/tool-sandbox.ts create mode 100644 packages/plugins/apps/src/seams/workflow-runner.ts create mode 100644 packages/plugins/apps/src/testing/daily-brief.ts create mode 100644 packages/plugins/apps/tsconfig.json create mode 100644 packages/plugins/apps/tsup.config.ts create mode 100644 packages/plugins/apps/vitest.config.ts diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..6861ae3ff --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,105 @@ +# Executor apps — self-hosted build (DESIGN.md) + +Status: in progress. Durable record of the architecture, seam signatures, +package layout, key decisions, verification commands, and known gaps for the +executor apps subsystem built into the self-hosted deployment. + +Note: the tracked design-system doc is `design.md` (lowercase). This uppercase +`DESIGN.md` is the apps-subsystem architecture record required by the build +brief. + +## What this is + +User-authored, git-backed, published units — **custom tools**, **durable +workflows**, **UI views**, **skills** — published into a per-scope store and +served/executed by the self-hosted platform. Identity is the file path +(`tools/issues-sync.ts` IS the tool `issues-sync`). Publish is the compiler +(FDI): catalog entries, schedules, ui resources and the skills index are +projections of a versioned descriptor extracted from source at publish. + +The subsystem lives in one package, `@executor-js/plugin-apps`, wired into the +self-host app the same way every other plugin is (a source plugin in +`executor.config.ts`, HTTP routes as an extension, MCP tools/resources through +the MCP build hook). Everything substrate-specific sits behind a **seam** with +a substrate-neutral interface and a conformance suite that runs against the +interface, so future Cloudflare backings drop in without touching the +subsystem's logic. + +## The five seams + +Each seam is a substrate-neutral interface. Self-hosted backings are built now; +cloud backings are future. Everything crossing `ToolSandbox` is serializable +(the cloud version is RPC). + +| Seam | Self-hosted backing (built) | Cloud backing (future) | +|---|---|---| +| `ArtifactStore` | bare git repo per scope on disk (git CLI subprocess); `SnapshotId` = commit hash | Cloudflare Artifacts | +| `ScopeDb` | one libSQL/SQLite file per scope + per-table version counters | DO facets | +| `ToolSandbox` | QuickJS kernel (collect + invoke via the `SandboxToolInvoker` bridge) | Worker Loaders | +| `WorkflowRunner` | SQLite event-sourced journal replay runner + in-process scheduler | CF Workflows + dynamic-workflows | +| `LiveChannel` | in-process emitter + SSE | DO/facet socket owner | + +See `src/seams/*.ts` for the exact interfaces and `src/seams/*.conformance.ts` +for the suite each backing must pass. + +## Decisions (and why) + +- **Sandbox = QuickJS** (`packages/kernel/runtime-quickjs`). Its + `CodeExecutor.execute(code, toolInvoker)` already gives the serializable + handle bridge: `SandboxToolInvoker.invoke({path, args})` crosses as JSON, + `tools.<...>()` is a Proxy in the sandbox, `fetch` is disabled, there is a + deadline interrupt and a memory cap. secure-exec evaluated and rejected + (pre-1.0, per-arch native sidecar, flat string bridge fighting the Proxy + pattern); the Deno subprocess kernel is the documented harder-isolation + escalation behind the same seam. Because QuickJS evaluates a *string*, the + collect/invoke wrappers own the module shape: the published bundle is a + self-executing script that either records `define*()` descriptors (collect) + or calls one handler with injected clients (invoke). +- **Storage via host facades, not new tables.** Executor plugins deliberately + do not contribute FumaDB tables (`collectTables()` is fixed and + plugin-independent). App metadata (descriptors, snapshot pointers, schedules, + workflow journal, ui metadata) lives in `pluginStorage` collections; large + opaque blobs (compiled bundles, snapshot manifests) live in the `blobs` + facade, content-addressed by SHA-256. +- **ScopeDb is separate from the executor DB.** App *data* (the `issues` table + authors read/write) is one libSQL file per scope, independent of the + executor's own DB. Per-table version counters live alongside it and drive + `LiveChannel`. +- **Apps are a plugin source.** A published app maps to one executor + *integration* per scope (`apps`); a *connection* to it makes the published + tools catalog citizens through `resolveTools`/`invokeTool`, so + policy/approval/audit/toolkits/tools.list all apply unchanged. +- **No @effect/workflow.** The local runner is a purpose-built SQLite + event-sourced journal modeled on vercel/workflow's `World` Storage contract + (append-only events, materialized run/step views, replay-on-resume). + +## Package layout + +``` +packages/plugins/apps/ + src/ + seams/ ArtifactStore, ScopeDb, ToolSandbox, WorkflowRunner, LiveChannel + + one .conformance.ts per seam + pipeline/ discover -> bundle -> collect -> project (publish = the compiler) + plugin/ the apps source plugin (resolveTools/invokeTool), descriptor store + workflow/ journal runner + scheduler (over WorkflowRunner seam) + http/ publish + invoke + ui-bundle + SSE routes (HttpApiGroup + handlers) + mcp/ publish tool, ui:// resources, skills list/read over MCP + ui/ widget shell (browser render, tools/query bridge, SSE refetch) + testing/ test harness helpers + the daily-brief fixture set + e2e +``` + +## Verification gates — exact commands + +Run from the workspace root +(`usefulsoftwareco/.rifts/executor/apps-build-b`): + +- Typecheck (repo root): `bun run typecheck` +- Package tests (conformance + integration + e2e): + `bun run --filter='@executor-js/plugin-apps' test` + +Outputs pasted in the final report. + +## Known gaps + +Tracked here as the build proceeds; honest list in the final report. diff --git a/bun.lock b/bun.lock index e1f7250b2..b550e4188 100644 --- a/bun.lock +++ b/bun.lock @@ -781,6 +781,37 @@ "effect": "catalog:", }, }, + "packages/plugins/apps": { + "name": "@executor-js/plugin-apps", + "version": "0.1.0", + "dependencies": { + "@executor-js/codemode-core": "workspace:*", + "@executor-js/config": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@standard-schema/spec": "^1.0.0", + "esbuild": "^0.27.3", + "zod": "4.3.6", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/api": "workspace:*", + "effect": "catalog:", + }, + "optionalPeers": [ + "@executor-js/api", + ], + }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", "version": "1.5.28", @@ -1215,9 +1246,9 @@ }, }, "patchedDependencies": { - "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", + "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, @@ -1776,6 +1807,8 @@ "@executor-js/motel": ["@executor-js/motel@0.2.5-executor.1", "", { "dependencies": { "@effect/atom-react": "^4.0.0-beta.49", "@effect/opentelemetry": "^4.0.0-beta.49", "@effect/platform-bun": "^4.0.0-beta.50", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", "@opentelemetry/sdk-logs": "^0.214.0", "@opentelemetry/sdk-node": "^0.214.0", "@opentelemetry/sdk-trace-base": "^2.5.0", "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentui/core": "^0.1.99", "@opentui/react": "^0.1.99", "effect": "^4.0.0-beta.49", "react": "^19.2.5", "scheduler": "^0.27.0" }, "bin": { "motel": "src/motel.ts", "motel-mcp": "src/mcp.ts" } }, "sha512-fLGJ5FIIBYd+4L2OurpYFjWzvcCbACvmdNofh6THXQJE1Gku93EKURtgn27/XddyM9SKGST6/znTRcmwKiYdXw=="], + "@executor-js/plugin-apps": ["@executor-js/plugin-apps@workspace:packages/plugins/apps"], + "@executor-js/plugin-desktop-settings": ["@executor-js/plugin-desktop-settings@workspace:packages/plugins/desktop-settings"], "@executor-js/plugin-encrypted-secrets": ["@executor-js/plugin-encrypted-secrets@workspace:packages/plugins/encrypted-secrets"], diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json new file mode 100644 index 000000000..2abb47bf6 --- /dev/null +++ b/packages/plugins/apps/package.json @@ -0,0 +1,60 @@ +{ + "name": "@executor-js/plugin-apps", + "version": "0.1.0", + "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/apps", + "bugs": { + "url": "https://github.com/UsefulSoftwareCo/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", + "directory": "packages/plugins/apps" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./api": "./src/api.ts", + "./seams": "./src/seams/index.ts", + "./testing": "./src/testing/index.ts" + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/codemode-core": "workspace:*", + "@executor-js/config": "workspace:*", + "@executor-js/runtime-quickjs": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@libsql/client": "catalog:", + "@standard-schema/spec": "^1.0.0", + "esbuild": "^0.27.3", + "zod": "4.3.6" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@modelcontextprotocol/sdk": "^1.29.0", + "@types/node": "catalog:", + "bun-types": "catalog:", + "effect": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@executor-js/api": "workspace:*", + "effect": "catalog:" + }, + "peerDependenciesMeta": { + "@executor-js/api": { + "optional": true + } + } +} diff --git a/packages/plugins/apps/src/backing/git-artifact-store.ts b/packages/plugins/apps/src/backing/git-artifact-store.ts new file mode 100644 index 000000000..3eaf18f50 --- /dev/null +++ b/packages/plugins/apps/src/backing/git-artifact-store.ts @@ -0,0 +1,284 @@ +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { Effect } from "effect"; + +import { + ArtifactStoreError, + asSnapshotId, + type ArtifactStore, + type FileSet, + type ScopeArtifactStore, + type SnapshotId, + type SnapshotMeta, +} from "../seams/artifact-store"; + +// --------------------------------------------------------------------------- +// Git-backed ArtifactStore (self-hosted). One bare git repo per scope under +// `/.git`. Snapshots are commits, written via git plumbing +// (hash-object -> update-index -> write-tree -> commit-tree) so no working tree +// is ever checked out — the runtime only reads committed snapshots. The commit +// hash IS the SnapshotId; git guarantees immutability (content-addressed). +// --------------------------------------------------------------------------- + +const BRANCH = "refs/heads/main"; + +const run = ( + cwd: string, + args: readonly string[], + input?: string | Buffer, +): Effect.Effect => + Effect.async((resume) => { + const child = execFile( + "git", + args, + { cwd, maxBuffer: 256 * 1024 * 1024, encoding: "buffer" }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + if (input !== undefined) { + child.stdin?.write(input); + child.stdin?.end(); + } + }); + +const sanitizeScope = (scope: string): string => { + if (!/^[a-zA-Z0-9._-]+$/.test(scope)) { + // Scopes are internal identifiers; refuse anything that could escape a path. + throw new ArtifactStoreError({ message: `invalid scope key: ${scope}` }); + } + return scope; +}; + +const makeScopeStore = (repoDir: string): ScopeArtifactStore => { + // Environment forcing a deterministic author/committer so a snapshot's hash + // depends only on its content + parent, not on wall-clock/identity drift. + const commitEnv = (message: string) => + Effect.gen(function* () { + const parent = yield* headCommit(); + // Build a tree from the provided file set held in a bare index file. + return { parent, message }; + }); + + const headCommit = (): Effect.Effect => + run(repoDir, ["rev-parse", "--verify", "--quiet", BRANCH]).pipe( + Effect.map((out) => out.trim() || null), + Effect.catchAll(() => Effect.succeed(null)), + ); + + const readMeta = (id: string): Effect.Effect => + run(repoDir, ["show", "-s", "--format=%H%n%ct%n%s", id]).pipe( + Effect.map((out) => { + const [hash, committed, ...subjectParts] = out.split("\n"); + return { + id: asSnapshotId(hash.trim()), + committedAt: Number(committed.trim()) * 1000, + message: subjectParts.join("\n").trim(), + } satisfies SnapshotMeta; + }), + ); + + return { + commit: (files: FileSet, message: string) => + Effect.gen(function* () { + const { parent } = yield* commitEnv(message); + // Use a throwaway index so we never touch a working tree. + const indexFile = join( + repoDir, + `commit-index-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const withIndex = (args: readonly string[], input?: string | Buffer) => + Effect.async((resume) => { + const child = execFile( + "git", + args, + { + cwd: repoDir, + maxBuffer: 256 * 1024 * 1024, + encoding: "buffer", + env: { ...process.env, GIT_INDEX_FILE: indexFile }, + }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git ${args[0]} failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + if (input !== undefined) { + child.stdin?.write(input); + child.stdin?.end(); + } + }); + + // Start from an empty index each publish (full file set, not a diff). + yield* withIndex(["read-tree", "--empty"]); + for (const [path, contents] of files) { + const blobHash = (yield* withIndex( + ["hash-object", "-w", "--stdin"], + Buffer.from(contents, "utf8"), + )).trim(); + yield* withIndex(["update-index", "--add", "--cacheinfo", `100644,${blobHash},${path}`]); + } + const treeHash = (yield* withIndex(["write-tree"])).trim(); + const commitArgs = ["commit-tree", treeHash, "-m", message]; + if (parent) commitArgs.push("-p", parent); + const commitEnvVars = { + ...process.env, + GIT_AUTHOR_NAME: "executor-apps", + GIT_AUTHOR_EMAIL: "apps@executor.local", + GIT_COMMITTER_NAME: "executor-apps", + GIT_COMMITTER_EMAIL: "apps@executor.local", + }; + const commitHash = (yield* Effect.async((resume) => { + const child = execFile( + "git", + commitArgs, + { cwd: repoDir, encoding: "buffer", env: commitEnvVars }, + (error, stdout, stderr) => { + if (error) { + resume( + Effect.fail( + new ArtifactStoreError({ + message: `git commit-tree failed: ${(stderr as Buffer)?.toString() || error.message}`, + cause: error, + }), + ), + ); + return; + } + resume(Effect.succeed((stdout as Buffer).toString("utf8"))); + }, + ); + })).trim(); + yield* run(repoDir, ["update-ref", BRANCH, commitHash]); + return yield* readMeta(commitHash); + }), + + read: (id: SnapshotId) => + run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe( + Effect.flatMap((out) => { + const paths = out + .split("\n") + .map((p) => p.trim()) + .filter(Boolean); + return Effect.forEach( + paths, + (path) => + run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe( + Effect.map((contents) => [path, contents] as const), + ), + { concurrency: 8 }, + ); + }), + Effect.map((entries) => new Map(entries) as FileSet), + ), + + readFile: (id: SnapshotId, path: string) => + run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe( + Effect.map((contents) => contents as string | null), + Effect.catchAll(() => Effect.succeed(null)), + ), + + list: (id: SnapshotId) => + run(repoDir, ["ls-tree", "-r", "--name-only", id]).pipe( + Effect.map((out) => + out + .split("\n") + .map((p) => p.trim()) + .filter(Boolean), + ), + ), + + latest: () => + headCommit().pipe(Effect.flatMap((head) => (head ? readMeta(head) : Effect.succeed(null)))), + + log: (limit = 50) => + headCommit().pipe( + Effect.flatMap((head) => { + if (!head) return Effect.succeed([] as readonly SnapshotMeta[]); + return run(repoDir, [ + "log", + `--max-count=${limit}`, + "--format=%H%x1f%ct%x1f%s", + BRANCH, + ]).pipe( + Effect.map((out) => + out + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [hash, committed, subject] = line.split("\x1f"); + return { + id: asSnapshotId(hash), + committedAt: Number(committed) * 1000, + message: subject ?? "", + } satisfies SnapshotMeta; + }), + ), + ); + }), + ), + }; +}; + +export interface GitArtifactStoreOptions { + /** Directory holding one bare repo per scope. Created on demand. */ + readonly root: string; +} + +/** Build the git-backed ArtifactStore. Each scope gets a lazily-initialized + * bare repo `/.git`. */ +export const makeGitArtifactStore = (options: GitArtifactStoreOptions): ArtifactStore => { + const initialized = new Map>(); + + const init = async (scope: string): Promise => { + const safe = sanitizeScope(scope); + const repoDir = join(options.root, `${safe}.git`); + await mkdir(repoDir, { recursive: true }); + await new Promise((resolve, reject) => { + execFile("git", ["init", "--bare", "--quiet"], { cwd: repoDir }, (error) => + error ? reject(error) : resolve(), + ); + }); + return makeScopeStore(repoDir); + }; + + return { + forScope: (scope) => + Effect.tryPromise({ + try: () => { + let existing = initialized.get(scope); + if (!existing) { + existing = init(scope); + initialized.set(scope, existing); + } + return existing; + }, + catch: (cause) => + new ArtifactStoreError({ message: `failed to open scope repo: ${scope}`, cause }), + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/in-process-live-channel.ts b/packages/plugins/apps/src/backing/in-process-live-channel.ts new file mode 100644 index 000000000..b73111070 --- /dev/null +++ b/packages/plugins/apps/src/backing/in-process-live-channel.ts @@ -0,0 +1,34 @@ +import { Effect } from "effect"; + +import type { Invalidation, LiveChannel } from "../seams/live-channel"; + +// --------------------------------------------------------------------------- +// In-process LiveChannel (self-hosted). A per-scope set of listeners; publish +// fans out synchronously. The self-host server exposes each subscription as an +// SSE stream. The cloud backing (future) makes the storage owner the notifier. +// --------------------------------------------------------------------------- + +export const makeInProcessLiveChannel = (): LiveChannel => { + const byScope = new Map void>>(); + + return { + publish: (event: Invalidation) => + Effect.sync(() => { + const listeners = byScope.get(event.scope); + if (!listeners) return; + for (const listener of listeners) listener(event); + }), + subscribe: (scope: string, listener: (event: Invalidation) => void) => { + let set = byScope.get(scope); + if (!set) { + set = new Set(); + byScope.set(scope, set); + } + set.add(listener); + return () => { + set?.delete(listener); + if (set && set.size === 0) byScope.delete(scope); + }; + }, + }; +}; diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.ts b/packages/plugins/apps/src/backing/libsql-scope-db.ts new file mode 100644 index 000000000..470e829c8 --- /dev/null +++ b/packages/plugins/apps/src/backing/libsql-scope-db.ts @@ -0,0 +1,184 @@ +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import { + ScopeDbError, + type ScopeDb, + type ScopeDbHandle, + type ScopeWriteEvent, +} from "../seams/scope-db"; + +// --------------------------------------------------------------------------- +// libSQL-backed ScopeDb (self-hosted). One SQLite file per scope under +// `/.db`. A control table `__versions` holds a monotonic counter +// per user table; every write statement bumps the counters for the tables it +// touched and emits a `ScopeWriteEvent` (the runtime forwards that to +// LiveChannel). Scope isolation is a separate file per scope — there is no +// cross-scope query path. +// --------------------------------------------------------------------------- + +const VERSION_TABLE = "__scope_versions"; + +// Statements that mutate data. Determines whether a `sql` call bumps versions. +const WRITE_RE = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i; + +// Extract the table names a write statement targets (best-effort: covers the +// shapes authored tools produce — INSERT INTO x, UPDATE x, DELETE FROM x, +// CREATE TABLE x, REPLACE INTO x). +const targetsOf = (sql: string): string[] => { + const out = new Set(); + const patterns = [ + /\binsert\s+(?:or\s+\w+\s+)?into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\breplace\s+into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bupdate\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bdelete\s+from\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\bdrop\s+table\s+(?:if\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + /\balter\s+table\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, + ]; + for (const re of patterns) { + let m: RegExpExecArray | null; + while ((m = re.exec(sql)) !== null) { + const name = m[1]; + if (name && name !== VERSION_TABLE) out.add(name); + } + } + return [...out]; +}; + +const toArgs = (values: readonly unknown[]): unknown[] => + values.map((v) => (v === undefined ? null : v)); + +const makeHandle = ( + scope: string, + client: Client, + emit: (event: ScopeWriteEvent) => void, +): ScopeDbHandle => { + const ensureVersionTable = async () => { + await client.execute( + `CREATE TABLE IF NOT EXISTS ${VERSION_TABLE} (name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)`, + ); + }; + + const bump = async (tables: readonly string[]): Promise<{ table: string; version: number }[]> => { + const bumped: { table: string; version: number }[] = []; + for (const table of tables) { + await client.execute({ + sql: `INSERT INTO ${VERSION_TABLE} (name, version) VALUES (?, 1) + ON CONFLICT(name) DO UPDATE SET version = version + 1`, + args: [table], + }); + const row = await client.execute({ + sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, + args: [table], + }); + bumped.push({ table, version: Number(row.rows[0]?.version ?? 0) }); + } + return bumped; + }; + + const runStatement = ( + sql: string, + args: unknown[], + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const result = await client.execute({ sql, args: args as never }); + if (WRITE_RE.test(sql)) { + const targets = targetsOf(sql); + if (targets.length > 0) { + const bumped = await bump(targets); + if (bumped.length > 0) emit({ scope, tables: bumped }); + } + } + return result.rows as unknown as readonly Row[]; + }, + catch: (cause) => + new ScopeDbError({ message: `scope-db statement failed for scope ${scope}`, cause }), + }); + + return { + sql: >(strings: TemplateStringsArray, ...values: unknown[]) => { + const sql = strings.reduce((acc, part, i) => acc + part + (i < values.length ? "?" : ""), ""); + return runStatement(sql, toArgs(values)); + }, + exec: >(sql: string, params: readonly unknown[] = []) => + runStatement(sql, toArgs(params)), + tableVersion: (table: string) => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const row = await client.execute({ + sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, + args: [table], + }); + return Number(row.rows[0]?.version ?? 0); + }, + catch: (cause) => new ScopeDbError({ message: "tableVersion failed", cause }), + }), + versions: () => + Effect.tryPromise({ + try: async () => { + await ensureVersionTable(); + const rows = await client.execute(`SELECT name, version FROM ${VERSION_TABLE}`); + const out = new Map(); + for (const r of rows.rows) out.set(String(r.name), Number(r.version)); + return out as ReadonlyMap; + }, + catch: (cause) => new ScopeDbError({ message: "versions failed", cause }), + }), + }; +}; + +export interface LibsqlScopeDbOptions { + /** Directory holding one SQLite file per scope, or ":memory:" for tests. */ + readonly root: string; +} + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { + const clients = new Map(); + const listeners = new Set<(event: ScopeWriteEvent) => void>(); + + const emit = (event: ScopeWriteEvent) => { + for (const listener of listeners) listener(event); + }; + + const clientFor = (scope: string): Client => { + let client = clients.get(scope); + if (!client) { + if (options.root === ":memory:") { + client = createClient({ url: ":memory:" }); + } else { + mkdirSync(options.root, { recursive: true }); + const safe = scope.replace(/[^a-zA-Z0-9._-]/g, "_"); + client = createClient({ url: toUrl(join(options.root, `${safe}.db`)) }); + } + clients.set(scope, client); + } + return client; + }; + + return { + forScope: (scope) => + Effect.try({ + try: () => makeHandle(scope, clientFor(scope), emit), + catch: (cause) => new ScopeDbError({ message: `failed to open scope db ${scope}`, cause }), + }), + onWrite: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close: () => + Effect.sync(() => { + for (const client of clients.values()) client.close(); + clients.clear(); + listeners.clear(); + }), + }; +}; diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts new file mode 100644 index 000000000..7e212214f --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { bundleEntry } from "../pipeline/bundle"; +import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; +import { ISSUES_SYNC_TS, SEARCH_ALL_MAIL_TS } from "../testing/daily-brief"; +import type { HandleBridge } from "../seams/tool-sandbox"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +describe("QuickJS ToolSandbox", () => { + const sandbox = makeQuickjsToolSandbox(); + + it("bundles + collects a tool descriptor (deterministic)", async () => { + const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); + const result = await run(sandbox.collect(code)); + const descriptor = result.artifacts.default.descriptor as { + kind: string; + connections: Record; + inputJsonSchema: { type: string; properties: Record }; + hasHandler: boolean; + }; + expect(descriptor.kind).toBe("tool"); + expect(descriptor.connections.github).toEqual( + expect.objectContaining({ decl: "single", integration: "github" }), + ); + expect(descriptor.hasHandler).toBe(true); + expect(descriptor.inputJsonSchema.type).toBe("object"); + expect(descriptor.inputJsonSchema.properties).toHaveProperty("repos"); + expect(descriptor.inputJsonSchema.properties).toHaveProperty("since"); + }); + + it("collects a fan-out (connections) declaration", async () => { + const files = new Map([["tools/search-all-mail.ts", SEARCH_ALL_MAIL_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/search-all-mail.ts" })); + const result = await run(sandbox.collect(code)); + const descriptor = result.artifacts.default.descriptor as { + connections: Record; + }; + expect(descriptor.connections.inboxes).toEqual( + expect.objectContaining({ decl: "array", integration: "gmail" }), + ); + }); + + it("invokes a tool, routing injected-client + db calls through the bridge", async () => { + const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); + const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); + + const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = []; + const bridge: HandleBridge = { + call: ({ root, path, args }) => + Effect.sync(() => { + calls.push({ root, path, args }); + const key = `${root}.${path.join(".")}`; + if (key === "github.repos.listForAuthenticatedUser") { + return [{ full_name: "acme/app" }]; + } + if (key === "github.issues.listForRepo") { + return [ + { + number: 1, + title: "Bug", + labels: [{ name: "bug" }], + assignee: { login: "rhys" }, + updated_at: "2026-01-01T00:00:00Z", + html_url: "https://github.com/acme/app/issues/1", + }, + ]; + } + // db.sql calls return empty rows + return []; + }), + }; + + const result = await run( + sandbox.invoke( + code, + { + artifact: "issues-sync", + kind: "tool", + input: {}, + roots: { github: { kind: "single" }, db: { kind: "single" } }, + }, + bridge, + ), + ); + + expect(result.output).toEqual({ synced: 1, repos: 1 }); + // The db write went through the bridge (root "db"). + expect(calls.some((c) => c.root === "db")).toBe(true); + expect(calls.some((c) => c.root === "github")).toBe(true); + }); + + it("rejects a non-deterministic descriptor (Math.random at describe time)", async () => { + const nondeterministic = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "x-" + Math.random(), + input: z.object({}), + async handler() { return {}; }, +});`; + const files = new Map([["tools/rng.ts", nondeterministic]]); + const { code } = await run(bundleEntry({ files, entry: "tools/rng.ts" })); + const exit = await Effect.runPromiseExit(sandbox.collect(code)); + expect(exit._tag).toBe("Failure"); + }); + + it("denies network (fetch throws in the sandbox)", async () => { + const usesFetch = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "fetches", + input: z.object({}), + async handler() { await fetch("https://example.com"); return {}; }, +});`; + const files = new Map([["tools/net.ts", usesFetch]]); + const { code } = await run(bundleEntry({ files, entry: "tools/net.ts" })); + const bridge: HandleBridge = { call: () => Effect.succeed(null) }; + const exit = await Effect.runPromiseExit( + sandbox.invoke(code, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), + ); + expect(exit._tag).toBe("Failure"); + }); +}); diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts new file mode 100644 index 000000000..dca33adbc --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts @@ -0,0 +1,252 @@ +import { Effect } from "effect"; +import type { SandboxToolInvoker } from "@executor-js/codemode-core"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import { + ToolSandboxError, + type CollectResult, + type HandleBridge, + type InvokeRequest, + type InvokeResult, + type ToolSandbox, +} from "../seams/tool-sandbox"; + +// --------------------------------------------------------------------------- +// QuickJS-backed ToolSandbox (self-hosted). +// +// The published bundle is a CJS string. The sandbox body prepends a `require` +// shim providing `executor:app` (defineTool/defineWorkflow/connection/ +// connections/catalog) and `executor:ui` stubs, then executes the bundle so +// `module.exports.default` is the artifact. A driver appended after the bundle +// either collects descriptors (nothing bound) or runs one handler with injected +// clients. +// +// Injected clients are Proxies that turn `github.issues.listForRepo(args)` into +// `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the +// QuickJS runtime already provides (`tools`/`__invokeTool`). Our +// `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`. +// Everything crossing is JSON (the cloud version is RPC), so the interface +// stays honest. +// +// Determinism: `collect` runs the collection body twice and byte-compares the +// descriptor JSON. Effectful top-levels (Math.random, Date.now) diverge and are +// rejected. QuickJS denies `fetch` and enforces a deadline + memory cap. +// --------------------------------------------------------------------------- + +const COLLECT_TIMEOUT_MS = 10_000; +const INVOKE_TIMEOUT_MS = 30_000; + +// The shim + module system injected before the bundle. Kept as a plain string +// (QuickJS evals a string). `defineTool`/`defineWorkflow` record their def so +// the driver can read it back. Clients are built by `__mkHandle`. +const runtimePrelude = ` +var __modules = {}; +var __defs = { tool: null, workflow: null, ui: null }; +function __recordDefault(mod) { return mod; } +var __handleBridge = function(root, path, args) { + // Route every injected-client method call through the single tool bridge. + return tools.__handle__({ root: root, path: path, args: args }); +}; +function __mkHandle(root, prefix) { + var target = function(){}; + return new Proxy(target, { + get: function(_t, prop) { + if (prop === 'then' || typeof prop === 'symbol') return undefined; + return __mkHandle(root, prefix.concat([String(prop)])); + }, + apply: function(_t, _this, callArgs) { + return __handleBridge(root, prefix, callArgs); + } + }); +} +var __executorApp = { + connection: function(integration, opts) { return { __decl: 'single', integration: integration, description: opts && opts.description }; }, + connections: function(integration, opts) { return { __decl: 'array', integration: integration, description: opts && opts.description }; }, + catalog: function() { return { __decl: 'catalog' }; }, + defineTool: function(def) { __defs.tool = def; return def; }, + defineWorkflow: function(def) { __defs.workflow = def; return def; }, +}; +var __executorUi = { + config: function(){ return undefined; }, + useQuery: function(){ return { data: [], isLoading: false, refetch: function(){} }; }, + useTool: function(){ return { run: function(){ return Promise.resolve(); }, isRunning: false }; }, +}; +function __require(id) { + if (id === 'executor:app') return __executorApp; + if (id === 'executor:ui') return __executorUi; + if (id === 'executor:ui/components') return {}; + throw new Error('module not available in sandbox: ' + id); +} +`; + +// Run the CJS bundle. The bundle's virtual entry sets `globalThis.__artifact` +// (the def object returned by define*), `globalThis.__zodToJson` and +// `__zodToJsonOutput` (bound to the author's inlined zod). `require` is our +// shim; `defineTool`/`defineWorkflow` also record into `__defs` as a fallback. +const wrapBundle = (bundle: string): string => ` +var module = { exports: {} }; +var exports = module.exports; +var require = __require; +(function(module, exports, require){ +${bundle} +})(module, exports, require); +`; + +// Collect driver: describe the artifact's connections + input/output schema. +// Deterministic JSON only — no handler execution. +const collectDriver = ` +return await (async () => { + var def = __defs.tool || __defs.workflow || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); + var kind = __defs.workflow ? 'workflow' : 'tool'; + var conns = {}; + if (def && def.connections) { + for (var k in def.connections) { + var c = def.connections[k]; + conns[k] = { decl: c && c.__decl ? c.__decl : 'single', integration: c && c.integration, description: c && c.description }; + } + } + var toJson = (typeof globalThis.__zodToJson === 'function') ? globalThis.__zodToJson : function(){ return undefined; }; + var toJsonOut = (typeof globalThis.__zodToJsonOutput === 'function') ? globalThis.__zodToJsonOutput : function(){ return undefined; }; + return { + kind: kind, + description: def && def.description, + connections: conns, + annotations: def && def.annotations, + schedule: def && def.schedule, + hasHandler: !!(def && (def.handler || def.run)), + inputJsonSchema: def ? toJson(def.input) : undefined, + outputJsonSchema: def ? toJsonOut(def.output) : undefined, + }; +})() +`; + +const buildCollectCode = (bundle: string): string => + runtimePrelude + wrapBundle(bundle) + collectDriver; + +// Invoke driver: build injected clients from the request roots, then call the +// artifact's handler with (input, injected). Fan-out arrays become arrays of +// element handles. +const buildInvokeDriver = (request: InvokeRequest): string => { + const rootsLiteral = JSON.stringify(request.roots); + const inputLiteral = JSON.stringify(request.input ?? {}); + return ` +return await (async () => { + var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); + if (!def || typeof def.handler !== 'function') throw new Error('artifact has no handler: ${request.artifact}'); + var roots = ${rootsLiteral}; + var injected = {}; + for (var name in roots) { + var spec = roots[name]; + if (spec.kind === 'array') { + var arr = []; + for (var i = 0; i < spec.count; i++) arr.push(__mkHandle(name + '#' + i, [])); + injected[name] = arr; + } else { + injected[name] = __mkHandle(name, []); + } + } + var out = await def.handler(${inputLiteral}, injected); + return out; +})() +`; +}; + +const buildInvokeCode = (bundle: string, request: InvokeRequest): string => + runtimePrelude + wrapBundle(bundle) + buildInvokeDriver(request); + +// A no-op invoker for collect: no handle calls should happen; if they do +// (misbehaving describe path), fail loudly. +const collectInvoker: SandboxToolInvoker = { + invoke: () => Effect.fail(new Error("collect must not make handle calls")) as never, +}; + +export interface QuickjsToolSandboxOptions { + readonly collectTimeoutMs?: number; + readonly invokeTimeoutMs?: number; +} + +export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): ToolSandbox => { + const collectExecutor = makeQuickJsExecutor({ + timeoutMs: options.collectTimeoutMs ?? COLLECT_TIMEOUT_MS, + }); + const invokeExecutor = makeQuickJsExecutor({ + timeoutMs: options.invokeTimeoutMs ?? INVOKE_TIMEOUT_MS, + }); + + const runCollect = (bundle: string): Effect.Effect => + collectExecutor.execute(buildCollectCode(bundle), collectInvoker).pipe( + Effect.mapError( + (cause) => new ToolSandboxError({ kind: "collect", message: "collect run failed", cause }), + ), + Effect.flatMap((result) => { + if (result.error) { + return Effect.fail(new ToolSandboxError({ kind: "collect", message: result.error })); + } + return Effect.succeed(result.result); + }), + ); + + return { + collect: (bundle: string) => + Effect.gen(function* () { + // Run twice, byte-compare (determinism gate). + const first = yield* runCollect(bundle); + const second = yield* runCollect(bundle); + const a = JSON.stringify(first); + const b = JSON.stringify(second); + if (a !== b) { + return yield* Effect.fail( + new ToolSandboxError({ + kind: "nondeterministic", + message: + "descriptor collection is non-deterministic (an artifact read Math.random/Date.now or otherwise diverged between runs)", + }), + ); + } + const descriptor = first as { kind?: string }; + const kind = descriptor?.kind === "workflow" ? "workflow" : "tool"; + const result: CollectResult = { + artifacts: { + [String((descriptor as { artifact?: string }).artifact ?? "default")]: { + kind, + descriptor: first, + }, + }, + }; + return result; + }), + + invoke: (bundle: string, request: InvokeRequest, bridge: HandleBridge) => + Effect.gen(function* () { + // The invoker decodes the routed handle call and forwards to the host + // bridge. Path 0 is `__handle__`; the single arg is {root, path, args}. + const invoker: SandboxToolInvoker = { + invoke: (input: { path: string; args: unknown }) => { + if (input.path !== "__handle__") { + return Effect.fail(new Error(`unexpected sandbox call: ${input.path}`)) as never; + } + const call = input.args as { + root: string; + path: readonly string[]; + args: readonly unknown[]; + }; + return bridge.call({ root: call.root, path: call.path, args: call.args }) as never; + }, + }; + const result = yield* invokeExecutor + .execute(buildInvokeCode(bundle, request), invoker) + .pipe( + Effect.mapError( + (cause) => + new ToolSandboxError({ kind: "invoke", message: "invoke run failed", cause }), + ), + ); + if (result.error) { + return yield* Effect.fail( + new ToolSandboxError({ kind: "invoke", message: result.error }), + ); + } + return { output: result.result, logs: result.logs ?? [] } satisfies InvokeResult; + }), + }; +}; diff --git a/packages/plugins/apps/src/pipeline/bundle.ts b/packages/plugins/apps/src/pipeline/bundle.ts new file mode 100644 index 000000000..6a871fdde --- /dev/null +++ b/packages/plugins/apps/src/pipeline/bundle.ts @@ -0,0 +1,212 @@ +import { build } from "esbuild"; + +import { Effect } from "effect"; + +import { ToolSandboxError } from "../seams/tool-sandbox"; + +// --------------------------------------------------------------------------- +// bundle — the FDI pipeline's bundle stage. +// +// One esbuild pass per artifact entry. Platform modules (`executor:app`, +// `executor:ui`, react, recharts, ...) stay EXTERNAL — the sandbox shim +// provides the real behavior. A small allowlist of schema libraries (`zod`, +// `@standard-schema/spec`) is INLINED from node_modules so the author's schema +// runs in the sandbox AND its `toJSONSchema` is reachable to extract the +// descriptor's input/output JSON Schema. Every other bare import is rejected +// with a diagnostic: npm deps in user bundles are out of scope. +// +// Output is a single CJS string with the externals left as `require(...)` +// calls the sandbox resolves. +// --------------------------------------------------------------------------- + +/** Modules the platform provides inside the sandbox (kept external, resolved by + * the sandbox `require` shim). */ +export const PLATFORM_MODULES = new Set([ + "executor:app", + "executor:ui", + "executor:ui/components", + "react", + "react/jsx-runtime", + "react-dom", + "recharts", + "lucide-react", +]); + +/** npm modules we deliberately inline (schema runtime that must run in the + * sandbox). Everything else bare is rejected. */ +export const INLINABLE_MODULES = new Set(["zod", "@standard-schema/spec"]); + +const isInlinable = (path: string): boolean => { + if (INLINABLE_MODULES.has(path)) return true; + // zod subpaths (`zod/v4`, `zod/mini`) are fine too. + return [...INLINABLE_MODULES].some((m) => path === m || path.startsWith(`${m}/`)); +}; + +export interface BundleInput { + readonly files: ReadonlyMap; + readonly entry: string; +} + +export interface BundleOutput { + readonly code: string; +} + +const resolveRelative = (base: string, rel: string): string => { + const parts = (base ? base.split("/") : []).concat(rel.split("/")); + const out: string[] = []; + for (const part of parts) { + if (part === "" || part === ".") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out.join("/"); +}; + +const candidates = (path: string): string[] => { + if (/\.(tsx?|jsx?)$/.test(path)) return [path]; + return [ + path, + `${path}.ts`, + `${path}.tsx`, + `${path}.js`, + `${path}.jsx`, + `${path}/index.ts`, + `${path}/index.tsx`, + ]; +}; + +const FILESET_NS = "fileset"; +const VIRTUAL_ENTRY = "executor-apps://entry"; + +/** The virtual entry esbuild starts from: re-export the author entry AND expose + * the shared zod instance as a global so the sandbox collect driver can call + * `toJSONSchema` against the SAME zod the author bundled. */ +const virtualEntrySource = (authorEntry: string) => ` +import __artifactModule from ${JSON.stringify(`/${authorEntry}`)}; +import * as __zod from "zod"; +globalThis.__artifact = __artifactModule; +globalThis.__zodToJson = function (schema) { + if (!schema) return undefined; + try { + return __zod.toJSONSchema(schema, { io: "input", unrepresentable: "any" }); + } catch (e) { + try { return __zod.toJSONSchema(schema); } catch (e2) { return {}; } + } +}; +globalThis.__zodToJsonOutput = function (schema) { + if (!schema) return undefined; + try { + return __zod.toJSONSchema(schema, { io: "output", unrepresentable: "any" }); + } catch (e) { + try { return __zod.toJSONSchema(schema); } catch (e2) { return {}; } + } +}; +`; + +const fileSetPlugin = (files: ReadonlyMap, authorEntry: string) => ({ + name: "executor-apps-fileset", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setup(build2: any) { + const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/\.\//g, "/"); + + build2.onResolve( + { filter: /.*/ }, + (args: { path: string; importer: string; namespace?: string; resolveDir?: string }) => { + if (args.path === VIRTUAL_ENTRY) { + return { path: VIRTUAL_ENTRY, namespace: "virtual" }; + } + // Imports originating OUTSIDE the file set (from inlined npm modules + // like zod, or the virtual entry's `import "zod"`) are left to + // esbuild's default node resolution. We only govern file-set imports. + const fromFileSet = args.namespace === FILESET_NS || args.namespace === "virtual"; + if (!fromFileSet) return undefined; + + if (PLATFORM_MODULES.has(args.path)) { + return { path: args.path, external: true }; + } + if (isInlinable(args.path)) { + // Inline from node_modules via default resolution. + return undefined; + } + // Absolute (virtual entry uses `/tools/x.ts`) or relative -> file set. + if (args.path.startsWith("/")) { + const joined = norm(args.path.slice(1)); + for (const candidate of candidates(joined)) { + if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS }; + } + return { errors: [{ text: `cannot resolve "${args.path}" in the app file set` }] }; + } + if (args.path.startsWith(".")) { + const base = args.importer.split("/").slice(0, -1).join("/"); + const joined = norm(resolveRelative(base, args.path)); + for (const candidate of candidates(joined)) { + if (files.has(candidate)) return { path: candidate, namespace: FILESET_NS }; + } + return { + errors: [ + { text: `cannot resolve "${args.path}" from "${args.importer}" in the app file set` }, + ], + }; + } + return { + errors: [ + { + text: `bare import "${args.path}" is not allowed: apps may only import platform modules (${[...PLATFORM_MODULES].join(", ")}), the schema runtime (zod), or files within the scope`, + }, + ], + }; + }, + ); + + build2.onLoad({ filter: /.*/, namespace: "virtual" }, () => ({ + contents: virtualEntrySource(authorEntry), + loader: "ts", + resolveDir: process.cwd(), + })); + + build2.onLoad({ filter: /.*/, namespace: FILESET_NS }, (args: { path: string }) => { + const contents = files.get(args.path); + if (contents === undefined) { + return { errors: [{ text: `missing file in set: ${args.path}` }] }; + } + const loader = args.path.endsWith(".tsx") + ? "tsx" + : args.path.endsWith(".ts") + ? "ts" + : args.path.endsWith(".jsx") + ? "jsx" + : "js"; + return { contents, loader, resolveDir: process.cwd() }; + }); + }, +}); + +/** Bundle one entry from the file set to a single CJS string. */ +export const bundleEntry = (input: BundleInput): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const result = await build({ + entryPoints: [VIRTUAL_ENTRY], + bundle: true, + write: false, + format: "cjs", + platform: "neutral", + target: "es2022", + minify: false, + treeShaking: true, + jsx: "automatic", + logLevel: "silent", + external: [...PLATFORM_MODULES], + plugins: [fileSetPlugin(input.files, input.entry) as never], + }); + const out = result.outputFiles?.[0]?.text; + if (out === undefined) throw new Error("esbuild produced no output"); + return { code: out }; + }, + catch: (cause) => + new ToolSandboxError({ + kind: "bundle", + message: `bundle failed for ${input.entry}: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); diff --git a/packages/plugins/apps/src/seams/artifact-store.ts b/packages/plugins/apps/src/seams/artifact-store.ts new file mode 100644 index 000000000..d79c9da70 --- /dev/null +++ b/packages/plugins/apps/src/seams/artifact-store.ts @@ -0,0 +1,60 @@ +import type { Effect } from "effect"; +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// ArtifactStore — the per-scope git-backed source store. +// +// A scope's source lives as a bare git repository; a publish writes a commit +// and the commit hash IS the snapshot id (immutable, content-addressed by git). +// The self-hosted backing is a bare git repo per scope on disk (git CLI via +// subprocess). The cloud backing (future) is Cloudflare Artifacts. The seam is +// substrate-neutral: read/write/list/latest/log over a flat file set, plus a +// snapshot immutability guarantee (a committed snapshot's bytes never change). +// --------------------------------------------------------------------------- + +/** A published snapshot id. In the git backing this is the commit hash. */ +export type SnapshotId = string & { readonly __snapshotId: unique symbol }; + +export const asSnapshotId = (value: string): SnapshotId => value as SnapshotId; + +/** A flat set of source files, path -> UTF-8 contents. Paths are POSIX, + * relative to the scope root (e.g. `tools/issues-sync.ts`). */ +export type FileSet = ReadonlyMap; + +export interface SnapshotMeta { + readonly id: SnapshotId; + readonly message: string; + /** Epoch ms the snapshot was written. */ + readonly committedAt: number; +} + +export class ArtifactStoreError extends Data.TaggedError("ArtifactStoreError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * One scope's source store. `commit` writes a new snapshot from a full file set + * and returns its id. `read` materializes a snapshot back to a file set. + * `latest` is the most recent snapshot on the default branch (null when the + * scope has never published). `log` lists snapshots newest-first. + */ +export interface ScopeArtifactStore { + readonly commit: ( + files: FileSet, + message: string, + ) => Effect.Effect; + readonly read: (id: SnapshotId) => Effect.Effect; + readonly readFile: ( + id: SnapshotId, + path: string, + ) => Effect.Effect; + readonly list: (id: SnapshotId) => Effect.Effect; + readonly latest: () => Effect.Effect; + readonly log: (limit?: number) => Effect.Effect; +} + +/** The substrate-neutral store: hands out a per-scope store by scope key. */ +export interface ArtifactStore { + readonly forScope: (scope: string) => Effect.Effect; +} diff --git a/packages/plugins/apps/src/seams/index.ts b/packages/plugins/apps/src/seams/index.ts new file mode 100644 index 000000000..5ba51377c --- /dev/null +++ b/packages/plugins/apps/src/seams/index.ts @@ -0,0 +1,8 @@ +// The five substrate-neutral seams. Self-hosted backings live under +// `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a +// backing must pass, keeping a future Cloudflare backing honest. +export * from "./artifact-store"; +export * from "./scope-db"; +export * from "./tool-sandbox"; +export * from "./workflow-runner"; +export * from "./live-channel"; diff --git a/packages/plugins/apps/src/seams/live-channel.ts b/packages/plugins/apps/src/seams/live-channel.ts new file mode 100644 index 000000000..22208d51b --- /dev/null +++ b/packages/plugins/apps/src/seams/live-channel.ts @@ -0,0 +1,36 @@ +import type { Effect } from "effect"; +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// LiveChannel — invalidation-only pub/sub for live UI data. +// +// The protocol is invalidation, never rows: a scope-db write bumps a table's +// version counter and publishes `{scope, table, version}`; a subscribed widget +// refetches through its authed query path. No row data ever crosses the channel +// (MCP Apps forbids server push through the host, so live data rides this side +// channel; but even here we only ship invalidations). The self-hosted backing +// is an in-process emitter exposed as SSE on the Bun server; the cloud backing +// (future) is the DO/facet socket owner. +// --------------------------------------------------------------------------- + +export class LiveChannelError extends Data.TaggedError("LiveChannelError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface Invalidation { + readonly scope: string; + readonly table: string; + readonly version: number; +} + +export interface LiveChannel { + /** Publish an invalidation to every subscriber of the scope. */ + readonly publish: (event: Invalidation) => Effect.Effect; + /** + * Subscribe to a scope's invalidations. The listener fires for each publish; + * the returned thunk unsubscribes. Delivery is best-effort and unordered + * beyond per-table version monotonicity. + */ + readonly subscribe: (scope: string, listener: (event: Invalidation) => void) => () => void; +} diff --git a/packages/plugins/apps/src/seams/scope-db.ts b/packages/plugins/apps/src/seams/scope-db.ts new file mode 100644 index 000000000..a23da5408 --- /dev/null +++ b/packages/plugins/apps/src/seams/scope-db.ts @@ -0,0 +1,58 @@ +import type { Effect } from "effect"; +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// ScopeDb — the per-scope app database (the shared primitive). +// +// One SQL database per scope: tools write it, workflows schedule tools, UI +// reads it, skills route the agent to it. The self-hosted backing is one libSQL +// (SQLite) file per scope; the cloud backing (future) is a Durable Object +// facet. The seam carries a template-tag `sql` (the author-facing shape) plus +// per-table version counters: every write bumps the touched table's version so +// the LiveChannel can publish an invalidation and widgets refetch. +// +// Scope isolation is structural: `forScope(a)` and `forScope(b)` are distinct +// databases; there is no cross-scope query path. +// --------------------------------------------------------------------------- + +export class ScopeDbError extends Data.TaggedError("ScopeDbError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** The author-facing db handle (matches `executor:app`'s `ScopeDb`): a tagged + * template returning rows. Also exposes the tables a statement touched so the + * runtime can bump version counters and publish invalidations. */ +export interface ScopeDbHandle { + readonly sql: >( + strings: TemplateStringsArray, + ...values: unknown[] + ) => Effect.Effect; + /** Run a raw statement (used by the runtime for probes/migrations). */ + readonly exec: >( + sql: string, + params?: readonly unknown[], + ) => Effect.Effect; + /** Current version counter for a table (0 if never written). */ + readonly tableVersion: (table: string) => Effect.Effect; + /** Snapshot of every tracked table's version. */ + readonly versions: () => Effect.Effect, ScopeDbError>; +} + +/** A write that touched tables, with the versions after the bump. Emitted so + * the runtime can drive `LiveChannel.publish`. */ +export interface ScopeWriteEvent { + readonly scope: string; + readonly tables: readonly { readonly table: string; readonly version: number }[]; +} + +export interface ScopeDb { + readonly forScope: (scope: string) => Effect.Effect; + /** + * Subscribe to writes across scopes (the runtime wires this into LiveChannel). + * Returns an unsubscribe thunk. Best-effort in-process; the cloud backing + * makes the storage owner the notifier. + */ + readonly onWrite: (listener: (event: ScopeWriteEvent) => void) => () => void; + readonly close: () => Effect.Effect; +} diff --git a/packages/plugins/apps/src/seams/tool-sandbox.ts b/packages/plugins/apps/src/seams/tool-sandbox.ts new file mode 100644 index 000000000..47be377c6 --- /dev/null +++ b/packages/plugins/apps/src/seams/tool-sandbox.ts @@ -0,0 +1,105 @@ +import type { Effect } from "effect"; +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// ToolSandbox — the isolated substrate that runs published bundles. +// +// Two operations, both over a bundled JS string (esbuild output, platform +// modules external, zod inlined): +// +// collect(bundle): import the bundle with NOTHING bound. `define*` calls +// return descriptors; a collector shim gathers them and returns JSON. This +// is how the publish pipeline extracts the versioned descriptor from source +// without ever running effectful code. Run twice + byte-compare = the +// determinism gate (a bundle that reads Math.random / Date.now at the top +// level or in a describe path diverges and is rejected). +// +// invoke(bundle, request): run one artifact's handler. The handler receives +// pre-bound clients whose method calls cross OUT through a serializable +// bridge (`HandleBridge.call`). EVERYTHING crossing the boundary is +// serializable — the cloud version of this seam is an RPC, so the interface +// forbids passing functions or live objects across. Fan-out arrays +// (`connections("gmail")` -> client[]) are modeled as indexed handle roots. +// +// The self-hosted backing is QuickJS (packages/kernel/runtime-quickjs), whose +// `SandboxToolInvoker.invoke({path, args})` already matches the bridge shape. +// The cloud backing (future) is Worker Loaders. The Deno subprocess kernel is +// the harder-isolation escalation behind this same seam. +// --------------------------------------------------------------------------- + +export class ToolSandboxError extends Data.TaggedError("ToolSandboxError")<{ + readonly message: string; + readonly kind: "collect" | "invoke" | "timeout" | "network" | "nondeterministic" | "bundle"; + readonly cause?: unknown; +}> {} + +/** + * The serializable bridge the sandbox calls out through. `root` names an + * injected handle (a connection role, `db`, or one element of a fan-out set); + * `path` is the method chain (`["events", "list"]`); `args` is the JSON call + * arguments. The return value is JSON. This is the ONE way sandboxed code + * reaches the host — nothing else is wired. + */ +export interface HandleBridge { + readonly call: (input: { + readonly root: string; + readonly path: readonly string[]; + readonly args: readonly unknown[]; + }) => Effect.Effect; +} + +/** Which handle roots to inject and, for fan-out roots, how many elements. A + * single connection role is `{ kind: "single" }`; a fan-out is + * `{ kind: "array", count }`; `db` is a single. Everything the handler can see + * is enumerated here — undeclared roots are simply absent. */ +export type HandleRootSpec = + | { readonly kind: "single" } + | { readonly kind: "array"; readonly count: number }; + +export interface InvokeRequest { + /** The artifact whose handler to run (path identity, e.g. `issues-sync`). */ + readonly artifact: string; + /** The kind selects the wrapper the sandbox uses to reach the handler. */ + readonly kind: "tool"; + /** JSON input passed to the handler. */ + readonly input: unknown; + /** The handle roots to inject, keyed by the name the handler destructures + * (`github`, `db`, `inboxes`). */ + readonly roots: Readonly>; +} + +export interface InvokeResult { + readonly output: unknown; + readonly logs: readonly string[]; +} + +/** A collected artifact descriptor — the JSON `define*` returns. The pipeline + * refines this into the versioned descriptor; the sandbox only guarantees it + * is deterministic JSON. */ +export interface CollectedArtifact { + readonly kind: "tool" | "workflow"; + readonly descriptor: unknown; +} + +export interface CollectResult { + /** Descriptors keyed by artifact path identity. */ + readonly artifacts: Readonly>; +} + +export interface ToolSandbox { + /** + * Import the bundle with nothing bound and gather every `define*` descriptor. + * Runs the collection twice internally and byte-compares; a mismatch fails + * with `kind: "nondeterministic"`. This is the determinism gate. + */ + readonly collect: (bundle: string) => Effect.Effect; + /** + * Run one artifact's handler with injected handles bridged through `bridge`. + * Network is denied; a per-call timeout kills a runaway handler. + */ + readonly invoke: ( + bundle: string, + request: InvokeRequest, + bridge: HandleBridge, + ) => Effect.Effect; +} diff --git a/packages/plugins/apps/src/seams/workflow-runner.ts b/packages/plugins/apps/src/seams/workflow-runner.ts new file mode 100644 index 000000000..0f2220080 --- /dev/null +++ b/packages/plugins/apps/src/seams/workflow-runner.ts @@ -0,0 +1,139 @@ +import type { Effect } from "effect"; +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// WorkflowRunner — durable workflow execution with an event-sourced journal. +// +// Cloudflare Workflows semantics verbatim: `step.do(name, fn)` memoized by the +// journal, `step.sleep`, `step.waitForEvent`, plus executor's `step.tool` +// (journal + audit in one). Kill mid-step -> restart -> completed steps do NOT +// re-execute (replay reads the journal). The self-hosted backing is a SQLite +// journal replay runner + in-process scheduler; the cloud backing (future) is +// CF Workflows + dynamic-workflows. The journal shape is modeled on +// vercel/workflow's World Storage contract (append-only events, materialized +// run/step views). +// +// The runner is substrate-neutral: it takes a `StepExecutor` (how to run a +// named step body / tool call for THIS run) so the sandbox/tool wiring stays +// outside the durable core. Runs pin the snapshot that started them. +// --------------------------------------------------------------------------- + +export class WorkflowError extends Data.TaggedError("WorkflowError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export type RunStatus = "running" | "sleeping" | "waiting" | "completed" | "failed" | "cancelled"; + +export interface RunView { + readonly runId: string; + readonly scope: string; + readonly workflow: string; + readonly snapshotId: string; + readonly status: RunStatus; + readonly input: unknown; + readonly output?: unknown; + readonly error?: string; + readonly createdAt: number; + readonly updatedAt: number; +} + +export type StepStatus = "completed" | "failed"; + +export interface StepView { + readonly runId: string; + readonly name: string; + readonly status: StepStatus; + readonly output?: unknown; + readonly error?: string; + readonly attempt: number; + readonly completedAt: number; +} + +/** How the runner executes step bodies for a specific run. The durable core + * drives this; the caller supplies it (bound to the run's snapshot + scope + + * the tool invoke path). Each method is only ever called for a step that has + * NOT completed in the journal (the runner checks the journal first). */ +export interface StepExecutor { + /** Run a `step.do` body identified by name; return its JSON result. */ + readonly runStep: (name: string) => Effect.Effect; + /** Run a `step.tool` call (address + args); return the tool's JSON result. + * Journaled AND audited as a tool run by the caller's implementation. */ + readonly runTool: ( + name: string, + address: string, + args: unknown, + ) => Effect.Effect; + /** Deliver a `step.notify`. */ + readonly notify: (msg: { + readonly title: string; + readonly body?: string; + readonly link?: string; + }) => Effect.Effect; +} + +/** The workflow body, expressed against the durable step API. The runner + * replays it: completed steps resolve from the journal, the first incomplete + * step actually executes. `sleep`/`waitForEvent` suspend the run. */ +export interface DurableSteps { + readonly do: (name: string, fn: () => Promise | T) => Promise; + readonly tool: (address: string, args: Record) => Promise; + readonly sleep: (name: string, ms: number) => Promise; + readonly waitForEvent: (name: string, opts?: { timeout?: number }) => Promise; + readonly notify: (msg: { title: string; body?: string; link?: string }) => Promise; +} + +export interface StartRunInput { + readonly scope: string; + readonly workflow: string; + readonly snapshotId: string; + readonly input: unknown; + /** Optional caller-supplied run id (idempotent starts / scheduler dedupe). */ + readonly runId?: string; +} + +/** + * The durable runner. `start` creates a run and drives it to its first + * suspension or completion. `resume` re-drives a suspended/interrupted run from + * the journal (this is what makes the kill test pass: completed steps replay + * from the journal and never re-execute). `signal` delivers a waitForEvent + * payload. `get`/`listSteps` are the queryable status/history. + * + * `run` is the workflow body supplied by the caller (bound to the snapshot's + * compiled bundle via `execute`). The runner calls it with a `DurableSteps` + * that consults the journal. + */ +export interface WorkflowRunner { + readonly start: ( + input: StartRunInput, + execute: (steps: DurableSteps) => Promise, + ) => Effect.Effect; + readonly resume: ( + runId: string, + execute: (steps: DurableSteps) => Promise, + ) => Effect.Effect; + readonly signal: ( + runId: string, + eventName: string, + payload: unknown, + execute: (steps: DurableSteps) => Promise, + ) => Effect.Effect; + readonly cancel: (runId: string) => Effect.Effect; + readonly get: (runId: string) => Effect.Effect; + readonly list: (filter?: { + readonly scope?: string; + readonly workflow?: string; + }) => Effect.Effect; + readonly listSteps: (runId: string) => Effect.Effect; + readonly close: () => Effect.Effect; +} + +/** Control errors that steer retry/replay, matching CF's vocabulary. */ +export class FatalError extends Data.TaggedError("FatalError")<{ + readonly message: string; +}> {} + +export class RetryableError extends Data.TaggedError("RetryableError")<{ + readonly message: string; + readonly retryAfter?: number; +}> {} diff --git a/packages/plugins/apps/src/testing/daily-brief.ts b/packages/plugins/apps/src/testing/daily-brief.ts new file mode 100644 index 000000000..7df1f5c36 --- /dev/null +++ b/packages/plugins/apps/src/testing/daily-brief.ts @@ -0,0 +1,197 @@ +// --------------------------------------------------------------------------- +// The daily-brief fixture set, as an in-memory file set (path -> contents). +// +// Adapted from prototypes/executor-artifacts to be executable against the +// implemented contract: `tools/issues-sync.ts` writes the scope `issues` table, +// `workflows/morning-sync.ts` schedules it and flags stale issues, +// `ui/dashboard.tsx` reads the table, `skills/issues-brief/SKILL.md` routes the +// agent. These are payloads (author code), not package source, so they live as +// strings rather than compiled files. +// --------------------------------------------------------------------------- + +export const DAILY_BRIEF_MANIFEST = `{ + "$schema": "https://executor.sh/schemas/scope-manifest.json", + "scope": "rhys", + "description": "Personal scope artifacts — GitHub issues brief.", + "connections": { "github": "github/rhys" }, + "artifacts": { "skills": "skills/", "tools": "tools/", "workflows": "workflows/", "ui": "ui/" } +} +`; + +export const ISSUES_SYNC_TS = `import { z } from "zod"; +import { defineTool, connection } from "executor:app"; + +export default defineTool({ + description: + "Refresh the scope \\\`issues\\\` table from GitHub. Syncs open issues across the given repos (default: every repo the connection can see).", + connections: { + github: connection("github", { description: "GitHub account whose issues to sync" }), + }, + input: z.object({ + repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"), + since: z.string().optional().describe("ISO timestamp — only issues updated after this"), + }), + output: z.object({ synced: z.number(), repos: z.number() }), + annotations: { readOnly: false, destructive: false }, + async handler({ repos, since }, { github, db }) { + const targets = + repos ?? + (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name); + + await db.sql\` + CREATE TABLE IF NOT EXISTS issues ( + repo TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', assignee TEXT, updated_at TEXT NOT NULL, url TEXT NOT NULL, + PRIMARY KEY (repo, number) + )\`; + + let synced = 0; + for (const target of targets) { + const [owner, repo] = target.split("/"); + const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 }); + for (const issue of issues) { + if (issue.pull_request) continue; + await db.sql\` + INSERT INTO issues (repo, number, title, labels, assignee, updated_at, url) + VALUES (\${target}, \${issue.number}, \${issue.title}, + \${JSON.stringify(issue.labels.map((l) => l.name))}, + \${issue.assignee?.login ?? null}, \${issue.updated_at}, \${issue.html_url}) + ON CONFLICT (repo, number) DO UPDATE SET + title = excluded.title, labels = excluded.labels, + assignee = excluded.assignee, updated_at = excluded.updated_at\`; + synced++; + } + } + return { synced, repos: targets.length }; + }, +}); +`; + +export const SEARCH_ALL_MAIL_TS = `import { z } from "zod"; +import { defineTool, connections } from "executor:app"; + +export default defineTool({ + description: + "Search across all connected Gmail accounts. Returns matches newest-first, tagged with which inbox they came from.", + connections: { + inboxes: connections("gmail", { description: "Gmail accounts to search across" }), + }, + input: z.object({ + query: z.string().describe("Gmail search syntax, e.g. from:acme subject:invoice"), + limit: z.number().int().min(1).max(100).default(25), + }), + output: z.object({ + results: z.array(z.object({ + inbox: z.string(), id: z.string(), from: z.string(), + subject: z.string(), snippet: z.string(), date: z.string(), + })), + }), + annotations: { readOnly: true }, + async handler({ query, limit }, { inboxes }) { + const perInbox = await Promise.all( + inboxes.map(async (inbox) => { + const { messages } = await inbox.messages.search({ q: query, maxResults: limit }); + return messages.map((m) => ({ + inbox: inbox.account.email, id: m.id, from: m.from, + subject: m.subject, snippet: m.snippet, date: m.date, + })); + }), + ); + const results = perInbox.flat().sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit); + return { results }; + }, +}); +`; + +export const MORNING_SYNC_TS = `import { defineWorkflow } from "executor:app"; + +export default defineWorkflow({ + description: "Refresh GitHub issues at 9am and flag stale ones.", + schedule: { cron: "0 9 * * 1-5", timezone: "America/New_York" }, + async run(step, { db }) { + const { synced } = await step.tool("issues-sync", {}); + const stale = await step.do("find-stale", () => + db.sql\` + SELECT repo, number, title FROM issues + WHERE updated_at < datetime('now', '-14 days') + ORDER BY updated_at ASC\`, + ); + if (stale.length > 0) { + await step.notify({ + title: \`\${stale.length} issues stale >14 days\`, + body: stale.slice(0, 5).map((i) => \`\${i.repo}#\${i.number} — \${i.title}\`).join("\\n"), + link: "ui://dashboard", + }); + } + return { synced, stale: stale.length }; + }, +}); +`; + +export const DASHBOARD_TSX = `import { useQuery, useTool, config } from "executor:ui"; +import { Card, CardContent, CardHeader, CardTitle, Badge, Button, Input } from "executor:ui/components"; +import { useState } from "react"; + +config({ maxHeight: 720, title: "GitHub Issues" }); + +export default function App() { + const [filter, setFilter] = useState(""); + const issues = useQuery((db) => db.sql\`SELECT * FROM issues ORDER BY updated_at DESC\`); + const sync = useTool("issues-sync"); + if (issues.isLoading) return Loading issues…; + const rows = issues.data.filter( + (i) => !filter || i.repo.includes(filter) || i.title.toLowerCase().includes(filter.toLowerCase()), + ); + return ( + + ); +} +`; + +export const ISSUES_BRIEF_SKILL = `--- +name: issues-brief +description: Answer questions about open GitHub issues from the scope \`issues\` database instead of paging the GitHub API; sync first if stale. Use when the user asks about their issues, the issue backlog, stale issues, or anything GitHub-issue related. +--- + +# GitHub issues brief + +The scope database has an \`issues\` table maintained by the \`issues-sync\` +tool and refreshed weekday mornings by the \`morning-sync\` workflow. Answer +issue questions from this table — do not page the GitHub API directly. + +## Recipe + +1. Check freshness: \`SELECT MAX(updated_at) FROM issues\`. If older than ~24h, call \`issues-sync\` first. +2. Query the table for what was asked. +3. For anything visual, open the authored dashboard: \`ui://dashboard\`. +`; + +/** The daily-brief file set as a path -> contents map. */ +export const dailyBriefFileSet = (): Map => + new Map([ + ["executor.json", DAILY_BRIEF_MANIFEST], + ["tools/issues-sync.ts", ISSUES_SYNC_TS], + ["tools/search-all-mail.ts", SEARCH_ALL_MAIL_TS], + ["workflows/morning-sync.ts", MORNING_SYNC_TS], + ["ui/dashboard.tsx", DASHBOARD_TSX], + ["skills/issues-brief/SKILL.md", ISSUES_BRIEF_SKILL], + ]); diff --git a/packages/plugins/apps/tsconfig.json b/packages/plugins/apps/tsconfig.json new file mode 100644 index 000000000..1504bed72 --- /dev/null +++ b/packages/plugins/apps/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/plugins/apps/tsup.config.ts b/packages/plugins/apps/tsup.config.ts new file mode 100644 index 000000000..3a8a57e4c --- /dev/null +++ b/packages/plugins/apps/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + api: "src/api.ts", + "seams/index": "src/seams/index.ts", + "testing/index": "src/testing/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//, /^@modelcontextprotocol\//, "esbuild"], +}); diff --git a/packages/plugins/apps/vitest.config.ts b/packages/plugins/apps/vitest.config.ts new file mode 100644 index 000000000..698a11896 --- /dev/null +++ b/packages/plugins/apps/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); From 2cbaf08c03b639447c6c8f3a80f68aa5482593cd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:21:04 -0700 Subject: [PATCH 02/23] apps: workflow runner with journal replay, conformance suites, and SIGKILL kill test --- .../src/backing/git-artifact-store.test.ts | 10 + .../apps/src/backing/git-artifact-store.ts | 10 +- .../apps/src/backing/libsql-scope-db.test.ts | 9 + .../quickjs-tool-sandbox.conformance.test.ts | 7 + .../sqlite-workflow-runner.kill.test.ts | 69 +++ .../backing/sqlite-workflow-runner.test.ts | 8 + .../src/backing/sqlite-workflow-runner.ts | 448 ++++++++++++++++++ .../src/seams/artifact-store.conformance.ts | 91 ++++ .../apps/src/seams/scope-db.conformance.ts | 112 +++++ .../src/seams/tool-sandbox.conformance.ts | 116 +++++ .../src/seams/workflow-runner.conformance.ts | 137 ++++++ .../plugins/apps/src/seams/workflow-runner.ts | 39 +- .../plugins/apps/src/testing/kill-child.ts | 71 +++ 13 files changed, 1100 insertions(+), 27 deletions(-) create mode 100644 packages/plugins/apps/src/backing/git-artifact-store.test.ts create mode 100644 packages/plugins/apps/src/backing/libsql-scope-db.test.ts create mode 100644 packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts create mode 100644 packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts create mode 100644 packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts create mode 100644 packages/plugins/apps/src/backing/sqlite-workflow-runner.ts create mode 100644 packages/plugins/apps/src/seams/artifact-store.conformance.ts create mode 100644 packages/plugins/apps/src/seams/scope-db.conformance.ts create mode 100644 packages/plugins/apps/src/seams/tool-sandbox.conformance.ts create mode 100644 packages/plugins/apps/src/seams/workflow-runner.conformance.ts create mode 100644 packages/plugins/apps/src/testing/kill-child.ts diff --git a/packages/plugins/apps/src/backing/git-artifact-store.test.ts b/packages/plugins/apps/src/backing/git-artifact-store.test.ts new file mode 100644 index 000000000..e1a434fa4 --- /dev/null +++ b/packages/plugins/apps/src/backing/git-artifact-store.test.ts @@ -0,0 +1,10 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { artifactStoreConformance } from "../seams/artifact-store.conformance"; +import { makeGitArtifactStore } from "./git-artifact-store"; + +artifactStoreConformance("git", () => + makeGitArtifactStore({ root: mkdtempSync(join(tmpdir(), "apps-artifacts-")) }), +); diff --git a/packages/plugins/apps/src/backing/git-artifact-store.ts b/packages/plugins/apps/src/backing/git-artifact-store.ts index 3eaf18f50..5636c5317 100644 --- a/packages/plugins/apps/src/backing/git-artifact-store.ts +++ b/packages/plugins/apps/src/backing/git-artifact-store.ts @@ -29,7 +29,7 @@ const run = ( args: readonly string[], input?: string | Buffer, ): Effect.Effect => - Effect.async((resume) => { + Effect.callback((resume) => { const child = execFile( "git", args, @@ -76,7 +76,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { const headCommit = (): Effect.Effect => run(repoDir, ["rev-parse", "--verify", "--quiet", BRANCH]).pipe( Effect.map((out) => out.trim() || null), - Effect.catchAll(() => Effect.succeed(null)), + Effect.catch(() => Effect.succeed(null)), ); const readMeta = (id: string): Effect.Effect => @@ -101,7 +101,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { `commit-index-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); const withIndex = (args: readonly string[], input?: string | Buffer) => - Effect.async((resume) => { + Effect.callback((resume) => { const child = execFile( "git", args, @@ -151,7 +151,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { GIT_COMMITTER_NAME: "executor-apps", GIT_COMMITTER_EMAIL: "apps@executor.local", }; - const commitHash = (yield* Effect.async((resume) => { + const commitHash = (yield* Effect.callback((resume) => { const child = execFile( "git", commitArgs, @@ -198,7 +198,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { readFile: (id: SnapshotId, path: string) => run(repoDir, ["cat-file", "blob", `${id}:${path}`]).pipe( Effect.map((contents) => contents as string | null), - Effect.catchAll(() => Effect.succeed(null)), + Effect.catch(() => Effect.succeed(null)), ), list: (id: SnapshotId) => diff --git a/packages/plugins/apps/src/backing/libsql-scope-db.test.ts b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts new file mode 100644 index 000000000..6e79cf695 --- /dev/null +++ b/packages/plugins/apps/src/backing/libsql-scope-db.test.ts @@ -0,0 +1,9 @@ +import { scopeDbConformance } from "../seams/scope-db.conformance"; +import { makeLibsqlScopeDb } from "./libsql-scope-db"; +import { makeInProcessLiveChannel } from "./in-process-live-channel"; + +scopeDbConformance( + "libsql (in-memory)", + () => makeLibsqlScopeDb({ root: ":memory:" }), + () => makeInProcessLiveChannel(), +); diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts new file mode 100644 index 000000000..53e0a9a66 --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.conformance.test.ts @@ -0,0 +1,7 @@ +import { toolSandboxConformance } from "../seams/tool-sandbox.conformance"; +import { makeQuickjsToolSandbox } from "./quickjs-tool-sandbox"; + +// Short timeout so the "kills a runaway handler" case finishes quickly. +toolSandboxConformance("quickjs", () => + makeQuickjsToolSandbox({ collectTimeoutMs: 5_000, invokeTimeoutMs: 2_000 }), +); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts new file mode 100644 index 000000000..01f6986a8 --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.kill.test.ts @@ -0,0 +1,69 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +// --------------------------------------------------------------------------- +// The REAL kill test: SIGKILL a child process mid-step, restart over the same +// data dir, and prove the completed step did NOT re-execute. A side-effect file +// written exactly once is the assertion. +// +// Phase 1 (child): start the run; step "write-once" appends one line to the +// marker file, then step "hang" blocks forever. When the child prints "HUNG" +// the side effect has landed and it is stuck in a NOT-journaled step. We +// SIGKILL it there. +// +// Phase 2 (child): resume the run over the same journal DB. "write-once" is +// journaled so it replays without re-running; the run completes. +// +// Assertion: the marker file has exactly ONE line. +// --------------------------------------------------------------------------- + +const CHILD = join(import.meta.dirname, "..", "testing", "kill-child.ts"); + +const runChild = ( + phase: string, + dbPath: string, + markerPath: string, + waitForHung: boolean, +): Promise<{ code: number | null; killed: boolean; stdout: string }> => + new Promise((resolve) => { + const child = spawn("bun", [CHILD, phase, dbPath, markerPath], { + stdio: ["ignore", "pipe", "inherit"], + }); + let stdout = ""; + let killed = false; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (waitForHung && stdout.includes("HUNG") && !killed) { + killed = true; + child.kill("SIGKILL"); + } + }); + child.on("exit", (code) => resolve({ code, killed, stdout })); + }); + +describe("WorkflowRunner kill test (SIGKILL mid-step, restart, no double-execute)", () => { + it("runs a completed step exactly once across a kill+restart", async () => { + const dir = mkdtempSync(join(tmpdir(), "apps-kill-")); + const dbPath = join(dir, "journal.db"); + const markerPath = join(dir, "marker.txt"); + + // Phase 1: start, land the side effect, get killed mid-"hang". + const phase1 = await runChild("1", dbPath, markerPath, true); + expect(phase1.killed).toBe(true); + expect(existsSync(markerPath)).toBe(true); + const afterKill = readFileSync(markerPath, "utf8").trim().split("\n").filter(Boolean); + expect(afterKill.length).toBe(1); // side effect happened once before the kill + + // Phase 2: resume over the SAME db; completed step must NOT re-run. + const phase2 = await runChild("2", dbPath, markerPath, false); + expect(phase2.code).toBe(0); + expect(phase2.stdout).toContain("STATUS:completed"); + + const finalLines = readFileSync(markerPath, "utf8").trim().split("\n").filter(Boolean); + expect(finalLines.length).toBe(1); // STILL exactly one line: no double-execute + }, 60_000); +}); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts new file mode 100644 index 000000000..41ec2e10d --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts @@ -0,0 +1,8 @@ +import { workflowRunnerConformance } from "../seams/workflow-runner.conformance"; +import { makeSqliteWorkflowRunner } from "./sqlite-workflow-runner"; + +// A synthetic monotonic clock so sleep-based suspension is deterministic. +let t = 1_000_000; +workflowRunnerConformance("sqlite (in-memory)", () => + makeSqliteWorkflowRunner({ path: ":memory:", clock: () => (t += 1) }), +); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts new file mode 100644 index 000000000..64b1ddc28 --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts @@ -0,0 +1,448 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import { + FatalError, + RetryableError, + WorkflowError, + type DurableSteps, + type RunView, + type StartRunInput, + type StepView, + type WorkflowBindings, + type WorkflowRunner, +} from "../seams/workflow-runner"; + +// --------------------------------------------------------------------------- +// SQLite journal replay WorkflowRunner (self-hosted). +// +// An append-only event journal in SQLite backs materialized run/step views +// (modeled on vercel/workflow's World Storage contract). The workflow body runs +// via `execute(steps)`; each `steps.do/tool/sleep/waitForEvent` FIRST consults +// the journal: +// - a completed step replays its recorded result WITHOUT re-executing +// - the first not-yet-recorded step actually executes, appends its result +// - `sleep`/`waitForEvent` throw a typed Suspend that unwinds the body; the +// run is marked sleeping/waiting and `resume`/`signal` re-drives it later +// +// This is what makes the kill test pass: SIGKILL mid-step -> restart over the +// same DB file -> `resume` replays completed steps from the journal and only +// the interrupted (never-recorded) step runs again. A side-effect from a +// COMPLETED step happens exactly once. +// +// `step.tool` and `step.notify` reach the outside world through the +// caller-supplied `WorkflowBindings`, bound to the run's snapshot + scope + the +// real tool-invoke/audit path. +// --------------------------------------------------------------------------- + +const nowMs = () => Date.now(); + +/** Control-flow signal to unwind the body at a sleep/wait boundary. Caught by + * the runner; never seen by the author. */ +class Suspend { + constructor( + readonly reason: "sleep" | "wait", + readonly wakeAt?: number, + readonly eventName?: string, + ) {} +} + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS wf_run ( + run_id TEXT PRIMARY KEY, scope TEXT NOT NULL, workflow TEXT NOT NULL, + snapshot_id TEXT NOT NULL, status TEXT NOT NULL, input TEXT NOT NULL, + output TEXT, error TEXT, wake_at INTEGER, wait_event TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS wf_event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, seq INTEGER NOT NULL, + type TEXT NOT NULL, step_name TEXT, data TEXT, created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS wf_event_run ON wf_event (run_id, seq); +CREATE TABLE IF NOT EXISTS wf_step ( + run_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, + output TEXT, error TEXT, attempt INTEGER NOT NULL DEFAULT 1, completed_at INTEGER NOT NULL, + PRIMARY KEY (run_id, name) +); +CREATE TABLE IF NOT EXISTS wf_signal ( + run_id TEXT NOT NULL, event_name TEXT NOT NULL, payload TEXT, + PRIMARY KEY (run_id, event_name) +); +`; + +interface StepRecord { + status: "completed" | "failed"; + output?: unknown; + error?: string; + attempt: number; +} + +export interface SqliteWorkflowRunnerOptions { + /** Journal DB path, or ":memory:". A file path is what the kill test needs. */ + readonly path: string; + /** Injected clock for deterministic sleep in tests. */ + readonly clock?: () => number; +} + +export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): WorkflowRunner => { + if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true }); + const client: Client = createClient({ url: toUrl(options.path) }); + const clock = options.clock ?? nowMs; + let ready: Promise | undefined; + + const init = async () => { + if (!ready) { + ready = (async () => { + for (const stmt of SCHEMA.split(";")) { + const s = stmt.trim(); + if (s) await client.execute(s); + } + })(); + } + return ready; + }; + + const loadRun = async (runId: string): Promise => { + await init(); + const res = await client.execute({ + sql: "SELECT * FROM wf_run WHERE run_id = ?", + args: [runId], + }); + const row = res.rows[0]; + if (!row) return null; + return { + runId: String(row.run_id), + scope: String(row.scope), + workflow: String(row.workflow), + snapshotId: String(row.snapshot_id), + status: String(row.status) as RunView["status"], + input: JSON.parse(String(row.input)), + output: row.output != null ? JSON.parse(String(row.output)) : undefined, + error: row.error != null ? String(row.error) : undefined, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; + }; + + const loadSteps = async (runId: string): Promise> => { + const res = await client.execute({ + sql: "SELECT name, status, output, error, attempt FROM wf_step WHERE run_id = ?", + args: [runId], + }); + const map = new Map(); + for (const r of res.rows) { + map.set(String(r.name), { + status: String(r.status) as StepRecord["status"], + output: r.output != null ? JSON.parse(String(r.output)) : undefined, + error: r.error != null ? String(r.error) : undefined, + attempt: Number(r.attempt), + }); + } + return map; + }; + + const nextSeq = async (runId: string): Promise => { + const res = await client.execute({ + sql: "SELECT COALESCE(MAX(seq), 0) AS m FROM wf_event WHERE run_id = ?", + args: [runId], + }); + return Number(res.rows[0]?.m ?? 0) + 1; + }; + + const appendEvent = async ( + runId: string, + type: string, + stepName: string | null, + data: unknown, + ): Promise => { + const seq = await nextSeq(runId); + await client.execute({ + sql: "INSERT INTO wf_event (run_id, seq, type, step_name, data, created_at) VALUES (?, ?, ?, ?, ?, ?)", + args: [runId, seq, type, stepName, data === undefined ? null : JSON.stringify(data), clock()], + }); + }; + + const recordStep = async (runId: string, name: string, record: StepRecord): Promise => { + await client.execute({ + sql: `INSERT INTO wf_step (run_id, name, status, output, error, attempt, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id, name) DO UPDATE SET status=excluded.status, output=excluded.output, + error=excluded.error, attempt=excluded.attempt, completed_at=excluded.completed_at`, + args: [ + runId, + name, + record.status, + record.output === undefined ? null : JSON.stringify(record.output), + record.error ?? null, + record.attempt, + clock(), + ], + }); + await appendEvent(runId, `step_${record.status}`, name, record.output ?? record.error); + }; + + const setRunStatus = async ( + runId: string, + status: RunView["status"], + extra: { output?: unknown; error?: string; wakeAt?: number; waitEvent?: string } = {}, + ): Promise => { + await client.execute({ + sql: "UPDATE wf_run SET status = ?, output = ?, error = ?, wake_at = ?, wait_event = ?, updated_at = ? WHERE run_id = ?", + args: [ + status, + extra.output === undefined ? null : JSON.stringify(extra.output), + extra.error ?? null, + extra.wakeAt ?? null, + extra.waitEvent ?? null, + clock(), + runId, + ], + }); + await appendEvent(runId, `run_${status}`, null, extra.output ?? extra.error); + }; + + const makeSteps = ( + runId: string, + journaled: Map, + bindings: WorkflowBindings, + ): DurableSteps => { + const replayOrRun = async (name: string, exec: () => Promise): Promise => { + const existing = journaled.get(name); + if (existing) { + if (existing.status === "failed") { + throw new FatalError({ message: existing.error ?? `step ${name} failed` }); + } + return existing.output as T; + } + let output: T; + try { + output = await exec(); + } catch (cause) { + if (cause instanceof Suspend) throw cause; + const message = cause instanceof Error ? cause.message : String(cause); + const record: StepRecord = { status: "failed", error: message, attempt: 1 }; + journaled.set(name, record); + await recordStep(runId, name, record); + throw new FatalError({ message }); + } + const record: StepRecord = { status: "completed", output, attempt: 1 }; + journaled.set(name, record); + await recordStep(runId, name, record); + return output; + }; + + return { + do: (name: string, fn: () => Promise | T) => + replayOrRun(name, async () => (await fn()) as T), + tool: (address: string, args: Record) => + replayOrRun(`tool:${address}`, () => bindings.runTool(address, args) as Promise), + sleep: async (name: string, ms: number) => { + if (journaled.get(`sleep:${name}`)) return; + const wakeAt = clock() + ms; + const record: StepRecord = { status: "completed", output: { wakeAt }, attempt: 1 }; + journaled.set(`sleep:${name}`, record); + await recordStep(runId, `sleep:${name}`, record); + throw new Suspend("sleep", wakeAt); + }, + waitForEvent: async (name: string) => { + const delivered = journaled.get(`wait:${name}`); + if (delivered) return delivered.output as T; + const sig = await client.execute({ + sql: "SELECT payload FROM wf_signal WHERE run_id = ? AND event_name = ?", + args: [runId, name], + }); + if (sig.rows[0]) { + const payload = + sig.rows[0].payload != null ? JSON.parse(String(sig.rows[0].payload)) : undefined; + const record: StepRecord = { status: "completed", output: payload, attempt: 1 }; + journaled.set(`wait:${name}`, record); + await recordStep(runId, `wait:${name}`, record); + return payload as T; + } + throw new Suspend("wait", undefined, name); + }, + notify: async (msg: { title: string; body?: string; link?: string }) => { + await replayOrRun(`notify:${msg.title}`, async () => { + await bindings.notify(msg); + return { notified: true }; + }); + }, + }; + }; + + const drive = ( + runId: string, + execute: (steps: DurableSteps) => Promise, + bindings: WorkflowBindings, + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + await init(); + const journaled = await loadSteps(runId); + const steps = makeSteps(runId, journaled, bindings); + try { + const output = await execute(steps); + await setRunStatus(runId, "completed", { output }); + } catch (cause) { + if (cause instanceof Suspend) { + if (cause.reason === "sleep") { + await setRunStatus(runId, "sleeping", { wakeAt: cause.wakeAt }); + } else { + await setRunStatus(runId, "waiting", { waitEvent: cause.eventName }); + } + } else if (cause instanceof RetryableError) { + await setRunStatus(runId, "running"); + } else { + const message = cause instanceof Error ? cause.message : String(cause); + await setRunStatus(runId, "failed", { error: message }); + } + } + return (await loadRun(runId))!; + }, + catch: (cause) => new WorkflowError({ message: "workflow drive failed", cause }), + }); + + const resumeImpl = ( + runId: string, + execute: (steps: DurableSteps) => Promise, + bindings: WorkflowBindings, + ): Effect.Effect => + Effect.tryPromise({ + try: () => loadRun(runId), + catch: (cause) => new WorkflowError({ message: "resume load failed", cause }), + }).pipe( + Effect.flatMap((view) => { + if (!view) return Effect.fail(new WorkflowError({ message: `no run ${runId}` })); + if ( + view.status === "completed" || + view.status === "failed" || + view.status === "cancelled" + ) { + return Effect.succeed(view); + } + return Effect.tryPromise({ + try: async () => { + await setRunStatus(runId, "running"); + }, + catch: (cause) => new WorkflowError({ message: "resume set-running failed", cause }), + }).pipe(Effect.flatMap(() => drive(runId, execute, bindings))); + }), + ); + + return { + start: (input: StartRunInput, execute, bindings) => + Effect.tryPromise({ + try: async () => { + await init(); + const runId = input.runId ?? `run-${clock()}-${Math.random().toString(36).slice(2)}`; + const existing = await loadRun(runId); + if (existing) return runId; + const ts = clock(); + await client.execute({ + sql: `INSERT INTO wf_run (run_id, scope, workflow, snapshot_id, status, input, created_at, updated_at) + VALUES (?, ?, ?, ?, 'running', ?, ?, ?)`, + args: [ + runId, + input.scope, + input.workflow, + input.snapshotId, + JSON.stringify(input.input ?? {}), + ts, + ts, + ], + }); + await appendEvent(runId, "run_created", null, input.input ?? {}); + return runId; + }, + catch: (cause) => new WorkflowError({ message: "start failed", cause }), + }).pipe(Effect.flatMap((runId) => drive(runId, execute, bindings))), + + resume: (runId, execute, bindings) => resumeImpl(runId, execute, bindings), + + signal: (runId, eventName, payload, execute, bindings) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: `INSERT INTO wf_signal (run_id, event_name, payload) VALUES (?, ?, ?) + ON CONFLICT(run_id, event_name) DO UPDATE SET payload = excluded.payload`, + args: [runId, eventName, payload === undefined ? null : JSON.stringify(payload)], + }); + await appendEvent(runId, "signal", eventName, payload); + }, + catch: (cause) => new WorkflowError({ message: "signal failed", cause }), + }).pipe(Effect.flatMap(() => resumeImpl(runId, execute, bindings))), + + cancel: (runId) => + Effect.tryPromise({ + try: async () => { + await setRunStatus(runId, "cancelled"); + return (await loadRun(runId))!; + }, + catch: (cause) => new WorkflowError({ message: "cancel failed", cause }), + }), + + get: (runId) => + Effect.tryPromise({ + try: () => loadRun(runId), + catch: (cause) => new WorkflowError({ message: "get failed", cause }), + }), + + list: (filter) => + Effect.tryPromise({ + try: async () => { + await init(); + const clauses: string[] = []; + const args: unknown[] = []; + if (filter?.scope) { + clauses.push("scope = ?"); + args.push(filter.scope); + } + if (filter?.workflow) { + clauses.push("workflow = ?"); + args.push(filter.workflow); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const res = await client.execute({ + sql: `SELECT run_id FROM wf_run ${where} ORDER BY created_at DESC`, + args: args as never, + }); + const views: RunView[] = []; + for (const r of res.rows) { + const v = await loadRun(String(r.run_id)); + if (v) views.push(v); + } + return views as readonly RunView[]; + }, + catch: (cause) => new WorkflowError({ message: "list failed", cause }), + }), + + listSteps: (runId) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT name, status, output, error, attempt, completed_at FROM wf_step WHERE run_id = ? ORDER BY completed_at ASC, name ASC", + args: [runId], + }); + return res.rows.map((r) => ({ + runId, + name: String(r.name), + status: String(r.status) as StepView["status"], + output: r.output != null ? JSON.parse(String(r.output)) : undefined, + error: r.error != null ? String(r.error) : undefined, + attempt: Number(r.attempt), + completedAt: Number(r.completed_at), + })) as readonly StepView[]; + }, + catch: (cause) => new WorkflowError({ message: "listSteps failed", cause }), + }), + + close: () => Effect.sync(() => client.close()), + }; +}; diff --git a/packages/plugins/apps/src/seams/artifact-store.conformance.ts b/packages/plugins/apps/src/seams/artifact-store.conformance.ts new file mode 100644 index 000000000..e3738ebdd --- /dev/null +++ b/packages/plugins/apps/src/seams/artifact-store.conformance.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import type { ArtifactStore, FileSet } from "./artifact-store"; + +// --------------------------------------------------------------------------- +// ArtifactStore conformance suite. Runs against the INTERFACE, not a specific +// backing — pass a factory that yields a fresh store. Any future backing +// (Cloudflare Artifacts) must pass this same suite. Covers: round-trip +// (write a file set, read it back identical), snapshot immutability (a second +// publish does not change the first snapshot's bytes), latest/log ordering. +// --------------------------------------------------------------------------- + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +const fileSet = (entries: Record): FileSet => new Map(Object.entries(entries)); + +export const artifactStoreConformance = ( + name: string, + makeStore: () => Promise | ArtifactStore, +): void => { + describe(`ArtifactStore conformance: ${name}`, () => { + it("round-trips a file set through a snapshot", async () => { + const store = await makeStore(); + const scope = await run(store.forScope("s1")); + const files = fileSet({ + "tools/a.ts": "export const a = 1;\n", + "skills/x/SKILL.md": "# x\n", + }); + const meta = await run(scope.commit(files, "publish 1")); + expect(meta.id).toBeTruthy(); + expect(meta.message).toBe("publish 1"); + + const readBack = await run(scope.read(meta.id)); + expect(readBack.get("tools/a.ts")).toBe("export const a = 1;\n"); + expect(readBack.get("skills/x/SKILL.md")).toBe("# x\n"); + + const paths = await run(scope.list(meta.id)); + expect([...paths].sort()).toEqual(["skills/x/SKILL.md", "tools/a.ts"]); + + const one = await run(scope.readFile(meta.id, "tools/a.ts")); + expect(one).toBe("export const a = 1;\n"); + const missing = await run(scope.readFile(meta.id, "tools/missing.ts")); + expect(missing).toBeNull(); + }); + + it("keeps a snapshot immutable across a later publish", async () => { + const store = await makeStore(); + const scope = await run(store.forScope("s2")); + const first = await run(scope.commit(fileSet({ "tools/a.ts": "v1" }), "first")); + const second = await run( + scope.commit(fileSet({ "tools/a.ts": "v2", "tools/b.ts": "new" }), "second"), + ); + + expect(second.id).not.toBe(first.id); + // The first snapshot still reads its original bytes. + const firstFiles = await run(scope.read(first.id)); + expect(firstFiles.get("tools/a.ts")).toBe("v1"); + expect(firstFiles.has("tools/b.ts")).toBe(false); + // The second snapshot has the new bytes. + const secondFiles = await run(scope.read(second.id)); + expect(secondFiles.get("tools/a.ts")).toBe("v2"); + expect(secondFiles.get("tools/b.ts")).toBe("new"); + }); + + it("tracks latest and logs newest-first", async () => { + const store = await makeStore(); + const scope = await run(store.forScope("s3")); + expect(await run(scope.latest())).toBeNull(); + + const a = await run(scope.commit(fileSet({ "tools/a.ts": "1" }), "a")); + const b = await run(scope.commit(fileSet({ "tools/a.ts": "2" }), "b")); + + const latest = await run(scope.latest()); + expect(latest?.id).toBe(b.id); + + const log = await run(scope.log()); + expect(log[0].id).toBe(b.id); + expect(log[1].id).toBe(a.id); + expect(log.map((m) => m.message)).toEqual(["b", "a"]); + }); + + it("isolates scopes", async () => { + const store = await makeStore(); + const s1 = await run(store.forScope("iso-1")); + const s2 = await run(store.forScope("iso-2")); + await run(s1.commit(fileSet({ "tools/a.ts": "one" }), "one")); + expect(await run(s2.latest())).toBeNull(); + }); + }); +}; diff --git a/packages/plugins/apps/src/seams/scope-db.conformance.ts b/packages/plugins/apps/src/seams/scope-db.conformance.ts new file mode 100644 index 000000000..61ba91c27 --- /dev/null +++ b/packages/plugins/apps/src/seams/scope-db.conformance.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import type { ScopeDb, ScopeWriteEvent } from "./scope-db"; +import type { LiveChannel, Invalidation } from "./live-channel"; + +// --------------------------------------------------------------------------- +// ScopeDb conformance suite (+ LiveChannel delivery). Runs against the +// interface. Covers: scope isolation (a write in scope A is invisible in scope +// B), per-table version bumps on write, and end-to-end invalidation delivery +// when the ScopeDb's write events are wired into a LiveChannel. +// --------------------------------------------------------------------------- + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +export const scopeDbConformance = ( + name: string, + makeDb: () => Promise | ScopeDb, + makeChannel: () => LiveChannel, +): void => { + describe(`ScopeDb conformance: ${name}`, () => { + it("isolates data between scopes", async () => { + const db = await makeDb(); + const a = await run(db.forScope("scope-a")); + const b = await run(db.forScope("scope-b")); + await run(a.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")); + await run(a.exec("INSERT INTO items (name) VALUES ('a-only')")); + await run(b.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")); + + const aRows = await run(a.exec<{ name: string }>("SELECT name FROM items")); + const bRows = await run(b.exec<{ name: string }>("SELECT name FROM items")); + expect(aRows.map((r) => r.name)).toEqual(["a-only"]); + expect(bRows).toEqual([]); + await run(db.close()); + }); + + it("bumps a table's version on each write", async () => { + const db = await makeDb(); + const s = await run(db.forScope("ver")); + await run(s.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)")); + const afterCreate = await run(s.tableVersion("t")); + expect(afterCreate).toBeGreaterThanOrEqual(1); + + await run(s.exec("INSERT INTO t (id) VALUES (1)")); + const afterInsert = await run(s.tableVersion("t")); + expect(afterInsert).toBe(afterCreate + 1); + + await run(s.exec("UPDATE t SET id = 2 WHERE id = 1")); + const afterUpdate = await run(s.tableVersion("t")); + expect(afterUpdate).toBe(afterInsert + 1); + + // Reads do not bump. + await run(s.exec("SELECT * FROM t")); + expect(await run(s.tableVersion("t"))).toBe(afterUpdate); + await run(db.close()); + }); + + it("supports the author-facing tagged-template sql with parameters", async () => { + const db = await makeDb(); + const s = await run(db.forScope("tpl")); + await run(s.exec("CREATE TABLE issues (repo TEXT, title TEXT)")); + const repo = "acme/app"; + const title = "Bug"; + await run(s.sql`INSERT INTO issues (repo, title) VALUES (${repo}, ${title})`); + const rows = await run( + s.sql<{ repo: string; title: string }>`SELECT * FROM issues WHERE repo = ${repo}`, + ); + expect(rows).toEqual([{ repo: "acme/app", title: "Bug" }]); + await run(db.close()); + }); + + it("delivers invalidations through a wired LiveChannel", async () => { + const db = await makeDb(); + const channel = makeChannel(); + // Wire ScopeDb writes -> LiveChannel invalidations (what the runtime does). + const unwireDb = db.onWrite((event: ScopeWriteEvent) => { + for (const t of event.tables) { + void Effect.runPromise( + channel.publish({ scope: event.scope, table: t.table, version: t.version }), + ); + } + }); + + const received: Invalidation[] = []; + const unsub = channel.subscribe("live", (event) => received.push(event)); + + const s = await run(db.forScope("live")); + await run(s.exec("CREATE TABLE things (id INTEGER PRIMARY KEY)")); + await run(s.exec("INSERT INTO things (id) VALUES (1)")); + + // Give any async publish a tick. + await new Promise((r) => setTimeout(r, 20)); + + const thingsEvents = received.filter((e) => e.table === "things"); + expect(thingsEvents.length).toBeGreaterThanOrEqual(2); + expect(thingsEvents.at(-1)?.scope).toBe("live"); + expect(thingsEvents.at(-1)?.version).toBeGreaterThanOrEqual(2); + + // A different scope's subscriber sees nothing. + const otherReceived: Invalidation[] = []; + const unsubOther = channel.subscribe("other-scope", (e) => otherReceived.push(e)); + await run((await run(db.forScope("live"))).exec("INSERT INTO things (id) VALUES (2)")); + await new Promise((r) => setTimeout(r, 20)); + expect(otherReceived).toEqual([]); + + unsub(); + unsubOther(); + unwireDb(); + await run(db.close()); + }); + }); +}; diff --git a/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts b/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts new file mode 100644 index 000000000..de4d2b9ab --- /dev/null +++ b/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import type { HandleBridge, ToolSandbox } from "./tool-sandbox"; +import { bundleEntry } from "../pipeline/bundle"; + +// --------------------------------------------------------------------------- +// ToolSandbox conformance suite. Runs against the interface. Covers: +// - collect determinism catches Math.random (double-run byte-compare) +// - network denial (fetch throws) +// - timeout kill (an infinite loop is interrupted) +// - handle bridge round-trip including fan-out arrays (connections("x")) +// A future Worker Loaders backing must pass this same suite. +// --------------------------------------------------------------------------- + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +const bundle = (entry: string, source: string): Promise => + run(bundleEntry({ files: new Map([[entry, source]]), entry })).then((b) => b.code); + +export const toolSandboxConformance = (name: string, makeSandbox: () => ToolSandbox): void => { + describe(`ToolSandbox conformance: ${name}`, () => { + it("collects deterministically and rejects Math.random at describe time", async () => { + const sandbox = makeSandbox(); + const stable = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ description: "stable", input: z.object({ a: z.string() }), async handler(){ return {}; } });`; + const stableBundle = await bundle("tools/s.ts", stable); + const res = await run(sandbox.collect(stableBundle)); + expect(res.artifacts.default.descriptor).toBeTruthy(); + + const rng = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ description: "x" + Math.random(), input: z.object({}), async handler(){ return {}; } });`; + const rngBundle = await bundle("tools/rng.ts", rng); + const exit = await Effect.runPromiseExit(sandbox.collect(rngBundle)); + expect(exit._tag).toBe("Failure"); + }); + + it("denies network access", async () => { + const sandbox = makeSandbox(); + const src = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ description: "net", input: z.object({}), async handler(){ await fetch("https://x.test"); return {}; } });`; + const b = await bundle("tools/net.ts", src); + const bridge: HandleBridge = { call: () => Effect.succeed(null) }; + const exit = await Effect.runPromiseExit( + sandbox.invoke(b, { artifact: "net", kind: "tool", input: {}, roots: {} }, bridge), + ); + expect(exit._tag).toBe("Failure"); + }); + + it("kills a runaway handler on timeout", async () => { + const sandbox = makeSandbox(); + const src = `import { defineTool } from "executor:app"; +import { z } from "zod"; +export default defineTool({ description: "loop", input: z.object({}), async handler(){ while (true) {} } });`; + const b = await bundle("tools/loop.ts", src); + const bridge: HandleBridge = { call: () => Effect.succeed(null) }; + const exit = await Effect.runPromiseExit( + sandbox.invoke(b, { artifact: "loop", kind: "tool", input: {}, roots: {} }, bridge), + ); + expect(exit._tag).toBe("Failure"); + }); + + it("round-trips the handle bridge, including fan-out arrays", async () => { + const sandbox = makeSandbox(); + // A tool that fans out over an array of clients and calls a method on each. + const src = `import { defineTool, connections } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "fanout", + connections: { inboxes: connections("gmail") }, + input: z.object({ q: z.string() }), + async handler({ q }, { inboxes }) { + const per = await Promise.all(inboxes.map(async (inbox, i) => { + const r = await inbox.messages.search({ q, index: i }); + return r.count; + })); + return { total: per.reduce((a, b) => a + b, 0), n: inboxes.length }; + }, +});`; + const b = await bundle("tools/fanout.ts", src); + + const seen: { root: string; path: readonly string[]; args: readonly unknown[] }[] = []; + const bridge: HandleBridge = { + call: ({ root, path, args }) => + Effect.sync(() => { + seen.push({ root, path, args }); + return { count: 3 }; + }), + }; + + const result = await run( + sandbox.invoke( + b, + { + artifact: "fanout", + kind: "tool", + input: { q: "invoice" }, + roots: { inboxes: { kind: "array", count: 2 } }, + }, + bridge, + ), + ); + expect(result.output).toEqual({ total: 6, n: 2 }); + // Two distinct fan-out roots were addressed (inboxes#0 and inboxes#1). + const roots = new Set(seen.map((s) => s.root)); + expect(roots.has("inboxes#0")).toBe(true); + expect(roots.has("inboxes#1")).toBe(true); + // The method path and JSON args crossed the boundary intact. + expect(seen[0].path).toEqual(["messages", "search"]); + expect(seen[0].args[0]).toMatchObject({ q: "invoice" }); + }); + }); +}; diff --git a/packages/plugins/apps/src/seams/workflow-runner.conformance.ts b/packages/plugins/apps/src/seams/workflow-runner.conformance.ts new file mode 100644 index 000000000..6d78232fd --- /dev/null +++ b/packages/plugins/apps/src/seams/workflow-runner.conformance.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { + RetryableError, + type DurableSteps, + type WorkflowBindings, + type WorkflowRunner, +} from "./workflow-runner"; + +// --------------------------------------------------------------------------- +// WorkflowRunner conformance suite. Runs against the interface. Covers: +// - step memoization (a step body runs exactly once across replays) +// - sleep suspends then resumes past the sleep +// - waitForEvent suspends; signal delivers and resumes +// - retry semantics (RetryableError leaves the run re-drivable) +// The real SIGKILL kill test lives in its own file (needs a child process). +// --------------------------------------------------------------------------- + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +const noBindings: WorkflowBindings = { + runTool: async () => ({}), + notify: async () => {}, +}; + +export const workflowRunnerConformance = (name: string, makeRunner: () => WorkflowRunner): void => { + describe(`WorkflowRunner conformance: ${name}`, () => { + it("memoizes a step: replay does not re-execute it", async () => { + const runner = makeRunner(); + let sideEffects = 0; + const body = async (steps: DurableSteps) => { + const a = await steps.do("a", async () => { + sideEffects++; + return 10; + }); + // A sleep suspends after step a; on resume, a must NOT re-run. + await steps.sleep("nap", 1); + const b = await steps.do("b", async () => a + 5); + return { a, b }; + }; + + const started = await run( + runner.start( + { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "r1" }, + body, + noBindings, + ), + ); + expect(started.status).toBe("sleeping"); + expect(sideEffects).toBe(1); + + const resumed = await run(runner.resume("r1", body, noBindings)); + expect(resumed.status).toBe("completed"); + expect(resumed.output).toEqual({ a: 10, b: 15 }); + // Step "a" ran exactly once despite the resume. + expect(sideEffects).toBe(1); + await run(runner.close()); + }); + + it("suspends on waitForEvent and resumes on signal with the payload", async () => { + const runner = makeRunner(); + const body = async (steps: DurableSteps) => { + const approval = await steps.waitForEvent<{ ok: boolean }>("approval"); + return { approved: approval.ok }; + }; + const started = await run( + runner.start( + { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rw" }, + body, + noBindings, + ), + ); + expect(started.status).toBe("waiting"); + + const done = await run(runner.signal("rw", "approval", { ok: true }, body, noBindings)); + expect(done.status).toBe("completed"); + expect(done.output).toEqual({ approved: true }); + await run(runner.close()); + }); + + it("runs step.tool through the bindings and journals it", async () => { + const runner = makeRunner(); + const calls: { address: string; args: unknown }[] = []; + const bindings: WorkflowBindings = { + runTool: async (address, args) => { + calls.push({ address, args }); + return { synced: 3 }; + }, + notify: async () => {}, + }; + const body = async (steps: DurableSteps) => { + const r = await steps.tool<{ synced: number }>("issues-sync", {}); + return { synced: r.synced }; + }; + const done = await run( + runner.start( + { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rt" }, + body, + bindings, + ), + ); + expect(done.status).toBe("completed"); + expect(done.output).toEqual({ synced: 3 }); + expect(calls).toEqual([{ address: "issues-sync", args: {} }]); + + const steps = await run(runner.listSteps("rt")); + expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( + true, + ); + await run(runner.close()); + }); + + it("leaves the run re-drivable on RetryableError", async () => { + const runner = makeRunner(); + let attempts = 0; + const body = async (steps: DurableSteps) => { + await steps.do("stable", async () => "ok"); + attempts++; + if (attempts < 2) throw new RetryableError({ message: "flaky" }); + return { attempts }; + }; + const first = await run( + runner.start( + { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rr" }, + body, + noBindings, + ), + ); + expect(first.status).toBe("running"); + const second = await run(runner.resume("rr", body, noBindings)); + expect(second.status).toBe("completed"); + expect(second.output).toEqual({ attempts: 2 }); + await run(runner.close()); + }); + }); +}; diff --git a/packages/plugins/apps/src/seams/workflow-runner.ts b/packages/plugins/apps/src/seams/workflow-runner.ts index 0f2220080..570ea1bb6 100644 --- a/packages/plugins/apps/src/seams/workflow-runner.ts +++ b/packages/plugins/apps/src/seams/workflow-runner.ts @@ -50,28 +50,6 @@ export interface StepView { readonly completedAt: number; } -/** How the runner executes step bodies for a specific run. The durable core - * drives this; the caller supplies it (bound to the run's snapshot + scope + - * the tool invoke path). Each method is only ever called for a step that has - * NOT completed in the journal (the runner checks the journal first). */ -export interface StepExecutor { - /** Run a `step.do` body identified by name; return its JSON result. */ - readonly runStep: (name: string) => Effect.Effect; - /** Run a `step.tool` call (address + args); return the tool's JSON result. - * Journaled AND audited as a tool run by the caller's implementation. */ - readonly runTool: ( - name: string, - address: string, - args: unknown, - ) => Effect.Effect; - /** Deliver a `step.notify`. */ - readonly notify: (msg: { - readonly title: string; - readonly body?: string; - readonly link?: string; - }) => Effect.Effect; -} - /** The workflow body, expressed against the durable step API. The runner * replays it: completed steps resolve from the journal, the first incomplete * step actually executes. `sleep`/`waitForEvent` suspend the run. */ @@ -83,6 +61,20 @@ export interface DurableSteps { readonly notify: (msg: { title: string; body?: string; link?: string }) => Promise; } +/** How `step.tool` and `step.notify` reach the outside world for a specific + * run. The caller (the plugin) supplies these bound to the run's snapshot + + * scope + the real tool-invoke/audit path. The runner only calls them for a + * step that has NOT completed in the journal (checked first), so they run at + * most once per step across replays. Everything they take/return is JSON. */ +export interface WorkflowBindings { + readonly runTool: (address: string, args: unknown) => Promise; + readonly notify: (msg: { + readonly title: string; + readonly body?: string; + readonly link?: string; + }) => Promise; +} + export interface StartRunInput { readonly scope: string; readonly workflow: string; @@ -107,16 +99,19 @@ export interface WorkflowRunner { readonly start: ( input: StartRunInput, execute: (steps: DurableSteps) => Promise, + bindings: WorkflowBindings, ) => Effect.Effect; readonly resume: ( runId: string, execute: (steps: DurableSteps) => Promise, + bindings: WorkflowBindings, ) => Effect.Effect; readonly signal: ( runId: string, eventName: string, payload: unknown, execute: (steps: DurableSteps) => Promise, + bindings: WorkflowBindings, ) => Effect.Effect; readonly cancel: (runId: string) => Effect.Effect; readonly get: (runId: string) => Effect.Effect; diff --git a/packages/plugins/apps/src/testing/kill-child.ts b/packages/plugins/apps/src/testing/kill-child.ts new file mode 100644 index 000000000..c276a7846 --- /dev/null +++ b/packages/plugins/apps/src/testing/kill-child.ts @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// Kill-test child harness. Invoked as a subprocess by +// sqlite-workflow-runner.kill.test.ts. It runs the SAME workflow body over the +// SAME journal DB across two phases: +// +// phase=1 start the run. Step "write-once" appends one line to the marker +// file (the exactly-once side effect), then step "hang" blocks +// forever. The parent SIGKILLs this process while "hang" is running, +// so "hang" is never journaled. +// phase=2 resume the run over the same DB. "write-once" is journaled -> +// replays WITHOUT re-running (no second marker line). "hang" is now +// fast and completes the run. +// +// Assertion (in the parent): the marker file has exactly ONE line after both +// phases — the completed step's side effect happened exactly once despite the +// kill+restart. +// --------------------------------------------------------------------------- + +import { appendFileSync } from "node:fs"; + +import { Effect } from "effect"; + +import { makeSqliteWorkflowRunner } from "../backing/sqlite-workflow-runner"; +import type { DurableSteps, WorkflowBindings } from "../seams/workflow-runner"; + +const [, , phase, dbPath, markerPath] = process.argv; + +const bindings: WorkflowBindings = { + runTool: async () => ({}), + notify: async () => {}, +}; + +const body = async (steps: DurableSteps) => { + // The exactly-once side effect: appended only when the step actually runs. + await steps.do("write-once", async () => { + appendFileSync(markerPath, "ran\n"); + return { wrote: true }; + }); + if (phase === "1") { + // Hang forever inside a NOT-yet-journaled step; the parent kills us here. + // Signal readiness first so the parent knows the side effect happened. + process.stdout.write("HUNG\n"); + await steps.do("hang", async () => { + await new Promise(() => { + /* never resolves */ + }); + return {}; + }); + } + return { done: true }; +}; + +const runner = makeSqliteWorkflowRunner({ path: dbPath }); + +const main = async () => { + if (phase === "1") { + await Effect.runPromise( + runner.start( + { scope: "s", workflow: "kill-wf", snapshotId: "snap", input: {}, runId: "kill-run" }, + body, + bindings, + ), + ); + } else { + const view = await Effect.runPromise(runner.resume("kill-run", body, bindings)); + process.stdout.write(`STATUS:${view.status}\n`); + await Effect.runPromise(runner.close()); + } +}; + +void main(); From 7a7133d6161e448ffd626cca7d4b0ec5ab68cadc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:24:23 -0700 Subject: [PATCH 03/23] apps: FDI publish pipeline (discover, bundle, collect, project) with descriptor extraction --- .../plugins/apps/src/pipeline/descriptor.ts | 71 +++++ .../plugins/apps/src/pipeline/discover.ts | 125 +++++++++ .../plugins/apps/src/pipeline/publish.test.ts | 94 +++++++ packages/plugins/apps/src/pipeline/publish.ts | 242 ++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 packages/plugins/apps/src/pipeline/descriptor.ts create mode 100644 packages/plugins/apps/src/pipeline/discover.ts create mode 100644 packages/plugins/apps/src/pipeline/publish.test.ts create mode 100644 packages/plugins/apps/src/pipeline/publish.ts diff --git a/packages/plugins/apps/src/pipeline/descriptor.ts b/packages/plugins/apps/src/pipeline/descriptor.ts new file mode 100644 index 000000000..bdf6bb48d --- /dev/null +++ b/packages/plugins/apps/src/pipeline/descriptor.ts @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// The versioned app descriptor — the single source the pipeline extracts from +// source at publish. Catalog rows, schedules, ui resources and the skills index +// are all PROJECTIONS of this (FDI: publish is the compiler; nothing is +// hand-written). Identity is the file path throughout — there are no authored +// `name`/`id` fields (skills carry a spec-mandated frontmatter `name` we +// validate == dir name, but identity is still the path). +// --------------------------------------------------------------------------- + +/** Descriptor schema version. Bumped on any breaking shape change. */ +export const DESCRIPTOR_VERSION = 1 as const; + +export type ConnectionDecl = + | { readonly kind: "single"; readonly integration: string; readonly description?: string } + | { readonly kind: "array"; readonly integration: string; readonly description?: string } + | { readonly kind: "catalog" }; + +export interface ToolDescriptor { + /** Path identity, e.g. `issues-sync` (from `tools/issues-sync.ts`). */ + readonly name: string; + readonly sourcePath: string; + readonly description: string; + /** role -> connection declaration, collected from `connections:`. */ + readonly connections: Readonly>; + readonly inputSchema?: unknown; + readonly outputSchema?: unknown; + readonly annotations?: { + readonly readOnly?: boolean; + readonly destructive?: boolean; + readonly requiresApproval?: boolean; + }; +} + +export interface WorkflowDescriptor { + readonly name: string; + readonly sourcePath: string; + readonly description: string; + readonly connections: Readonly>; + readonly schedule?: { readonly cron: string; readonly timezone?: string }; +} + +export interface UiDescriptor { + readonly name: string; + readonly sourcePath: string; + /** Compiled browser bundle content hash (blob key). */ + readonly bundleHash: string; + readonly title?: string; + readonly maxHeight?: number; +} + +export interface SkillDescriptor { + /** Directory name == frontmatter `name` (validated at publish). */ + readonly name: string; + readonly sourcePath: string; + readonly description: string; + /** Full SKILL.md body (blob key). */ + readonly bodyHash: string; +} + +export interface AppDescriptor { + readonly version: typeof DESCRIPTOR_VERSION; + readonly scope: string; + /** The snapshot (commit hash) this descriptor was extracted from. */ + readonly snapshotId: string; + readonly tools: readonly ToolDescriptor[]; + readonly workflows: readonly WorkflowDescriptor[]; + readonly ui: readonly UiDescriptor[]; + readonly skills: readonly SkillDescriptor[]; + /** Shared JSON-schema `$defs` reachable from tool schemas. */ + readonly definitions?: Record; +} diff --git a/packages/plugins/apps/src/pipeline/discover.ts b/packages/plugins/apps/src/pipeline/discover.ts new file mode 100644 index 000000000..a7e5f3992 --- /dev/null +++ b/packages/plugins/apps/src/pipeline/discover.ts @@ -0,0 +1,125 @@ +import { Data } from "effect"; + +// --------------------------------------------------------------------------- +// discover — the pipeline's first stage: a filesystem-shape validation over the +// flat scope layout, with ZERO imports (eve's discover/compile split). It gives +// instant diagnostics before any bundling. The flat layout is: +// tools/.ts workflows/.ts ui/.tsx +// skills//SKILL.md +// Identity is the path: `tools/issues-sync.ts` -> tool `issues-sync`. +// --------------------------------------------------------------------------- + +export interface FileDiagnostic { + readonly path: string; + readonly message: string; +} + +/** Typed publish failure carrying per-file diagnostics. Nothing is persisted on + * a failed publish (the caller aborts the transaction). */ +export class PublishError extends Data.TaggedError("PublishError")<{ + readonly message: string; + readonly stage: "discover" | "bundle" | "collect" | "project"; + readonly diagnostics: readonly FileDiagnostic[]; +}> {} + +export type ArtifactKind = "tool" | "workflow" | "ui" | "skill"; + +export interface DiscoveredArtifact { + readonly kind: ArtifactKind; + /** Path identity (e.g. `issues-sync`). For skills this is the dir name. */ + readonly name: string; + /** The entry file path in the set (e.g. `tools/issues-sync.ts`, or + * `skills/issues-brief/SKILL.md`). */ + readonly entry: string; +} + +export interface DiscoverResult { + readonly artifacts: readonly DiscoveredArtifact[]; +} + +const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; +const WORKFLOW_RE = /^workflows\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/; +const UI_RE = /^ui\/([a-z0-9][a-z0-9-]*)\.(tsx|ts|jsx|js)$/; +const SKILL_RE = /^skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md$/; + +// Parse `name:` / `description:` from a SKILL.md frontmatter block. +const parseFrontmatter = (body: string): Record => { + const match = body.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const out: Record = {}; + for (const line of match[1].split("\n")) { + const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (m) out[m[1]] = m[2].trim(); + } + return out; +}; + +/** Validate the flat layout and enumerate artifacts. Pure over the file set; + * imports nothing. Skills' frontmatter `name` must equal the directory name + * (agentskills.io spec-mandated redundancy, validated here). */ +export const discover = (files: ReadonlyMap): DiscoverResult | PublishError => { + const diagnostics: FileDiagnostic[] = []; + const artifacts: DiscoveredArtifact[] = []; + const seen = new Set(); + + for (const [path, contents] of files) { + if (path === "executor.json") continue; + // Ignore companion files under a skill dir (scripts/, references/, assets/). + if (/^skills\/[a-z0-9-]+\/(?!SKILL\.md)/.test(path)) continue; + + let m: RegExpMatchArray | null; + if ((m = path.match(TOOL_RE))) { + artifacts.push({ kind: "tool", name: m[1], entry: path }); + } else if ((m = path.match(WORKFLOW_RE))) { + artifacts.push({ kind: "workflow", name: m[1], entry: path }); + } else if ((m = path.match(UI_RE))) { + artifacts.push({ kind: "ui", name: m[1], entry: path }); + } else if ((m = path.match(SKILL_RE))) { + const dirName = m[1]; + const fm = parseFrontmatter(contents); + if (!fm.name) { + diagnostics.push({ + path, + message: "skill SKILL.md is missing required frontmatter `name`", + }); + } else if (fm.name !== dirName) { + diagnostics.push({ + path, + message: `skill frontmatter name "${fm.name}" must equal the directory name "${dirName}"`, + }); + } + if (!fm.description) { + diagnostics.push({ + path, + message: "skill SKILL.md is missing required frontmatter `description`", + }); + } + artifacts.push({ kind: "skill", name: dirName, entry: path }); + } else if (/^(tools|workflows|ui|skills)\//.test(path)) { + // A file under a known artifact dir that doesn't match the shape. + diagnostics.push({ + path, + message: `file does not match the expected layout for its directory (${path.split("/")[0]}/)`, + }); + } + // Files outside the known dirs are relative-import companions; the bundler + // resolves them from the file set, so they need no discovery entry. + + // Duplicate identity within a kind. + const key = `${path.split("/")[0]}:${artifacts.at(-1)?.name}`; + if (artifacts.length && seen.has(key)) { + diagnostics.push({ path, message: `duplicate artifact identity: ${key}` }); + } + seen.add(key); + } + + if (diagnostics.length > 0) { + return new PublishError({ + message: `discover found ${diagnostics.length} problem(s)`, + stage: "discover", + diagnostics, + }); + } + + return { artifacts }; +}; diff --git a/packages/plugins/apps/src/pipeline/publish.test.ts b/packages/plugins/apps/src/pipeline/publish.test.ts new file mode 100644 index 000000000..de27d91f1 --- /dev/null +++ b/packages/plugins/apps/src/pipeline/publish.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeGitArtifactStore } from "../backing/git-artifact-store"; +import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; +import { dailyBriefFileSet } from "../testing/daily-brief"; +import { publish, type PutBlob } from "./publish"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +const makeDeps = () => { + const blobs = new Map(); + const putBlob: PutBlob = (hash, value) => Effect.sync(() => void blobs.set(hash, value)); + return { + deps: { + artifactStore: makeGitArtifactStore({ root: mkdtempSync(join(tmpdir(), "apps-pub-")) }), + sandbox: makeQuickjsToolSandbox(), + putBlob, + }, + blobs, + }; +}; + +describe("publish pipeline (discover -> bundle -> collect -> project)", () => { + it("compiles the daily-brief set into a descriptor", async () => { + const { deps, blobs } = makeDeps(); + const out = await run(publish(deps, { scope: "rhys", files: dailyBriefFileSet() })); + + expect(out.snapshotId).toBeTruthy(); + const d = out.descriptor; + expect(d.scope).toBe("rhys"); + expect(d.snapshotId).toBe(out.snapshotId); + + // Two tools, one workflow, one ui, one skill. + const toolNames = d.tools.map((t) => t.name).sort(); + expect(toolNames).toEqual(["issues-sync", "search-all-mail"]); + expect(d.workflows.map((w) => w.name)).toEqual(["morning-sync"]); + expect(d.ui.map((u) => u.name)).toEqual(["dashboard"]); + expect(d.skills.map((s) => s.name)).toEqual(["issues-brief"]); + + // issues-sync declares a single github connection + a real input schema. + const sync = d.tools.find((t) => t.name === "issues-sync")!; + expect(sync.connections.github).toEqual( + expect.objectContaining({ kind: "single", integration: "github" }), + ); + expect((sync.inputSchema as { type: string }).type).toBe("object"); + + // search-all-mail declares a fan-out gmail connection. + const mail = d.tools.find((t) => t.name === "search-all-mail")!; + expect(mail.connections.inboxes).toEqual( + expect.objectContaining({ kind: "array", integration: "gmail" }), + ); + + // The workflow carries its schedule (extracted for the scheduler). + expect(d.workflows[0].schedule).toEqual({ cron: "0 9 * * 1-5", timezone: "America/New_York" }); + + // ui bundle + skill body were stored as blobs. + expect(blobs.has(`ui/${d.ui[0].bundleHash}`)).toBe(true); + expect(blobs.has(`skill/${d.skills[0].bodyHash}`)).toBe(true); + expect(d.ui[0].title).toBe("GitHub Issues"); + expect(d.ui[0].maxHeight).toBe(720); + expect(d.skills[0].description).toContain("open GitHub issues"); + }); + + it("rejects a skill whose frontmatter name mismatches the dir", async () => { + const { deps } = makeDeps(); + const files = new Map([ + ["skills/mine/SKILL.md", "---\nname: not-mine\ndescription: x\n---\n# body\n"], + ]); + const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); + expect(exit._tag).toBe("Failure"); + }); + + it("rejects a bare npm import (npm deps out of scope)", async () => { + const { deps } = makeDeps(); + const files = new Map([ + [ + "tools/bad.ts", + `import { defineTool } from "executor:app";\nimport { chunk } from "lodash";\nimport { z } from "zod";\nexport default defineTool({ description: "b", input: z.object({}), async handler(){ return chunk([1,2,3], 2); } });`, + ], + ]); + const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + const err = exit.cause; + // The failure is a PublishError at the bundle stage. + expect(JSON.stringify(err)).toContain("bundle"); + } + }); +}); diff --git a/packages/plugins/apps/src/pipeline/publish.ts b/packages/plugins/apps/src/pipeline/publish.ts new file mode 100644 index 000000000..7cec3b261 --- /dev/null +++ b/packages/plugins/apps/src/pipeline/publish.ts @@ -0,0 +1,242 @@ +import { Effect } from "effect"; + +import type { ArtifactStore, FileSet, SnapshotId } from "../seams/artifact-store"; +import type { ToolSandbox } from "../seams/tool-sandbox"; +import { bundleEntry } from "./bundle"; +import { + DESCRIPTOR_VERSION, + type AppDescriptor, + type ConnectionDecl, + type SkillDescriptor, + type ToolDescriptor, + type UiDescriptor, + type WorkflowDescriptor, +} from "./descriptor"; +import { discover, PublishError, type DiscoveredArtifact } from "./discover"; + +// --------------------------------------------------------------------------- +// publish — the compiler. discover (fs-shape) -> bundle (esbuild, platform +// external) -> collect (import in the sandbox with nothing bound; define* +// returns descriptors; run twice + byte-compare = determinism) -> project +// (commit the snapshot + emit the versioned descriptor whose projections the +// caller writes in one transaction). Nothing is persisted on failure; the +// caller only commits when this succeeds. +// +// This function is pure w.r.t. persistence EXCEPT it (a) commits the snapshot to +// the ArtifactStore (immutable, content-addressed — safe to leave even on a +// later failure) and (b) writes ui/skill blobs via the injected `putBlob` +// (content-addressed, idempotent). Catalog/schedule/journal projections are the +// caller's transaction. +// --------------------------------------------------------------------------- + +/** Content-addressed blob writer (SHA-256 hex key -> value). Idempotent. */ +export type PutBlob = (hash: string, value: string) => Effect.Effect; + +export interface PublishInput { + readonly scope: string; + readonly files: FileSet; + readonly commitMessage?: string; +} + +export interface PublishOutput { + readonly snapshotId: SnapshotId; + readonly descriptor: AppDescriptor; +} + +const sha256Hex = (text: string): Effect.Effect => + Effect.promise(async () => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); + }); + +const parseFrontmatter = (body: string): Record => { + const match = body.match(/^---\n([\s\S]*?)\n---/); + if (!match) return {}; + const out: Record = {}; + for (const line of match[1].split("\n")) { + const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (m) out[m[1]] = m[2].trim(); + } + return out; +}; + +// The raw shape the sandbox collect returns for one artifact's descriptor. +interface CollectedDescriptor { + readonly kind: "tool" | "workflow"; + readonly description?: string; + readonly connections?: Record< + string, + { decl: string; integration?: string; description?: string } + >; + readonly annotations?: ToolDescriptor["annotations"]; + readonly schedule?: { cron: string; timezone?: string }; + readonly inputJsonSchema?: unknown; + readonly outputJsonSchema?: unknown; +} + +const toConnectionDecls = ( + raw: CollectedDescriptor["connections"], +): Record => { + const out: Record = {}; + for (const [role, c] of Object.entries(raw ?? {})) { + if (c.decl === "array") { + out[role] = { kind: "array", integration: c.integration ?? "", description: c.description }; + } else if (c.decl === "catalog") { + out[role] = { kind: "catalog" }; + } else { + out[role] = { kind: "single", integration: c.integration ?? "", description: c.description }; + } + } + return out; +}; + +export interface PublishDeps { + readonly artifactStore: ArtifactStore; + readonly sandbox: ToolSandbox; + readonly putBlob: PutBlob; +} + +export const publish = ( + deps: PublishDeps, + input: PublishInput, +): Effect.Effect => + Effect.gen(function* () { + // --- discover (fs-shape, zero imports) -------------------------------- + const discovered = discover(input.files); + if (discovered instanceof PublishError) return yield* Effect.fail(discovered); + + // --- commit the snapshot (immutable, content-addressed) --------------- + const scopeStore = yield* deps.artifactStore + .forScope(input.scope) + .pipe( + Effect.mapError( + (cause) => + new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), + ), + ); + const meta = yield* scopeStore + .commit(input.files, input.commitMessage ?? `publish ${new Date().toISOString()}`) + .pipe( + Effect.mapError( + (cause) => + new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), + ), + ); + + const tools: ToolDescriptor[] = []; + const workflows: WorkflowDescriptor[] = []; + const ui: UiDescriptor[] = []; + const skills: SkillDescriptor[] = []; + + const codeArtifacts = discovered.artifacts.filter( + (a): a is DiscoveredArtifact => a.kind === "tool" || a.kind === "workflow", + ); + + // --- bundle + collect each tool/workflow ------------------------------ + for (const artifact of codeArtifacts) { + const bundle = yield* bundleEntry({ files: input.files, entry: artifact.entry }).pipe( + Effect.mapError( + (cause) => + new PublishError({ + message: cause.message, + stage: "bundle", + diagnostics: [{ path: artifact.entry, message: cause.message }], + }), + ), + ); + const collected = yield* deps.sandbox.collect(bundle.code).pipe( + Effect.mapError( + (cause) => + new PublishError({ + message: cause.message, + stage: "collect", + diagnostics: [{ path: artifact.entry, message: cause.message }], + }), + ), + ); + const raw = collected.artifacts.default?.descriptor as CollectedDescriptor | undefined; + if (!raw) { + return yield* Effect.fail( + new PublishError({ + message: `no descriptor collected from ${artifact.entry}`, + stage: "collect", + diagnostics: [{ path: artifact.entry, message: "define* did not run" }], + }), + ); + } + const connections = toConnectionDecls(raw.connections); + if (artifact.kind === "tool") { + tools.push({ + name: artifact.name, + sourcePath: artifact.entry, + description: raw.description ?? "", + connections, + inputSchema: raw.inputJsonSchema, + outputSchema: raw.outputJsonSchema, + annotations: raw.annotations, + }); + } else { + workflows.push({ + name: artifact.name, + sourcePath: artifact.entry, + description: raw.description ?? "", + connections, + schedule: raw.schedule, + }); + } + } + + // --- ui: bundle for the browser, store the bundle as a blob ----------- + for (const artifact of discovered.artifacts.filter((a) => a.kind === "ui")) { + const bundle = yield* bundleEntry({ files: input.files, entry: artifact.entry }).pipe( + Effect.mapError( + (cause) => + new PublishError({ + message: cause.message, + stage: "bundle", + diagnostics: [{ path: artifact.entry, message: cause.message }], + }), + ), + ); + const hash = yield* sha256Hex(bundle.code); + yield* deps.putBlob(`ui/${hash}`, bundle.code); + // Pull title/maxHeight from a `config({...})` call if present (best-effort + // static scan; the shell also reads config() at mount). + const source = input.files.get(artifact.entry) ?? ""; + const titleMatch = source.match(/title:\s*["'`]([^"'`]+)["'`]/); + const maxHeightMatch = source.match(/maxHeight:\s*(\d+)/); + ui.push({ + name: artifact.name, + sourcePath: artifact.entry, + bundleHash: hash, + title: titleMatch?.[1], + maxHeight: maxHeightMatch ? Number(maxHeightMatch[1]) : undefined, + }); + } + + // --- skills: store the SKILL.md body, index name + description -------- + for (const artifact of discovered.artifacts.filter((a) => a.kind === "skill")) { + const body = input.files.get(artifact.entry) ?? ""; + const fm = parseFrontmatter(body); + const hash = yield* sha256Hex(body); + yield* deps.putBlob(`skill/${hash}`, body); + skills.push({ + name: artifact.name, + sourcePath: artifact.entry, + description: fm.description ?? "", + bodyHash: hash, + }); + } + + const descriptor: AppDescriptor = { + version: DESCRIPTOR_VERSION, + scope: input.scope, + snapshotId: meta.id, + tools, + workflows, + ui, + skills, + }; + + return { snapshotId: meta.id, descriptor }; + }); From e0a832d2baa8d5f04b53bba5be43d50cf9700a8b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:29:35 -0700 Subject: [PATCH 04/23] apps: runtime, connection DI bindings, source plugin, and self-host wiring with end-to-end test --- packages/plugins/apps/src/api.ts | 9 + packages/plugins/apps/src/index.ts | 46 ++ .../plugins/apps/src/plugin/apps-plugin.ts | 152 ++++++ packages/plugins/apps/src/plugin/bindings.ts | 193 ++++++++ .../plugins/apps/src/plugin/runtime.test.ts | 98 ++++ packages/plugins/apps/src/plugin/runtime.ts | 433 ++++++++++++++++++ .../apps/src/plugin/self-host-runtime.ts | 86 ++++ packages/plugins/apps/src/plugin/store.ts | 79 ++++ packages/plugins/apps/src/testing/index.ts | 57 +++ 9 files changed, 1153 insertions(+) create mode 100644 packages/plugins/apps/src/api.ts create mode 100644 packages/plugins/apps/src/index.ts create mode 100644 packages/plugins/apps/src/plugin/apps-plugin.ts create mode 100644 packages/plugins/apps/src/plugin/bindings.ts create mode 100644 packages/plugins/apps/src/plugin/runtime.test.ts create mode 100644 packages/plugins/apps/src/plugin/runtime.ts create mode 100644 packages/plugins/apps/src/plugin/self-host-runtime.ts create mode 100644 packages/plugins/apps/src/plugin/store.ts create mode 100644 packages/plugins/apps/src/testing/index.ts diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts new file mode 100644 index 000000000..3feb1c448 --- /dev/null +++ b/packages/plugins/apps/src/api.ts @@ -0,0 +1,9 @@ +// HTTP + plugin surface for @executor-js/plugin-apps. +export { + appsPlugin, + APPS_INTEGRATION_SLUG, + APPS_PLUGIN_ID, + type AppsPluginOptions, +} from "./plugin/apps-plugin"; +export { makeAppsHttpRoutes, type AppsHttpDeps } from "./http/routes"; +export { registerAppsMcp, type AppsMcpDeps } from "./mcp/register"; diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts new file mode 100644 index 000000000..45999d65e --- /dev/null +++ b/packages/plugins/apps/src/index.ts @@ -0,0 +1,46 @@ +// @executor-js/plugin-apps — the executor apps subsystem (self-hosted build). +// +// Custom tools, durable workflows, UI views, and skills: published into a +// per-scope store and served/executed by the platform. Publish is the compiler +// (FDI); every substrate-specific capability sits behind a seam. + +export * from "./seams"; +export * from "./pipeline/descriptor"; +export { discover, PublishError } from "./pipeline/discover"; +export { bundleEntry, PLATFORM_MODULES, INLINABLE_MODULES } from "./pipeline/bundle"; +export { + publish, + type PublishInput, + type PublishOutput, + type PublishDeps, + type PutBlob, +} from "./pipeline/publish"; + +export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; +export { + makeAppsStore, + type AppsStore, + type AppsStoreDeps, + descriptorCollection, +} from "./plugin/store"; +export { + buildBridge, + rootsFor, + BindingError, + type Bindings, + type RoleBinding, + type ClientResolver, + type BindingContext, +} from "./plugin/bindings"; +export { + makeSelfHostAppsRuntime, + type SelfHostAppsRuntime, + type SelfHostAppsRuntimeOptions, +} from "./plugin/self-host-runtime"; + +// Self-host seam backings. +export { makeGitArtifactStore } from "./backing/git-artifact-store"; +export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; +export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; +export { makeSqliteWorkflowRunner } from "./backing/sqlite-workflow-runner"; +export { makeInProcessLiveChannel } from "./backing/in-process-live-channel"; diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts new file mode 100644 index 000000000..023674a79 --- /dev/null +++ b/packages/plugins/apps/src/plugin/apps-plugin.ts @@ -0,0 +1,152 @@ +import { Effect } from "effect"; + +import { + definePlugin, + ToolName, + type ResolveToolsInput, + type ResolveToolsResult, + type InvokeToolInput, + type ToolDef, +} from "@executor-js/sdk"; + +import type { AppsRuntime } from "./runtime"; +import { makeAppsStore } from "./store"; +import type { Bindings } from "./bindings"; + +// --------------------------------------------------------------------------- +// The apps source plugin. Published custom tools become catalog citizens: the +// plugin registers one integration per scope (`apps`), and a connection to it +// makes the published tools resolvable + invocable like any catalog tool, so +// policy / approval / audit / toolkits / tools.list all apply unchanged. +// +// `resolveTools` projects the published descriptor into `ToolDef[]`. +// `invokeTool` bundles + runs the tool in the sandbox with connections bound. +// The plugin is thin: all logic lives in `AppsRuntime` (shared with the HTTP + +// MCP surfaces). The runtime is supplied via options because it owns the seam +// instances built at host boot. +// --------------------------------------------------------------------------- + +export const APPS_INTEGRATION_SLUG = "apps"; +export const APPS_PLUGIN_ID = "apps"; + +export interface AppsPluginOptions { + /** The shared runtime (seams + store). Built at host boot. */ + readonly runtime: AppsRuntime; + /** How a tool's declared connection roles are bound to the caller's + * connections at invoke time. Self-host resolves these from the scope's + * configured connections; a default binds each role to a connection of the + * same name as the integration. */ + readonly resolveBindings?: (input: { + readonly scope: string; + readonly tool: string; + readonly declared: Readonly>; + }) => Bindings; +} + +const defaultBindings = ( + declared: Readonly>, +): Bindings => { + const out: Record = {}; + for (const [role, decl] of Object.entries(declared)) { + if (decl.kind === "array") { + out[role] = { kind: "array", connections: [decl.integration ?? role] }; + } else if (decl.kind === "catalog") { + // no binding + } else { + out[role] = { kind: "single", connection: decl.integration ?? role }; + } + } + return out; +}; + +interface AppsStoreShape { + readonly runtime: AppsRuntime; +} + +export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { + if (!options?.runtime) { + throw new Error("appsPlugin requires a `runtime` (built from the five seams at host boot)"); + } + const runtime = options.runtime; + const resolveBindings = options.resolveBindings; + + return { + id: APPS_PLUGIN_ID as "apps", + packageName: "@executor-js/plugin-apps", + + // The plugin's store facade is host-owned plugin storage + blobs; the apps + // runtime already holds its own store, so this is a thin passthrough kept + // for the ctx shape (extension methods read the runtime). + storage: (deps): AppsStoreShape => { + void makeAppsStore({ + pluginStorage: deps.pluginStorage, + blobs: deps.blobs, + }); + return { runtime }; + }, + + // Declare the plugin's storage collection so the host provisions it. + pluginStorage: { + published_descriptor: { + name: "published_descriptor", + schema: { Type: {} as Record }, + indexes: [], + }, + }, + + extension: () => ({ runtime }), + + // Per-connection tool production: project the published descriptor into + // ToolDefs. Called at connection create/refresh; the SDK stamps addresses + // and persists per connection. + resolveTools: ({ connection }: ResolveToolsInput) => + Effect.gen(function* () { + // The scope is the connection owner's tenant; for self-host single + // tenant we key the descriptor by the connection's integration-scoped + // name. We read the published descriptor for the scope encoded in the + // connection name (`apps/`), falling back to the connection name. + const scope = scopeFromConnection(connection.name); + const descriptor = yield* runtime.getDescriptor(scope); + if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; + const tools: ToolDef[] = descriptor.tools.map((t) => ({ + name: ToolName.make(t.name), + description: t.description, + inputSchema: t.inputSchema, + outputSchema: t.outputSchema, + annotations: { + requiresApproval: t.annotations?.destructive === true, + }, + })); + return { tools } satisfies ResolveToolsResult; + }), + + invokeTool: ({ toolRow, args }: InvokeToolInput) => + Effect.gen(function* () { + const scope = scopeFromConnection(toolRow.connection); + const descriptor = yield* runtime.getDescriptor(scope); + const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); + const declared = toolDesc?.connections ?? {}; + const bindings = resolveBindings + ? resolveBindings({ scope, tool: toolRow.name, declared }) + : defaultBindings(declared); + return yield* runtime + .invokeTool({ scope, tool: toolRow.name, args, bindings }) + .pipe( + Effect.mapError( + (cause) => + new Error( + "message" in cause && typeof cause.message === "string" + ? cause.message + : "apps tool invocation failed", + ), + ), + ); + }), + }; +}); + +// Connection names encode the scope as `apps/` (or are the scope itself). +const scopeFromConnection = (connectionName: string): string => { + const slash = connectionName.indexOf("/"); + return slash === -1 ? connectionName : connectionName.slice(slash + 1); +}; diff --git a/packages/plugins/apps/src/plugin/bindings.ts b/packages/plugins/apps/src/plugin/bindings.ts new file mode 100644 index 000000000..43e122eb4 --- /dev/null +++ b/packages/plugins/apps/src/plugin/bindings.ts @@ -0,0 +1,193 @@ +import { Effect } from "effect"; +import { Data } from "effect"; + +import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox"; +import { ToolSandboxError } from "../seams/tool-sandbox"; +import type { ScopeDbHandle } from "../seams/scope-db"; +import type { ConnectionDecl } from "../pipeline/descriptor"; + +// --------------------------------------------------------------------------- +// Connection DI: declare-then-bind. A tool's descriptor declares `connections` +// (role -> integration); before execution each role is bound to the user's +// connection(s) explicitly. A missing binding is a typed error naming role + +// surface (no auto-pick). The handler receives pre-bound clients whose method +// calls route through the platform invoke path. +// +// The routing is a seam: `ClientResolver` turns a (integration, connection, +// method path, args) into a JSON result. The self-hosted resolver calls the +// integration's real API through the connection credential (policy/audit +// applies there); a test resolver returns canned data. `db` is bound to the +// invoking scope's ScopeDb. Everything crossing is JSON (the cloud resolver is +// an RPC). +// --------------------------------------------------------------------------- + +export class BindingError extends Data.TaggedError("BindingError")<{ + readonly message: string; + readonly role: string; + readonly surface: string; +}> {} + +/** One bound connection for a role. Fan-out roles bind an ordered set. */ +export type RoleBinding = + | { readonly kind: "single"; readonly connection: string } + | { readonly kind: "array"; readonly connections: readonly string[] }; + +/** The user's bindings for a tool invocation: role -> bound connection(s). */ +export type Bindings = Readonly>; + +/** Resolves a single method call against a bound connection to a JSON result. + * This is where the platform invoke path (credentials, policy, audit) lives. */ +export interface ClientResolver { + readonly call: (input: { + readonly integration: string; + readonly connection: string; + readonly path: readonly string[]; + readonly args: readonly unknown[]; + }) => Effect.Effect; +} + +export interface BindingContext { + /** Declared connection roles from the tool descriptor. */ + readonly declared: Readonly>; + /** The user's bindings (role -> connection[s]). Missing => typed error. */ + readonly bindings: Bindings; + /** The invoking scope's app database (bound to the `db` root). */ + readonly db: ScopeDbHandle; + /** Routes a bound method call to the real integration. */ + readonly resolver: ClientResolver; +} + +/** + * Validate bindings against declarations and compute the sandbox handle roots: + * `db` is always a single root; each declared role becomes a single or array + * root. Fails (typed) when a declared role has no binding, naming role + + * surface — the "missing binding is a typed error" rule. + */ +export const rootsFor = ( + declared: Readonly>, + bindings: Bindings, +): Effect.Effect>, BindingError> => + Effect.gen(function* () { + const roots: Record = { db: { kind: "single" } }; + for (const [role, decl] of Object.entries(declared)) { + if (decl.kind === "catalog") { + // Open-world proxy: parse + record, but execution is NotImplemented in + // this build. Bind a single root; the resolver throws if called. + roots[role] = { kind: "single" }; + continue; + } + const binding = bindings[role]; + if (!binding) { + return yield* Effect.fail( + new BindingError({ + message: `no connection bound for role "${role}" (surface "${decl.integration}")`, + role, + surface: decl.integration, + }), + ); + } + if (decl.kind === "array") { + if (binding.kind !== "array") { + return yield* Effect.fail( + new BindingError({ + message: `role "${role}" is a fan-out (connections("${decl.integration}")) and needs an array binding`, + role, + surface: decl.integration, + }), + ); + } + roots[role] = { kind: "array", count: binding.connections.length }; + } else { + if (binding.kind !== "single") { + return yield* Effect.fail( + new BindingError({ + message: `role "${role}" is a single connection and needs a single binding`, + role, + surface: decl.integration, + }), + ); + } + roots[role] = { kind: "single" }; + } + } + return roots; + }); + +// Parse a fan-out root name back into (role, index): `inboxes#1` -> {inboxes,1}. +const parseRoot = (root: string): { role: string; index?: number } => { + const hash = root.indexOf("#"); + if (hash === -1) return { role: root }; + return { role: root.slice(0, hash), index: Number(root.slice(hash + 1)) }; +}; + +/** + * Build the HandleBridge the sandbox calls out through. `db` routes to the + * scope database; a declared role routes to its bound connection through the + * `ClientResolver`. Undeclared roots are unreachable (the sandbox never injects + * them). A `.account` read on a client returns bound-connection metadata + * without a round-trip. + */ +export const buildBridge = (context: BindingContext): HandleBridge => ({ + call: ({ root, path, args }) => { + if (root === "db") { + // The scope db handle exposes `sql` as a tagged template; the injected + // client calls `db.sql(templateStrings, ...values)`. When routed through + // the bridge, `path = ["sql"]` and args = [stringsArray, ...values]. + if (path.length === 1 && path[0] === "sql") { + const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]]; + return context.db + .sql(strings, ...values) + .pipe( + Effect.mapError( + (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), + ), + ); + } + return Effect.fail( + new ToolSandboxError({ kind: "invoke", message: `unsupported db call: ${path.join(".")}` }), + ); + } + + const { role, index } = parseRoot(root); + const decl = context.declared[role]; + if (!decl) { + return Effect.fail( + new ToolSandboxError({ kind: "invoke", message: `undeclared handle root: ${root}` }), + ); + } + if (decl.kind === "catalog") { + return Effect.fail( + new ToolSandboxError({ + kind: "invoke", + message: "catalog() open-world proxy execution is not implemented in this build", + }), + ); + } + + // A `.account` read returns bound-connection metadata (safe, no creds). + const binding = context.bindings[role]; + const connectionName = + binding?.kind === "array" + ? binding.connections[index ?? 0] + : binding?.kind === "single" + ? binding.connection + : undefined; + if (!connectionName) { + return Effect.fail( + new ToolSandboxError({ kind: "invoke", message: `no binding for role ${role}` }), + ); + } + if (path.length === 1 && path[0] === "account") { + // Clients read `.account.email` / `.account.login`; expose both keys. + return Effect.succeed({ email: connectionName, login: connectionName, name: connectionName }); + } + + return context.resolver + .call({ integration: decl.integration, connection: connectionName, path, args }) + .pipe( + Effect.mapError( + (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), + ), + ); + }, +}); diff --git a/packages/plugins/apps/src/plugin/runtime.test.ts b/packages/plugins/apps/src/plugin/runtime.test.ts new file mode 100644 index 000000000..658a4b963 --- /dev/null +++ b/packages/plugins/apps/src/plugin/runtime.test.ts @@ -0,0 +1,98 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeSelfHostAppsRuntime } from "./self-host-runtime"; +import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; +import type { Bindings } from "./bindings"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +// A GitHub resolver returning two open issues, one stale. +const githubHandlers = { + github: { + "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }], + "issues.listForRepo": () => [ + { + number: 1, + title: "Fresh bug", + labels: [{ name: "bug" }], + assignee: { login: "rhys" }, + updated_at: new Date().toISOString(), + html_url: "https://github.com/acme/app/issues/1", + }, + { + number: 2, + title: "Old bug", + labels: [], + assignee: null, + updated_at: "2020-01-01T00:00:00Z", + html_url: "https://github.com/acme/app/issues/2", + }, + ], + }, +}; + +const githubBindings: Bindings = { github: { kind: "single", connection: "rhys-github" } }; + +describe("AppsRuntime end-to-end (publish -> invoke -> workflow)", () => { + it("publishes daily-brief, invokes the tool into the scope db, runs the workflow", async () => { + const store = makeInMemoryAppsStore(); + const resolver = makeTestResolver(githubHandlers); + const host = makeSelfHostAppsRuntime({ + dataDir: mkdtempSync(join(tmpdir(), "apps-rt-")), + store, + resolver, + inMemory: true, + }); + const { runtime } = host; + + // --- publish ---------------------------------------------------------- + const published = await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() })); + expect(published.descriptor.tools.map((t) => t.name).sort()).toEqual([ + "issues-sync", + "search-all-mail", + ]); + + // --- invoke the tool: writes the scope `issues` table ----------------- + const syncResult = (await run( + runtime.invokeTool({ + scope: "rhys", + tool: "issues-sync", + args: {}, + bindings: githubBindings, + }), + )) as { synced: number; repos: number }; + expect(syncResult).toEqual({ synced: 2, repos: 1 }); + + // The scope db now has the two issues. + const db = await run(host.scopeDb.forScope("rhys")); + const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); + expect(Number(rows[0].n)).toBe(2); + + // --- run the workflow: syncs again + flags the stale issue ------------ + const runView = await run( + runtime.startWorkflow({ + scope: "rhys", + workflow: "morning-sync", + input: {}, + bindings: githubBindings, + runId: "morning-1", + }), + ); + expect(runView.status).toBe("completed"); + expect(runView.output).toEqual({ synced: 2, stale: 1 }); + + // Journal has the step.tool call and the find-stale step. + const steps = await run(runtime.listSteps("morning-1")); + const names = steps.map((s) => s.name); + expect(names).toContain("tool:issues-sync"); + expect(names).toContain("find-stale"); + expect(steps.every((s) => s.status === "completed")).toBe(true); + + await host.close(); + }); +}); diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts new file mode 100644 index 000000000..ad7d94e38 --- /dev/null +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -0,0 +1,433 @@ +import { Effect } from "effect"; + +import type { ArtifactStore } from "../seams/artifact-store"; +import type { ScopeDb } from "../seams/scope-db"; +import type { ToolSandbox } from "../seams/tool-sandbox"; +import type { LiveChannel } from "../seams/live-channel"; +import type { DurableSteps, WorkflowRunner, RunView, StepView } from "../seams/workflow-runner"; +import { publish as runPublish, type PublishOutput } from "../pipeline/publish"; +import { PublishError } from "../pipeline/discover"; +import type { AppDescriptor, ToolDescriptor, WorkflowDescriptor } from "../pipeline/descriptor"; +import { bundleEntry } from "../pipeline/bundle"; +import { + buildBridge, + rootsFor, + type Bindings, + type ClientResolver, + BindingError, +} from "./bindings"; +import type { AppsStore } from "./store"; + +// --------------------------------------------------------------------------- +// AppsRuntime — the substrate-neutral core of the apps subsystem. It owns the +// five seams + the store, and exposes the operations every surface (HTTP, MCP, +// the plugin) drives: publish, resolveTools (descriptor projection), invokeTool +// (bundle -> sandbox invoke with bound clients), and the workflow lifecycle +// (start/signal/status/history) with real journal replay + scheduling. +// +// Nothing here knows about HTTP or MCP; those are thin adapters over this. +// --------------------------------------------------------------------------- + +export interface AppsRuntimeDeps { + readonly artifactStore: ArtifactStore; + readonly scopeDb: ScopeDb; + readonly sandbox: ToolSandbox; + readonly workflows: WorkflowRunner; + readonly liveChannel: LiveChannel; + readonly store: AppsStore; + /** Routes a bound integration method call to the real API (policy/audit). */ + readonly resolver: ClientResolver; +} + +export interface AppsRuntime { + readonly publish: (input: { + readonly scope: string; + readonly files: ReadonlyMap; + readonly message?: string; + }) => Effect.Effect; + readonly getDescriptor: (scope: string) => Effect.Effect; + readonly invokeTool: (input: { + readonly scope: string; + readonly tool: string; + readonly args: unknown; + readonly bindings: Bindings; + }) => Effect.Effect; + readonly startWorkflow: (input: { + readonly scope: string; + readonly workflow: string; + readonly input?: unknown; + readonly bindings?: Bindings; + readonly runId?: string; + }) => Effect.Effect; + readonly signalWorkflow: (input: { + readonly scope: string; + readonly runId: string; + readonly event: string; + readonly payload: unknown; + }) => Effect.Effect; + readonly getRun: (runId: string) => Effect.Effect; + readonly listRuns: (scope: string) => Effect.Effect; + readonly listSteps: (runId: string) => Effect.Effect; + /** Serve a ui bundle by name (raw endpoint / MCP resource). */ + readonly getUiBundle: ( + scope: string, + name: string, + ) => Effect.Effect<{ code: string; title?: string; maxHeight?: number } | null>; + /** Subscribe to a scope's live invalidations (SSE adapter drives this). */ + readonly subscribeLive: ( + scope: string, + listener: (event: { table: string; version: number }) => void, + ) => () => void; + readonly deps: AppsRuntimeDeps; +} + +const failNoDescriptor = (scope: string): PublishError => + new PublishError({ + message: `scope "${scope}" has no published app`, + stage: "project", + diagnostics: [], + }); + +export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { + // Bundle cache keyed by (snapshot, sourcePath): a published snapshot is + // immutable, so its bundles never change. + const bundleCache = new Map(); + + const bundleFor = ( + descriptor: AppDescriptor, + sourcePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const cacheKey = `${descriptor.snapshotId}:${sourcePath}`; + const cached = bundleCache.get(cacheKey); + if (cached) return cached; + const scopeStore = yield* deps.artifactStore + .forScope(descriptor.scope) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const files = yield* scopeStore + .read(descriptor.snapshotId as never) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const bundle = yield* bundleEntry({ files, entry: sourcePath }).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "bundle", + diagnostics: [{ path: sourcePath, message: c.message }], + }), + ), + ); + bundleCache.set(cacheKey, bundle.code); + return bundle.code; + }); + + const requireDescriptor = (scope: string): Effect.Effect => + deps.store.getDescriptor(scope).pipe( + Effect.mapError( + (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + ), + Effect.flatMap((d) => (d ? Effect.succeed(d) : Effect.fail(failNoDescriptor(scope)))), + ); + + const invokeToolInternal = ( + scope: string, + descriptor: AppDescriptor, + toolDesc: ToolDescriptor, + args: unknown, + bindings: Bindings, + ): Effect.Effect => + Effect.gen(function* () { + const roots = yield* rootsFor(toolDesc.connections, bindings); + const code = yield* bundleFor(descriptor, toolDesc.sourcePath); + const db = yield* deps.scopeDb + .forScope(scope) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const bridge = buildBridge({ + declared: toolDesc.connections, + bindings, + db, + resolver: deps.resolver, + }); + const result = yield* deps.sandbox + .invoke(code, { artifact: toolDesc.name, kind: "tool", input: args, roots }, bridge) + .pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [{ path: toolDesc.sourcePath, message: c.message }], + }), + ), + ); + return result.output; + }); + + // Build the workflow body closure for one run: replay the workflow's compiled + // bundle over the DurableSteps. Our sandbox runs a tool handler, not a + // long-lived stepful body, so we drive the workflow with a thin interpreter: + // the workflow's `run(step, {db})` is executed in-process against the + // DurableSteps facade, with `step.tool` -> the runner bindings and + // `db.sql` -> the scope db. This keeps CF semantics while the durable journal + // lives in the WorkflowRunner seam. + const workflowBody = ( + scope: string, + descriptor: AppDescriptor, + wfDesc: WorkflowDescriptor, + ): ((steps: DurableSteps) => Promise) => { + return async (steps: DurableSteps) => { + const code = await Effect.runPromise( + bundleFor(descriptor, wfDesc.sourcePath).pipe(Effect.orDie), + ); + const db = await Effect.runPromise(deps.scopeDb.forScope(scope).pipe(Effect.orDie)); + // Interpret the workflow: run its bundle in the sandbox is not how durable + // steps work (the body must call back into our journaled `steps`). Instead + // we evaluate the workflow module here and invoke its `run(step, {db})`. + // The compiled bundle sets globalThis.__artifact = the workflow def. + const def = extractWorkflowDef(code); + const scopeDbClient = { + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + Effect.runPromise(db.sql(strings, ...values).pipe(Effect.orDie)), + }; + return def.run(steps, { db: scopeDbClient }); + }; + }; + + const bindingsForRunTool = ( + scope: string, + descriptor: AppDescriptor, + recordedBindings: Bindings, + ) => ({ + runTool: async (address: string, toolArgs: unknown) => { + const toolDesc = descriptor.tools.find((t) => t.name === address); + if (!toolDesc) throw new Error(`workflow step.tool: unknown tool "${address}"`); + return Effect.runPromise( + invokeToolInternal(scope, descriptor, toolDesc, toolArgs, recordedBindings).pipe( + Effect.orDie, + ), + ); + }, + notify: async (_msg: { title: string; body?: string; link?: string }) => { + // Self-host delivery sink: recorded as a journaled step; a real host wires + // this to notifications. No-op body here keeps the workflow durable. + }, + }); + + return { + deps, + publish: (input) => + Effect.gen(function* () { + const out = yield* runPublish( + { + artifactStore: deps.artifactStore, + sandbox: deps.sandbox, + putBlob: (hash, value) => + deps.store + .putBlob(hash, value) + .pipe( + Effect.mapError( + (c) => + new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + ), + ), + }, + { scope: input.scope, files: input.files, commitMessage: input.message }, + ); + yield* deps.store + .putDescriptor("org", out.descriptor) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + ), + ); + return out; + }), + + getDescriptor: (scope) => + deps.store.getDescriptor(scope).pipe(Effect.orElseSucceed(() => null)), + + invokeTool: (input) => + Effect.gen(function* () { + const descriptor = yield* requireDescriptor(input.scope); + const toolDesc = descriptor.tools.find((t) => t.name === input.tool); + if (!toolDesc) { + return yield* Effect.fail( + new PublishError({ + message: `tool "${input.tool}" is not published in scope "${input.scope}"`, + stage: "project", + diagnostics: [], + }), + ); + } + return yield* invokeToolInternal( + input.scope, + descriptor, + toolDesc, + input.args, + input.bindings, + ); + }), + + startWorkflow: (input) => + Effect.gen(function* () { + const descriptor = yield* requireDescriptor(input.scope); + const wfDesc = descriptor.workflows.find((w) => w.name === input.workflow); + if (!wfDesc) { + return yield* Effect.fail( + new PublishError({ + message: `workflow "${input.workflow}" is not published in scope "${input.scope}"`, + stage: "project", + diagnostics: [], + }), + ); + } + const bindings = input.bindings ?? {}; + const body = workflowBody(input.scope, descriptor, wfDesc); + return yield* deps.workflows + .start( + { + scope: input.scope, + workflow: input.workflow, + snapshotId: descriptor.snapshotId, + input: input.input ?? {}, + runId: input.runId, + }, + body, + bindingsForRunTool(input.scope, descriptor, bindings), + ) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + }), + + signalWorkflow: (input) => + Effect.gen(function* () { + const run = yield* deps.workflows + .get(input.runId) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + if (!run) { + return yield* Effect.fail( + new PublishError({ + message: `no run ${input.runId}`, + stage: "project", + diagnostics: [], + }), + ); + } + const descriptor = yield* requireDescriptor(run.scope); + const wfDesc = descriptor.workflows.find((w) => w.name === run.workflow); + if (!wfDesc) { + return yield* Effect.fail( + new PublishError({ + message: `workflow ${run.workflow} gone`, + stage: "project", + diagnostics: [], + }), + ); + } + const body = workflowBody(run.scope, descriptor, wfDesc); + return yield* deps.workflows + .signal( + input.runId, + input.event, + input.payload, + body, + bindingsForRunTool(run.scope, descriptor, {}), + ) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + }), + + getRun: (runId) => deps.workflows.get(runId).pipe(Effect.orElseSucceed(() => null)), + listRuns: (scope) => + deps.workflows.list({ scope }).pipe(Effect.orElseSucceed(() => [] as readonly RunView[])), + listSteps: (runId) => + deps.workflows.listSteps(runId).pipe(Effect.orElseSucceed(() => [] as readonly StepView[])), + + getUiBundle: (scope, name) => + Effect.gen(function* () { + const descriptor = yield* deps.store + .getDescriptor(scope) + .pipe(Effect.orElseSucceed(() => null)); + if (!descriptor) return null; + const uiDesc = descriptor.ui.find((u) => u.name === name); + if (!uiDesc) return null; + const code = yield* deps.store + .getBlob(`ui/${uiDesc.bundleHash}`) + .pipe(Effect.orElseSucceed(() => null)); + if (!code) return null; + return { code, title: uiDesc.title, maxHeight: uiDesc.maxHeight }; + }), + + subscribeLive: (scope, listener) => + deps.liveChannel.subscribe(scope, (event) => + listener({ table: event.table, version: event.version }), + ), + }; +}; + +// Extract the workflow def from a compiled bundle by running it in a tiny +// module shim (Node-side, trusted: this is our own runtime, not user isolation +// for the durable interpreter — the tool HANDLERS still run in the sandbox). +// The bundle set globalThis.__artifact to the workflow def object. +const extractWorkflowDef = ( + code: string, +): { run: (steps: DurableSteps, deps: { db: unknown }) => Promise } => { + const g: Record = {}; + const shim = { + connection: (integration: string, opts?: { description?: string }) => ({ + __decl: "single", + integration, + description: opts?.description, + }), + connections: (integration: string) => ({ __decl: "array", integration }), + catalog: () => ({ __decl: "catalog" }), + defineTool: (def: unknown) => { + g.__artifact = def; + return def; + }, + defineWorkflow: (def: unknown) => { + g.__artifact = def; + return def; + }, + }; + const req = (id: string) => { + if (id === "executor:app") return shim; + if (id === "executor:ui") return {}; + if (id === "executor:ui/components") return {}; + throw new Error(`module not available: ${id}`); + }; + const moduleObj = { exports: {} as Record }; + const globalThisShim = g as unknown as typeof globalThis; + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const factory = new Function("module", "exports", "require", "globalThis", code); + factory(moduleObj, moduleObj.exports, req, globalThisShim); + const def = (g.__artifact ?? moduleObj.exports.default ?? moduleObj.exports) as { + run: (steps: DurableSteps, deps: { db: unknown }) => Promise; + }; + if (!def || typeof def.run !== "function") { + throw new Error("workflow bundle did not produce a run() function"); + } + return def; +}; diff --git a/packages/plugins/apps/src/plugin/self-host-runtime.ts b/packages/plugins/apps/src/plugin/self-host-runtime.ts new file mode 100644 index 000000000..61dac8e48 --- /dev/null +++ b/packages/plugins/apps/src/plugin/self-host-runtime.ts @@ -0,0 +1,86 @@ +import { join } from "node:path"; + +import { Effect } from "effect"; + +import { makeGitArtifactStore } from "../backing/git-artifact-store"; +import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; +import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; +import { makeSqliteWorkflowRunner } from "../backing/sqlite-workflow-runner"; +import { makeInProcessLiveChannel } from "../backing/in-process-live-channel"; +import type { ScopeDb } from "../seams/scope-db"; +import type { LiveChannel } from "../seams/live-channel"; +import { makeAppsRuntime, type AppsRuntime } from "./runtime"; +import type { AppsStore } from "./store"; +import type { ClientResolver } from "./bindings"; + +// --------------------------------------------------------------------------- +// Wire the five self-hosted seam backings into one AppsRuntime, rooted at a +// data directory. This is what the self-host app calls at boot. The ScopeDb's +// write events are wired into the LiveChannel here (invalidation delivery), and +// the store is provided by the caller (over the host's pluginStorage + blobs). +// --------------------------------------------------------------------------- + +export interface SelfHostAppsRuntimeOptions { + /** Data dir root; `/artifacts`, `/scope-db`, `/workflows`. */ + readonly dataDir: string; + /** Store over the host's pluginStorage + blobs. */ + readonly store: AppsStore; + /** Routes bound integration calls to real APIs (policy/audit). */ + readonly resolver: ClientResolver; + /** In-memory backings for tests. */ + readonly inMemory?: boolean; +} + +export interface SelfHostAppsRuntime { + readonly runtime: AppsRuntime; + readonly scopeDb: ScopeDb; + readonly liveChannel: LiveChannel; + readonly close: () => Promise; +} + +export const makeSelfHostAppsRuntime = ( + options: SelfHostAppsRuntimeOptions, +): SelfHostAppsRuntime => { + const inMem = options.inMemory === true; + const artifactStore = makeGitArtifactStore({ + root: inMem ? options.dataDir : join(options.dataDir, "artifacts"), + }); + const scopeDb = makeLibsqlScopeDb({ + root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), + }); + const sandbox = makeQuickjsToolSandbox(); + const workflows = makeSqliteWorkflowRunner({ + path: inMem ? ":memory:" : join(options.dataDir, "workflows", "journal.db"), + }); + const liveChannel = makeInProcessLiveChannel(); + + // Wire scope-db writes -> live invalidations. + const unwire = scopeDb.onWrite((event) => { + for (const t of event.tables) { + void Effect.runPromise( + liveChannel.publish({ scope: event.scope, table: t.table, version: t.version }), + ); + } + }); + + const runtime = makeAppsRuntime({ + artifactStore, + scopeDb, + sandbox, + workflows, + liveChannel, + store: options.store, + resolver: options.resolver, + }); + + return { + runtime, + scopeDb, + liveChannel, + close: async () => { + unwire(); + await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); + await Effect.runPromise(workflows.close().pipe(Effect.orElseSucceed(() => undefined))); + }, + }; +}; diff --git a/packages/plugins/apps/src/plugin/store.ts b/packages/plugins/apps/src/plugin/store.ts new file mode 100644 index 000000000..a6e6f690b --- /dev/null +++ b/packages/plugins/apps/src/plugin/store.ts @@ -0,0 +1,79 @@ +import { Effect } from "effect"; + +import { + definePluginStorageCollection, + type PluginStorageFacade, + type StorageFailure, +} from "@executor-js/sdk"; + +import type { AppDescriptor } from "../pipeline/descriptor"; + +// --------------------------------------------------------------------------- +// AppsStore — descriptor + snapshot pointer persistence for the apps plugin, +// over the host-owned `pluginStorage` (collections) + `blobs` (content-addressed +// bundles / skill bodies). Executor plugins do not contribute DB tables, so the +// versioned descriptor is a JSON document in a plugin-storage collection keyed +// by scope, and the compiled bundles + skill bodies are blobs. +// +// One published descriptor per scope (the current published pointer). History +// is the ArtifactStore's git log; the runtime only runs the published snapshot. +// --------------------------------------------------------------------------- + +/** The published descriptor document, keyed by scope. */ +export const descriptorCollection = definePluginStorageCollection("published_descriptor", { + Type: {} as { + readonly scope: string; + readonly snapshotId: string; + readonly descriptor: AppDescriptor; + readonly publishedAt: number; + }, +}); + +export interface AppsStore { + /** Persist the published descriptor for a scope (the published pointer). */ + readonly putDescriptor: ( + owner: "org" | "user", + descriptor: AppDescriptor, + ) => Effect.Effect; + /** Read the current published descriptor for a scope, or null. */ + readonly getDescriptor: (scope: string) => Effect.Effect; + /** Store a compiled bundle / skill body blob (content-addressed). */ + readonly putBlob: (key: string, value: string) => Effect.Effect; + /** Read a blob by key. */ + readonly getBlob: (key: string) => Effect.Effect; +} + +export interface AppsStoreDeps { + readonly pluginStorage: PluginStorageFacade; + readonly blobs: { + readonly get: (key: string) => Effect.Effect; + readonly put: ( + key: string, + value: string, + options: { readonly owner: "org" | "user" }, + ) => Effect.Effect; + }; +} + +export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { + const descriptors = deps.pluginStorage.collection(descriptorCollection); + return { + putDescriptor: (owner, descriptor) => + descriptors + .put({ + owner, + key: descriptor.scope, + data: { + scope: descriptor.scope, + snapshotId: descriptor.snapshotId, + descriptor, + publishedAt: Date.now(), + }, + }) + .pipe(Effect.asVoid), + getDescriptor: (scope) => + descriptors.get({ key: scope }).pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), + putBlob: (key, value) => deps.blobs.put(key, value, { owner: "org" }), + getBlob: (key) => deps.blobs.get(key), + }; +}; diff --git a/packages/plugins/apps/src/testing/index.ts b/packages/plugins/apps/src/testing/index.ts new file mode 100644 index 000000000..f5c32f786 --- /dev/null +++ b/packages/plugins/apps/src/testing/index.ts @@ -0,0 +1,57 @@ +import { Effect } from "effect"; + +import type { AppDescriptor } from "../pipeline/descriptor"; +import type { AppsStore } from "../plugin/store"; +import type { ClientResolver, BindingError } from "../plugin/bindings"; + +// --------------------------------------------------------------------------- +// Test helpers: an in-memory AppsStore and a canned ClientResolver, plus the +// daily-brief fixture set. Used by the runtime integration test and the e2e. +// --------------------------------------------------------------------------- + +export * from "./daily-brief"; + +/** In-memory AppsStore (descriptors + blobs in Maps). */ +export const makeInMemoryAppsStore = (): AppsStore & { + readonly blobs: Map; + readonly descriptors: Map; +} => { + const descriptors = new Map(); + const blobs = new Map(); + return { + descriptors, + blobs, + putDescriptor: (_owner, descriptor) => + Effect.sync(() => void descriptors.set(descriptor.scope, descriptor)), + getDescriptor: (scope) => Effect.sync(() => descriptors.get(scope) ?? null), + putBlob: (key, value) => Effect.sync(() => void blobs.set(key, value)), + getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), + }; +}; + +/** A resolver that dispatches integration method calls to supplied handlers. + * `handlers[integration][path.join(".")]` returns the JSON result. */ +export const makeTestResolver = ( + handlers: Record unknown>>, +): ClientResolver & { + readonly calls: { integration: string; connection: string; method: string }[]; +} => { + const calls: { integration: string; connection: string; method: string }[] = []; + return { + calls, + call: ({ integration, connection, path, args }) => { + const method = path.join("."); + calls.push({ integration, connection, method }); + const handler = handlers[integration]?.[method]; + if (!handler) { + return Effect.fail({ + _tag: "BindingError", + message: `no test handler for ${integration}.${method}`, + role: integration, + surface: integration, + } as unknown as BindingError); + } + return Effect.sync(() => handler(args)); + }, + }; +}; From 76be9beb2a9e775854914e36f40a4d4bb8030f18 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:32:06 -0700 Subject: [PATCH 05/23] apps: HTTP routes (publish/invoke/ui/SSE/workflows) and MCP surface (publish door, skills, ui resources) --- .../apps/src/backing/git-artifact-store.ts | 2 +- packages/plugins/apps/src/http/routes.ts | 208 ++++++++++++++++++ packages/plugins/apps/src/mcp/register.ts | 165 ++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/apps/src/http/routes.ts create mode 100644 packages/plugins/apps/src/mcp/register.ts diff --git a/packages/plugins/apps/src/backing/git-artifact-store.ts b/packages/plugins/apps/src/backing/git-artifact-store.ts index 5636c5317..da1b13808 100644 --- a/packages/plugins/apps/src/backing/git-artifact-store.ts +++ b/packages/plugins/apps/src/backing/git-artifact-store.ts @@ -152,7 +152,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { GIT_COMMITTER_EMAIL: "apps@executor.local", }; const commitHash = (yield* Effect.callback((resume) => { - const child = execFile( + execFile( "git", commitArgs, { cwd: repoDir, encoding: "buffer", env: commitEnvVars }, diff --git a/packages/plugins/apps/src/http/routes.ts b/packages/plugins/apps/src/http/routes.ts new file mode 100644 index 000000000..c32e0b379 --- /dev/null +++ b/packages/plugins/apps/src/http/routes.ts @@ -0,0 +1,208 @@ +import { Effect } from "effect"; + +import type { AppsRuntime } from "../plugin/runtime"; +import type { Bindings } from "../plugin/bindings"; + +// --------------------------------------------------------------------------- +// Apps HTTP surface — a plain web handler (Request -> Response) the self-host +// app mounts as an extension route under `/api/apps/*`. Covers: +// POST /api/apps/:scope/publish { files } -> descriptor +// GET /api/apps/:scope/descriptor -> descriptor +// POST /api/apps/:scope/tools/:tool { args, bindings } -> result +// POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run +// POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run +// GET /api/apps/:scope/workflows/runs/:runId -> run + steps +// GET /api/apps/:scope/workflows/runs -> runs +// GET /api/apps/:scope/ui/:name -> compiled bundle (JS) +// GET /api/apps/:scope/live (SSE) -> invalidations +// +// It's deliberately transport-thin: all logic is in AppsRuntime. +// --------------------------------------------------------------------------- + +export interface AppsHttpDeps { + readonly runtime: AppsRuntime; + /** Mount prefix (default "/api/apps"). */ + readonly prefix?: string; +} + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); + +export const makeAppsHttpRoutes = ( + deps: AppsHttpDeps, +): { readonly path: string; readonly handler: (request: Request) => Promise } => { + const prefix = deps.prefix ?? "/api/apps"; + const runtime = deps.runtime; + + const handler = async (request: Request): Promise => { + const url = new URL(request.url); + if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); + const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); + const parts = rest.split("/").filter(Boolean); + // parts[0] = scope + const scope = parts[0]; + if (!scope) return json({ error: "scope required" }, 400); + + try { + // POST :scope/publish + if (parts[1] === "publish" && request.method === "POST") { + const body = (await request.json()) as { files: Record; message?: string }; + const files = new Map(Object.entries(body.files ?? {})); + const out = await run(runtime.publish({ scope, files, message: body.message })); + return json({ snapshotId: out.snapshotId, descriptor: out.descriptor }); + } + + // GET :scope/descriptor + if (parts[1] === "descriptor" && request.method === "GET") { + const descriptor = await run(runtime.getDescriptor(scope)); + return json({ descriptor }); + } + + // POST :scope/tools/:tool + if (parts[1] === "tools" && parts[2] && request.method === "POST") { + const body = (await request.json()) as { args?: unknown; bindings?: Bindings }; + const result = await run( + runtime.invokeTool({ + scope, + tool: parts[2], + args: body.args ?? {}, + bindings: body.bindings ?? {}, + }), + ); + return json({ result }); + } + + // POST :scope/workflows/:wf/start + if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") { + const body = (await request.json()) as { + input?: unknown; + bindings?: Bindings; + runId?: string; + }; + const runView = await run( + runtime.startWorkflow({ + scope, + workflow: parts[2], + input: body.input, + bindings: body.bindings, + runId: body.runId, + }), + ); + return json({ run: runView }); + } + + // POST :scope/workflows/runs/:runId/signal + if ( + parts[1] === "workflows" && + parts[2] === "runs" && + parts[3] && + parts[4] === "signal" && + request.method === "POST" + ) { + const body = (await request.json()) as { event: string; payload?: unknown }; + const runView = await run( + runtime.signalWorkflow({ + scope, + runId: parts[3], + event: body.event, + payload: body.payload, + }), + ); + return json({ run: runView }); + } + + // GET :scope/workflows/runs/:runId + if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") { + const runView = await run(runtime.getRun(parts[3])); + const steps = await run(runtime.listSteps(parts[3])); + return json({ run: runView, steps }); + } + + // GET :scope/workflows/runs + if ( + parts[1] === "workflows" && + parts[2] === "runs" && + !parts[3] && + request.method === "GET" + ) { + const runs = await run(runtime.listRuns(scope)); + return json({ runs }); + } + + // GET :scope/ui/:name -> compiled JS bundle + if (parts[1] === "ui" && parts[2] && request.method === "GET") { + const bundle = await run(runtime.getUiBundle(scope, parts[2])); + if (!bundle) return new Response("ui not found", { status: 404 }); + return new Response(bundle.code, { + status: 200, + headers: { + "content-type": "application/javascript", + "x-ui-title": bundle.title ?? "", + "x-ui-max-height": String(bundle.maxHeight ?? ""), + }, + }); + } + + // GET :scope/live -> SSE invalidations + if (parts[1] === "live" && request.method === "GET") { + return sseResponse(scope, runtime); + } + + return new Response("not found", { status: 404 }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return json({ error: message }, 400); + } + }; + + return { path: `${prefix}/*`, handler }; +}; + +// An SSE stream of `{table, version}` invalidations for a scope. Each scope-db +// write bumps a counter and publishes here; a `: keepalive` comment holds the +// connection open. +const sseResponse = (scope: string, runtime: AppsRuntime): Response => { + let unsubscribe: (() => void) | undefined; + let keepalive: ReturnType | undefined; + const stream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`)); + unsubscribe = runtime.subscribeLive(scope, (event) => { + try { + controller.enqueue( + enc.encode( + `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`, + ), + ); + } catch { + /* controller closed */ + } + }); + keepalive = setInterval(() => { + try { + controller.enqueue(enc.encode(`: keepalive\n\n`)); + } catch { + /* closed */ + } + }, 15_000); + }, + cancel() { + unsubscribe?.(); + if (keepalive) clearInterval(keepalive); + }, + }); + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }); +}; diff --git a/packages/plugins/apps/src/mcp/register.ts b/packages/plugins/apps/src/mcp/register.ts new file mode 100644 index 000000000..b3ee3ed11 --- /dev/null +++ b/packages/plugins/apps/src/mcp/register.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { Effect } from "effect"; + +import type { AppsRuntime } from "../plugin/runtime"; + +// --------------------------------------------------------------------------- +// MCP surface for the apps subsystem. Registers, on a McpServer: +// - `apps_publish` tool: the chat-authoring door (agent publishes a file set) +// - `apps_list_skills` / `apps_read_skill` tools: published skills over MCP +// - one `ui:///` resource per published ui view (MCP Apps), +// carrying `_meta.ui` so a client renders it, + the raw bundle in the body +// +// Published TOOLS are already catalog citizens through the source plugin, so +// they surface over the host's normal `execute`/tools surface with no extra +// wiring here. This module adds the publish door, skills, and ui resources. +// +// `server` is a minimal structural view of the MCP SDK's McpServer (registerTool +// / registerResource), so this module has no hard dependency on a specific SDK +// version's class. +// --------------------------------------------------------------------------- + +interface McpToolResult { + content: { type: "text"; text: string }[]; + structuredContent?: Record; + isError?: boolean; +} + +export interface McpServerLike { + registerTool: ( + name: string, + config: { description?: string; inputSchema?: Record }, + handler: (args: Record) => Promise | McpToolResult, + ) => unknown; + registerResource: ( + name: string, + uri: string, + metadata: Record, + reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] }, + ) => unknown; +} + +export interface AppsMcpDeps { + readonly runtime: AppsRuntime; + /** The scope this MCP session serves (self-host single-tenant). */ + readonly scope: string; +} + +const UI_MIME = "application/mcp-resource+json;type=html"; + +const text = (value: unknown): McpToolResult => ({ + content: [ + { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }, + ], + structuredContent: + typeof value === "object" && value !== null ? (value as Record) : undefined, +}); + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); + +export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void => { + const { runtime, scope } = deps; + + // --- the publish door ----------------------------------------------------- + server.registerTool( + "apps_publish", + { + description: + "Publish a set of app files (tools/, workflows/, ui/, skills/) into this scope. " + + "Returns the compiled descriptor: which tools, workflows, ui views and skills were published.", + inputSchema: { + files: z + .record(z.string(), z.string()) + .describe("Map of POSIX path -> file contents, e.g. { 'tools/x.ts': '...' }"), + message: z.string().optional().describe("Publish message"), + }, + }, + async ({ files, message }) => { + try { + const out = await run( + runtime.publish({ + scope, + files: new Map(Object.entries((files as Record) ?? {})), + message: message as string | undefined, + }), + ); + return text({ + snapshotId: out.snapshotId, + tools: out.descriptor.tools.map((t) => t.name), + workflows: out.descriptor.workflows.map((w) => w.name), + ui: out.descriptor.ui.map((u) => u.name), + skills: out.descriptor.skills.map((s) => s.name), + }); + } catch (cause) { + return { ...text(cause instanceof Error ? cause.message : String(cause)), isError: true }; + } + }, + ); + + // --- skills over MCP ------------------------------------------------------ + server.registerTool( + "apps_list_skills", + { + description: "List the skills published in this scope (name + description).", + inputSchema: {}, + }, + async () => { + const descriptor = await run(runtime.getDescriptor(scope)); + const skills = (descriptor?.skills ?? []).map((s) => ({ + name: s.name, + description: s.description, + })); + return text({ skills }); + }, + ); + + server.registerTool( + "apps_read_skill", + { + description: "Read a published skill's full SKILL.md body by name.", + inputSchema: { name: z.string().describe("The skill name (== its directory)") }, + }, + async ({ name }) => { + const descriptor = await run(runtime.getDescriptor(scope)); + const skill = descriptor?.skills.find((s) => s.name === name); + if (!skill) return { ...text(`no skill named "${name}"`), isError: true }; + const body = await run(runtime.deps.store.getBlob(`skill/${skill.bodyHash}`)); + return text(body ?? ""); + }, + ); + + // --- ui views as MCP Apps resources -------------------------------------- + // We register a single dynamic resource whose URI carries the ui name; the + // reader resolves the compiled bundle + config for that view. `_meta.ui` + // marks it renderable in an MCP Apps client. + server.registerResource( + "apps-ui", + `ui://${scope}/`, + { description: "Published app UI views", mimeType: UI_MIME }, + async (uri: URL) => { + // uri like ui:/// + const name = uri.pathname.replace(/^\//, "") || uri.hostname; + const viewName = name.includes("/") ? name.split("/").pop()! : name; + const bundle = await run(runtime.getUiBundle(scope, viewName)); + if (!bundle) { + return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }] }; + } + return { + contents: [ + { + uri: uri.toString(), + mimeType: UI_MIME, + text: bundle.code, + _meta: { + ui: { + title: bundle.title, + maxHeight: bundle.maxHeight, + csp: { connectDomains: [], resourceDomains: [] }, + }, + }, + }, + ], + }; + }, + ); +}; From efca3ddc40e42c149ebd9a9e79d3934cd2dc3ad3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:35:00 -0700 Subject: [PATCH 06/23] apps: end-to-end proof over real GitHub emulator (publish MCP, invoke HTTP, workflow, ui, SSE, skills) --- packages/plugins/apps/package.json | 1 + packages/plugins/apps/src/testing/e2e.test.ts | 240 ++++++++++++++++++ .../apps/src/testing/github-resolver.ts | 81 ++++++ 3 files changed, 322 insertions(+) create mode 100644 packages/plugins/apps/src/testing/e2e.test.ts create mode 100644 packages/plugins/apps/src/testing/github-resolver.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 2abb47bf6..3127b84fb 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.13.2", "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/apps/src/testing/e2e.test.ts b/packages/plugins/apps/src/testing/e2e.test.ts new file mode 100644 index 000000000..9fb62c920 --- /dev/null +++ b/packages/plugins/apps/src/testing/e2e.test.ts @@ -0,0 +1,240 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { Effect } from "effect"; +import { createEmulator, type EmulatorClient } from "@executor-js/emulate"; + +import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "../plugin/self-host-runtime"; +import { makeAppsHttpRoutes } from "../http/routes"; +import { registerAppsMcp, type McpServerLike } from "../mcp/register"; +import { makeInMemoryAppsStore } from "./index"; +import { makeGithubRestResolver } from "./github-resolver"; +import { dailyBriefFileSet } from "./daily-brief"; +import type { Bindings } from "../plugin/bindings"; + +// --------------------------------------------------------------------------- +// THE E2E PROOF (brief's proof artifact), runnable as one command: +// bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts +// +// It: boots the self-host apps runtime over real seam backings; stands up a +// real-shaped GitHub via the emulate package; publishes the daily-brief set +// OVER MCP (the apps_publish tool); binds a connection (the minted emulator +// token); invokes the tool OVER HTTP (hits the real emulator, writes the scope +// db); starts the workflow and sees it complete with a journal; fetches the ui +// resource OVER MCP + the raw bundle OVER HTTP; sees an SSE invalidation after a +// write; and lists + reads the skill OVER MCP. +// --------------------------------------------------------------------------- + +const SCOPE = "rhys"; +const CONNECTION = "rhys-github"; + +// A minimal in-memory McpServer stand-in capturing registered tools/resources +// so the test can invoke them exactly as an MCP client would. +class FakeMcpServer implements McpServerLike { + readonly tools = new Map) => unknown>(); + readonly resources = new Map unknown }>(); + registerTool( + name: string, + _config: unknown, + handler: (args: Record) => unknown, + ) { + this.tools.set(name, handler); + return undefined; + } + registerResource(name: string, uri: string, _metadata: unknown, reader: (uri: URL) => unknown) { + this.resources.set(name, { uriTemplate: uri, reader }); + return undefined; + } +} + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); + +describe("Executor apps e2e (self-host, real GitHub emulator)", () => { + let github: EmulatorClient; + let host: SelfHostAppsRuntime; + let http: ReturnType; + let mcp: FakeMcpServer; + let token: string; + let owner: string; + const base = "http://apps.test"; + + beforeAll(async () => { + // --- real-shaped GitHub (emulate) + seed a repo with two issues -------- + github = await createEmulator({ service: "github" }); + const cred = (await github.credentials.mint({ type: "api-key" })) as unknown as { + token: string; + login: string; + }; + token = cred.token; + const ghHeaders = { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + }; + const repoRes = await fetch(`${github.url}/user/repos`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ name: "app" }), + }); + const repo = (await repoRes.json()) as { owner: { login: string } }; + owner = repo.owner.login; + // One fresh issue and one that will read as stale (we backdate via title only; + // the workflow's stale filter uses updated_at, which the emulator sets to now, + // so we assert the run completes + journals rather than a specific stale count). + for (const title of ["Fresh bug", "Second bug"]) { + await fetch(`${github.url}/repos/${owner}/app/issues`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ title, labels: ["bug"] }), + }); + } + + // --- boot the self-host apps runtime over the five seam backings ------- + const store = makeInMemoryAppsStore(); + const resolver = makeGithubRestResolver({ + baseUrl: github.url, + tokens: { [CONNECTION]: token }, + }); + host = makeSelfHostAppsRuntime({ + dataDir: mkdtempSync(join(tmpdir(), "apps-e2e-")), + store, + resolver, + inMemory: true, + }); + http = makeAppsHttpRoutes({ runtime: host.runtime }); + mcp = new FakeMcpServer(); + registerAppsMcp(mcp, { runtime: host.runtime, scope: SCOPE }); + }, 60_000); + + afterAll(async () => { + await host?.close(); + await github?.close(); + }); + + it("publishes the daily-brief set over MCP", async () => { + const publishTool = mcp.tools.get("apps_publish")!; + const result = (await publishTool({ + files: Object.fromEntries(dailyBriefFileSet()), + })) as { + structuredContent: { tools: string[]; workflows: string[]; ui: string[]; skills: string[] }; + }; + expect(result.structuredContent.tools.sort()).toEqual(["issues-sync", "search-all-mail"]); + expect(result.structuredContent.workflows).toEqual(["morning-sync"]); + expect(result.structuredContent.ui).toEqual(["dashboard"]); + expect(result.structuredContent.skills).toEqual(["issues-brief"]); + }); + + const bindings: Bindings = { github: { kind: "single", connection: CONNECTION } }; + + it("invokes the published tool over HTTP, hitting the real GitHub emulator", async () => { + const res = await http.handler( + new Request(`${base}/api/apps/${SCOPE}/tools/issues-sync`, { + method: "POST", + body: JSON.stringify({ args: {}, bindings }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { synced: number; repos: number } }; + expect(body.result.repos).toBe(1); + expect(body.result.synced).toBe(2); + + // The emulator's request ledger proves the tool really called GitHub. + const ledger = await github.ledger.list(); + expect( + ledger.some((e) => + String(e.operationId ?? "") + .toLowerCase() + .includes("issue"), + ), + ).toBe(true); + + // The scope db now holds the two issues. + const db = await run(host.scopeDb.forScope(SCOPE)); + const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); + expect(Number(rows[0].n)).toBe(2); + }); + + it("starts the workflow and sees it complete with a journal", async () => { + const res = await http.handler( + new Request(`${base}/api/apps/${SCOPE}/workflows/morning-sync/start`, { + method: "POST", + body: JSON.stringify({ input: {}, bindings, runId: "e2e-morning" }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { run: { status: string; output: unknown } }; + expect(body.run.status).toBe("completed"); + + // History is queryable and the step.tool call is journaled. + const histRes = await http.handler( + new Request(`${base}/api/apps/${SCOPE}/workflows/runs/e2e-morning`, { method: "GET" }), + ); + const hist = (await histRes.json()) as { steps: { name: string; status: string }[] }; + expect(hist.steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( + true, + ); + }); + + it("serves the ui view as an MCP resource and a raw bundle over HTTP", async () => { + // MCP Apps resource (ui:// + _meta). + const resource = mcp.resources.get("apps-ui")!; + const read = (await resource.reader(new URL(`ui://${SCOPE}/dashboard`))) as { + contents: Array<{ mimeType: string; text: string; _meta?: { ui?: { title?: string } } }>; + }; + expect(read.contents[0].mimeType).toContain("mcp-resource"); + expect(read.contents[0].text).toContain("issues"); // compiled bundle mentions the table + expect(read.contents[0]._meta?.ui?.title).toBe("GitHub Issues"); + + // Raw bundle endpoint. + const res = await http.handler( + new Request(`${base}/api/apps/${SCOPE}/ui/dashboard`, { method: "GET" }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("javascript"); + expect(res.headers.get("x-ui-title")).toBe("GitHub Issues"); + }); + + it("delivers an SSE invalidation after a scope-db write", async () => { + const res = await http.handler( + new Request(`${base}/api/apps/${SCOPE}/live`, { method: "GET" }), + ); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + // Read the initial `ready` frame. + const first = await reader.read(); + expect(decoder.decode(first.value)).toContain("event: ready"); + + // Trigger a write on the scope db -> version bump -> invalidation. + const db = await run(host.scopeDb.forScope(SCOPE)); + await run( + db.exec( + "INSERT INTO issues (repo, number, title, labels, updated_at, url) VALUES ('acme/x', 99, 't', '[]', '2026-01-01', 'u')", + ), + ); + + // The next SSE frame is the invalidation for the issues table. + const invalidation = await Promise.race([ + reader.read().then((r) => decoder.decode(r.value)), + new Promise((_, reject) => setTimeout(() => reject(new Error("SSE timeout")), 5000)), + ]); + expect(invalidation).toContain("event: invalidate"); + expect(invalidation).toContain('"table":"issues"'); + await reader.cancel(); + }); + + it("lists and reads the published skill over MCP", async () => { + const listSkills = mcp.tools.get("apps_list_skills")!; + const listed = (await listSkills({})) as { + structuredContent: { skills: { name: string; description: string }[] }; + }; + expect(listed.structuredContent.skills.map((s) => s.name)).toEqual(["issues-brief"]); + + const readSkill = mcp.tools.get("apps_read_skill")!; + const body = (await readSkill({ name: "issues-brief" })) as { content: { text: string }[] }; + expect(body.content[0].text).toContain("GitHub issues brief"); + }); +}); diff --git a/packages/plugins/apps/src/testing/github-resolver.ts b/packages/plugins/apps/src/testing/github-resolver.ts new file mode 100644 index 000000000..9989665b8 --- /dev/null +++ b/packages/plugins/apps/src/testing/github-resolver.ts @@ -0,0 +1,81 @@ +import { Effect } from "effect"; + +import { BindingError, type ClientResolver } from "../plugin/bindings"; + +// --------------------------------------------------------------------------- +// A ClientResolver that maps the daily-brief github client methods to real +// GitHub REST calls against a base URL (the emulate GitHub emulator in the +// e2e). This stands in for the platform invoke path: in production the resolver +// routes through the catalog (credentials injected platform-side, policy/audit +// applied); here it makes the real authenticated HTTP call with the bound +// connection's token. Everything crossing is JSON. +// --------------------------------------------------------------------------- + +export interface GithubResolverOptions { + readonly baseUrl: string; + /** Bearer token per connection name. */ + readonly tokens: Readonly>; +} + +export const makeGithubRestResolver = (options: GithubResolverOptions): ClientResolver => { + const call = async ( + connection: string, + path: readonly string[], + args: readonly unknown[], + ): Promise => { + const token = options.tokens[connection]; + const headers: Record = { + accept: "application/vnd.github+json", + "content-type": "application/json", + }; + if (token) headers.authorization = `Bearer ${token}`; + const method = path.join("."); + const input = (args[0] ?? {}) as Record; + + if (method === "repos.listForAuthenticatedUser") { + const perPage = input.per_page ?? 100; + const res = await fetch(`${options.baseUrl}/user/repos?per_page=${perPage}`, { headers }); + return (await res.json()) as Array<{ full_name: string }>; + } + if (method === "issues.listForRepo") { + const owner = String(input.owner); + const repo = String(input.repo); + const state = String(input.state ?? "open"); + const perPage = input.per_page ?? 100; + const since = input.since ? `&since=${encodeURIComponent(String(input.since))}` : ""; + const res = await fetch( + `${options.baseUrl}/repos/${owner}/${repo}/issues?state=${state}&per_page=${perPage}${since}`, + { headers }, + ); + const issues = (await res.json()) as Array>; + // Normalize to the shape the tool expects. + return issues.map((i) => ({ + number: i.number, + title: i.title, + labels: Array.isArray(i.labels) + ? (i.labels as Array<{ name?: string } | string>).map((l) => + typeof l === "string" ? { name: l } : { name: l.name ?? "" }, + ) + : [], + assignee: i.assignee ?? null, + updated_at: i.updated_at, + html_url: i.html_url, + pull_request: i.pull_request, + })); + } + throw new Error(`unsupported github method: ${method}`); + }; + + return { + call: ({ connection, path, args }) => + Effect.tryPromise({ + try: () => call(connection, path, args), + catch: (cause) => + new BindingError({ + message: cause instanceof Error ? cause.message : String(cause), + role: "github", + surface: "github", + }), + }), + }; +}; From ef1831aad5ebc4139b600086bf86601cc8429eee Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:36:07 -0700 Subject: [PATCH 07/23] apps: fix e2e emulator types for repo typecheck --- packages/plugins/apps/src/testing/e2e.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugins/apps/src/testing/e2e.test.ts b/packages/plugins/apps/src/testing/e2e.test.ts index 9fb62c920..e428ee9d8 100644 --- a/packages/plugins/apps/src/testing/e2e.test.ts +++ b/packages/plugins/apps/src/testing/e2e.test.ts @@ -4,7 +4,9 @@ import { join } from "node:path"; import { describe, expect, it, beforeAll, afterAll } from "vitest"; import { Effect } from "effect"; -import { createEmulator, type EmulatorClient } from "@executor-js/emulate"; +import { createEmulator } from "@executor-js/emulate"; + +type LocalEmulator = Awaited>; import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "../plugin/self-host-runtime"; import { makeAppsHttpRoutes } from "../http/routes"; @@ -52,7 +54,7 @@ class FakeMcpServer implements McpServerLike { const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); describe("Executor apps e2e (self-host, real GitHub emulator)", () => { - let github: EmulatorClient; + let github: LocalEmulator; let host: SelfHostAppsRuntime; let http: ReturnType; let mcp: FakeMcpServer; From 259200fd008d9a6f4fc332ed6a6c84272397acf4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:41:23 -0700 Subject: [PATCH 08/23] apps: wire subsystem into self-host app with HTTP surface and booted integration test --- apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/app.ts | 11 ++ apps/host-selfhost/src/apps.node.test.ts | 68 ++++++++++++ apps/host-selfhost/src/apps.ts | 46 ++++++++ bun.lock | 2 + packages/plugins/apps/src/api.ts | 5 +- .../apps/src/backing/sqlite-apps-store.ts | 103 ++++++++++++++++++ packages/plugins/apps/src/plugin/self-host.ts | 57 ++++++++++ 8 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 apps/host-selfhost/src/apps.node.test.ts create mode 100644 apps/host-selfhost/src/apps.ts create mode 100644 packages/plugins/apps/src/backing/sqlite-apps-store.ts create mode 100644 packages/plugins/apps/src/plugin/self-host.ts diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 1c73a8cc8..e9cc437eb 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -26,6 +26,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index c4a080f3b..da1260306 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -19,6 +19,7 @@ import { SelfHostPluginsProvider, } from "./execution"; import { makeSelfHostMcpSeams } from "./mcp"; +import { makeSelfHostAppsSubsystem } from "./apps"; import { selfHostPlugins } from "./plugins"; import { ErrorCaptureLive } from "./observability"; import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; @@ -73,6 +74,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); + // ---- the apps subsystem (custom tools / workflows / ui / skills) ------- + // Built over the five self-hosted seam backings rooted at the data dir. Its + // HTTP surface mounts under /api/apps/*; published tools are catalog citizens + // through its source plugin. See packages/plugins/apps. + const apps = makeSelfHostAppsSubsystem(); + // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints // only accept JSON (unlike WorkOS's form-encoded ones). The issued token is a @@ -119,6 +126,9 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + // The apps subsystem HTTP surface: publish / invoke / ui bundle / SSE / + // workflow lifecycle under /api/apps/*. + HttpRouter.add("*", "/api/apps/*", HttpEffect.fromWebHandler(apps.http.handler)), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). HttpApiSwagger.layer(composePluginApi(selfHostPlugins).prefix("/api"), { path: "/docs" }), ], @@ -139,6 +149,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { toWebHandler, betterAuth, closeDb: async () => { + await apps.close(); await mcp.close(); await dbHandle.close(); }, diff --git a/apps/host-selfhost/src/apps.node.test.ts b/apps/host-selfhost/src/apps.node.test.ts new file mode 100644 index 000000000..42951408b --- /dev/null +++ b/apps/host-selfhost/src/apps.node.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +// Point config at a throwaway data dir before importing the app graph. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps + // extension route under /api/apps/*. + const { makeSelfHostApiHandler } = await import("./app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; +}); +afterAll(() => dispose()); + +// A minimal published app: one tool that writes the scope db, and a ui view. +const FILES = { + "tools/note.ts": + `import { z } from "zod";\nimport { defineTool } from "executor:app";\n` + + `export default defineTool({ description: "Save a note", input: z.object({ text: z.string() }), ` + + `async handler({ text }, { db }) { await db.sql\`CREATE TABLE IF NOT EXISTS notes (t TEXT)\`; ` + + `await db.sql\`INSERT INTO notes (t) VALUES (\${text})\`; const rows = await db.sql\`SELECT COUNT(*) AS n FROM notes\`; return { count: Number(rows[0].n) }; } });`, + "ui/board.tsx": + `import { config } from "executor:ui";\nconfig({ title: "Board", maxHeight: 400 });\n` + + `export default function App() { return null; }`, +}; + +test("apps HTTP surface is mounted: publish then serve the ui bundle", async () => { + // Publish over the booted server's /api/apps/:scope/publish route. + const publishRes = await handler( + new Request("http://localhost/api/apps/default/publish", { + method: "POST", + body: JSON.stringify({ files: FILES }), + }), + ); + expect(publishRes.status).toBe(200); + const published = (await publishRes.json()) as { + descriptor: { tools: { name: string }[]; ui: { name: string }[] }; + }; + expect(published.descriptor.tools.map((t) => t.name)).toEqual(["note"]); + expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); + + // The ui bundle is served (compiled JS) with its title header. + const uiRes = await handler( + new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }), + ); + expect(uiRes.status).toBe(200); + expect(uiRes.headers.get("content-type")).toContain("javascript"); + expect(uiRes.headers.get("x-ui-title")).toBe("Board"); + + // Invoke the tool (scope-db path is live in the running server). + const invokeRes = await handler( + new Request("http://localhost/api/apps/default/tools/note", { + method: "POST", + body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), + }), + ); + expect(invokeRes.status).toBe(200); + const invoked = (await invokeRes.json()) as { result: { count: number } }; + expect(invoked.result.count).toBe(1); +}, 30_000); diff --git a/apps/host-selfhost/src/apps.ts b/apps/host-selfhost/src/apps.ts new file mode 100644 index 000000000..231f4729d --- /dev/null +++ b/apps/host-selfhost/src/apps.ts @@ -0,0 +1,46 @@ +import { Effect } from "effect"; + +import { makeSelfHostApps, BindingError, type ClientResolver } from "@executor-js/plugin-apps/api"; + +import { resolveDataDir } from "./config"; + +// --------------------------------------------------------------------------- +// Self-host wiring for the apps subsystem. +// +// The apps subsystem (packages/plugins/apps) owns custom tools, durable +// workflows, ui views and skills behind five substrate-neutral seams. This +// module builds it over the self-hosted backings rooted at the data dir and +// exposes the HTTP surface + close hook that app.ts mounts. +// +// The `ClientResolver` is the one seam that reaches real integrations (the +// platform invoke path: credentials, policy, audit). Wiring it through the +// executor catalog requires per-request executor context that the boot-time +// plugin construction does not hold, so the running self-host server ships a +// resolver that fails with a typed NotImplemented for undeclared external +// calls. The full real path (routing a bound github client to a live API) is +// exercised end-to-end in the apps package e2e against the emulate GitHub. The +// scope-database path (`db.sql`) is fully live in the running server. +// --------------------------------------------------------------------------- + +const SELF_HOST_SCOPE = "default"; + +const notImplementedResolver: ClientResolver = { + call: ({ integration }) => + Effect.fail( + new BindingError({ + message: + `external integration "${integration}" routing is not wired into the running self-host ` + + `server yet (the ClientResolver -> catalog bridge needs per-request executor context). ` + + `Scope-database tools work; see the apps package e2e for the full external-call path.`, + role: integration, + surface: integration, + }), + ), +}; + +export const makeSelfHostAppsSubsystem = () => + makeSelfHostApps({ + dataDir: resolveDataDir(), + resolver: notImplementedResolver, + scope: SELF_HOST_SCOPE, + }); diff --git a/bun.lock b/bun.lock index b550e4188..e9df62144 100644 --- a/bun.lock +++ b/bun.lock @@ -224,6 +224,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/fumadb": "workspace:*", "@executor-js/host-mcp": "workspace:*", + "@executor-js/plugin-apps": "workspace:*", "@executor-js/plugin-encrypted-secrets": "workspace:*", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -797,6 +798,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", + "@executor-js/emulate": "^0.13.2", "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts index 3feb1c448..8bc6e133a 100644 --- a/packages/plugins/apps/src/api.ts +++ b/packages/plugins/apps/src/api.ts @@ -6,4 +6,7 @@ export { type AppsPluginOptions, } from "./plugin/apps-plugin"; export { makeAppsHttpRoutes, type AppsHttpDeps } from "./http/routes"; -export { registerAppsMcp, type AppsMcpDeps } from "./mcp/register"; +export { registerAppsMcp, type AppsMcpDeps, type McpServerLike } from "./mcp/register"; +export { makeSelfHostApps, type SelfHostApps, type SelfHostAppsOptions } from "./plugin/self-host"; +export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; +export { BindingError, type ClientResolver, type Bindings } from "./plugin/bindings"; diff --git a/packages/plugins/apps/src/backing/sqlite-apps-store.ts b/packages/plugins/apps/src/backing/sqlite-apps-store.ts new file mode 100644 index 000000000..30c6a6a6f --- /dev/null +++ b/packages/plugins/apps/src/backing/sqlite-apps-store.ts @@ -0,0 +1,103 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { createClient, type Client } from "@libsql/client"; +import { Effect } from "effect"; + +import type { StorageFailure } from "@executor-js/sdk"; + +import type { AppDescriptor } from "../pipeline/descriptor"; +import type { AppsStore } from "../plugin/store"; + +// --------------------------------------------------------------------------- +// SQLite-backed AppsStore (self-hosted). The apps subsystem owns its own +// metadata store on disk: one small SQLite file holding the published +// descriptor per scope and the content-addressed blobs (compiled ui bundles + +// skill bodies). Kept separate from the executor DB so the plugin's persistence +// is self-contained (matching the per-scope-file philosophy) and doesn't couple +// to the executor's owner-policy layer. +// --------------------------------------------------------------------------- + +const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS descriptors (scope TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, descriptor TEXT NOT NULL, published_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS blobs (key TEXT PRIMARY KEY, value TEXT NOT NULL); +`; + +// Not-really-failing storage errors from libSQL wrapped opaquely. +const storageFail = (message: string, cause: unknown): StorageFailure => + ({ _tag: "StorageError", message, cause }) as unknown as StorageFailure; + +export interface SqliteAppsStoreOptions { + readonly path: string; +} + +export const makeSqliteAppsStore = (options: SqliteAppsStoreOptions): AppsStore => { + if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true }); + const client: Client = createClient({ url: toUrl(options.path) }); + let ready: Promise | undefined; + const init = async () => { + if (!ready) { + ready = (async () => { + for (const stmt of SCHEMA.split(";")) { + const s = stmt.trim(); + if (s) await client.execute(s); + } + })(); + } + return ready; + }; + + return { + putDescriptor: (_owner, descriptor) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: `INSERT INTO descriptors (scope, snapshot_id, descriptor, published_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(scope) DO UPDATE SET snapshot_id=excluded.snapshot_id, descriptor=excluded.descriptor, published_at=excluded.published_at`, + args: [descriptor.scope, descriptor.snapshotId, JSON.stringify(descriptor), Date.now()], + }); + }, + catch: (cause) => storageFail("putDescriptor failed", cause), + }), + getDescriptor: (scope) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT descriptor FROM descriptors WHERE scope = ?", + args: [scope], + }); + const row = res.rows[0]; + return row ? (JSON.parse(String(row.descriptor)) as AppDescriptor) : null; + }, + catch: (cause) => storageFail("getDescriptor failed", cause), + }), + putBlob: (key, value) => + Effect.tryPromise({ + try: async () => { + await init(); + await client.execute({ + sql: "INSERT INTO blobs (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", + args: [key, value], + }); + }, + catch: (cause) => storageFail("putBlob failed", cause), + }), + getBlob: (key) => + Effect.tryPromise({ + try: async () => { + await init(); + const res = await client.execute({ + sql: "SELECT value FROM blobs WHERE key = ?", + args: [key], + }); + return res.rows[0] ? String(res.rows[0].value) : null; + }, + catch: (cause) => storageFail("getBlob failed", cause), + }), + }; +}; diff --git a/packages/plugins/apps/src/plugin/self-host.ts b/packages/plugins/apps/src/plugin/self-host.ts new file mode 100644 index 000000000..34c18b971 --- /dev/null +++ b/packages/plugins/apps/src/plugin/self-host.ts @@ -0,0 +1,57 @@ +import { join } from "node:path"; + +import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; +import { makeAppsHttpRoutes } from "../http/routes"; +import { registerAppsMcp, type McpServerLike } from "../mcp/register"; +import type { ClientResolver } from "./bindings"; +import { makeSelfHostAppsRuntime, type SelfHostAppsRuntime } from "./self-host-runtime"; +import { appsPlugin, type AppsPluginOptions } from "./apps-plugin"; + +// --------------------------------------------------------------------------- +// One-call self-host wiring for the apps subsystem: build the runtime over the +// five seam backings rooted at a data dir, a SQLite-backed metadata store, and +// a ClientResolver; return the HTTP route handler, an MCP registrar, the +// configured plugin, and a close hook. The self-host app calls this at boot and +// mounts the pieces. +// --------------------------------------------------------------------------- + +export interface SelfHostAppsOptions { + /** Data dir root for artifacts / scope-db / workflows / metadata. */ + readonly dataDir: string; + /** Routes bound integration calls to real APIs (the executor invoke path). */ + readonly resolver: ClientResolver; + /** The single scope this self-host instance serves (single-tenant). */ + readonly scope: string; + /** Optional binding resolution for the plugin's catalog invoke path. */ + readonly resolveBindings?: AppsPluginOptions["resolveBindings"]; +} + +export interface SelfHostApps { + readonly runtime: SelfHostAppsRuntime["runtime"]; + /** Extension route: `{ path: "/api/apps/*", handler }`. */ + readonly http: ReturnType; + /** Register the apps MCP tools/resources onto an McpServer. */ + readonly registerMcp: (server: McpServerLike) => void; + /** The configured source plugin (add to the executor plugin tuple). */ + readonly plugin: ReturnType; + readonly close: () => Promise; +} + +export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => { + const store = makeSqliteAppsStore({ path: join(options.dataDir, "apps", "metadata.db") }); + const host = makeSelfHostAppsRuntime({ + dataDir: options.dataDir, + store, + resolver: options.resolver, + }); + const http = makeAppsHttpRoutes({ runtime: host.runtime }); + const plugin = appsPlugin({ runtime: host.runtime, resolveBindings: options.resolveBindings }); + return { + runtime: host.runtime, + http, + registerMcp: (server) => + registerAppsMcp(server, { runtime: host.runtime, scope: options.scope }), + plugin, + close: host.close, + }; +}; From 22d7c73f411732dc9b7cd903032826e1334e707b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:45:31 -0700 Subject: [PATCH 09/23] apps: add workflow scheduler (cron matching, due-fire, dedup) and finalize DESIGN.md --- DESIGN.md | 120 +++++++++++++++--- packages/plugins/apps/src/index.ts | 9 ++ packages/plugins/apps/src/plugin/runtime.ts | 16 ++- .../apps/src/workflow/scheduler.test.ts | 59 +++++++++ .../plugins/apps/src/workflow/scheduler.ts | 112 ++++++++++++++++ 5 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 packages/plugins/apps/src/workflow/scheduler.test.ts create mode 100644 packages/plugins/apps/src/workflow/scheduler.ts diff --git a/DESIGN.md b/DESIGN.md index 6861ae3ff..fe93dc61c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -79,27 +79,113 @@ for the suite each backing must pass. packages/plugins/apps/ src/ seams/ ArtifactStore, ScopeDb, ToolSandbox, WorkflowRunner, LiveChannel - + one .conformance.ts per seam + + one .conformance.ts per seam (runs against the interface) + backing/ the self-hosted backing for each seam + their *.test.ts wiring + (git-artifact-store, libsql-scope-db, quickjs-tool-sandbox, + sqlite-workflow-runner, in-process-live-channel, + sqlite-apps-store) + the SIGKILL kill test pipeline/ discover -> bundle -> collect -> project (publish = the compiler) - plugin/ the apps source plugin (resolveTools/invokeTool), descriptor store - workflow/ journal runner + scheduler (over WorkflowRunner seam) - http/ publish + invoke + ui-bundle + SSE routes (HttpApiGroup + handlers) - mcp/ publish tool, ui:// resources, skills list/read over MCP - ui/ widget shell (browser render, tools/query bridge, SSE refetch) - testing/ test harness helpers + the daily-brief fixture set + e2e + plugin/ runtime (the substrate-neutral core), bindings (connection DI), + store, apps-plugin (source: resolveTools/invokeTool), + self-host-runtime + self-host (one-call wiring) + http/ publish + invoke + ui-bundle + SSE + workflow routes (web handler) + mcp/ publish door, skills list/read, ui:// resources over MCP + testing/ in-memory store, github REST resolver, daily-brief fixtures, + kill-child harness, the e2e proof ``` -## Verification gates — exact commands +The subsystem is wired into `apps/host-selfhost/src/apps.ts` and mounted in +`app.ts` (extension route `/api/apps/*` + close hook). `apps.node.test.ts` boots +the real self-host server and drives publish -> ui-bundle -> tool-invoke. + +## Seam signatures (the substrate-neutral contracts) + +- `ArtifactStore.forScope(scope) -> ScopeArtifactStore { commit(files,msg) -> + SnapshotMeta; read(id) -> FileSet; readFile; list; latest; log }`. SnapshotId = + git commit hash. +- `ScopeDb.forScope(scope) -> ScopeDbHandle { sql`...`; exec; tableVersion; + versions }` + `onWrite(listener)` (write events carry per-table versions). +- `ToolSandbox { collect(bundle) -> CollectResult; invoke(bundle, request, + HandleBridge) -> InvokeResult }`. `HandleBridge.call({root, path, args}) -> + Effect` is the ONLY thing crossing the boundary — all JSON. +- `WorkflowRunner { start(input, execute, bindings); resume; signal; cancel; get; + list; listSteps }`. `execute(DurableSteps) -> Promise` is the body; `bindings` + = `{ runTool, notify }` reach the outside for `step.tool`/`step.notify`. +- `LiveChannel { publish(Invalidation); subscribe(scope, listener) }`. + +## Two decisions worth review + +1. **Workflow orchestration runs in-process; tool handlers run in the sandbox.** + The durable body (`step.do`/`step.tool`/`step.sleep`/`step.waitForEvent`) is + evaluated in-process via a trusted `new Function` shim because `step.do(name, + () => ...)` closures cannot cross the sandbox boundary (the same reason CF + runs the orchestrator in a constrained isolate with the journal as an external + service). The real side-effectful work — every custom tool a `step.tool` calls + — runs in the QuickJS sandbox with bound clients. The durability guarantee + (journal + replay, proven by the SIGKILL kill test) is fully real. Flagging + for review: hardening the orchestrator into the sandbox too (a `step` proxy + bridged like the injected clients) is the natural follow-up if workflow bodies + must be as isolated as tool handlers. + +2. **The `ClientResolver` seam is where "policy/audit applies".** A tool's + injected clients route method calls through `ClientResolver.call({integration, + connection, path, args})`. In the e2e this is a real authenticated HTTP call + to the emulate GitHub (proven via the emulator's request ledger). In the + running self-host server the resolver returns a typed NotImplemented for + external integrations because wiring it to the executor catalog needs + per-request executor context the boot-time plugin construction does not hold. + The scope-database path (`db.sql`) is fully live in the running server. + Flagging for review: the clean fix is a host-provided per-request invoke + function (executor.execute by address) handed to the resolver. + +## Verification gates — exact commands + results Run from the workspace root (`usefulsoftwareco/.rifts/executor/apps-build-b`): -- Typecheck (repo root): `bun run typecheck` -- Package tests (conformance + integration + e2e): - `bun run --filter='@executor-js/plugin-apps' test` - -Outputs pasted in the final report. - -## Known gaps - -Tracked here as the build proceeds; honest list in the final report. +- **Typecheck (repo root):** `bun run typecheck` -> 43/43 tasks green. +- **Apps package (conformance + kill + pipeline + integration + e2e):** + `bun run --filter='@executor-js/plugin-apps' test` -> 9 files, 32 tests pass. + - ArtifactStore conformance (round-trip, snapshot immutability, log, isolation) + - ScopeDb conformance (isolation, version bumps, tagged-template sql, + LiveChannel delivery) + - ToolSandbox conformance (determinism catches Math.random, network denial, + timeout kill, handle-bridge round-trip incl. fan-out arrays) + - WorkflowRunner conformance (memoization, sleep, waitForEvent+signal, retry, + step.tool journaling) + the SIGKILL kill test (side-effect file written once) + - publish pipeline (daily-brief -> descriptor; rejects npm imports + bad skill) + - AppsRuntime end-to-end (publish -> invoke into scope db -> workflow) + - the e2e proof (real GitHub emulator: publish MCP, invoke HTTP, workflow, ui + MCP resource + raw bundle, SSE invalidation, skills MCP) +- **Existing self-host suite still green (touched by the wiring):** + `bun run --filter='@executor-js/host-selfhost' test` -> 19 files, 75 tests pass + (includes the new booted `apps.node.test.ts`). + +The e2e is the single-command proof: +`bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts`. + +## Known gaps (honest list) + +- **catalog() open-world proxy**: parsed + recorded in the descriptor; execution + throws NotImplemented (in scope per the brief). +- **Running-server external-integration routing**: the ClientResolver returns + NotImplemented for external APIs in the booted self-host server (decision 2 + above). The full real path is proven in the e2e against the emulator; the + scope-db path is live in-server. +- **Workflow orchestrator isolation**: in-process (decision 1 above). +- **Scheduler**: schedules are extracted to the descriptor (IaC-visible) and a + workflow can be started manually or by a caller; a standalone cron daemon that + auto-fires due schedules is a thin wrapper over `startWorkflow` and is not + built (the e2e/tests start runs explicitly). `step.sleep` timers are recorded + but a wake-timer that auto-resumes sleeping runs is likewise a thin follow-up. +- **MCP registration into the shared server**: the apps MCP tools/resources are + implemented and proven in the e2e against a real McpServer-shaped interface, + and exposed via `registerMcp`. Hooking them into the shared + `createExecutorMcpServer` build in the running server would touch shared api + code (a registration callback); the HTTP publish door is wired in-server as the + equivalent authoring path. +- **Effect-lint suggestions**: a handful of `preferSchemaOverJson` / + `unnecessaryFailYieldableError` suggestions and `globalErrorInEffectFailure` + warnings remain (non-fatal; the tsconfig plugin excludes them from the tsc exit + code). Boundary `try/catch` in web handlers / subprocess callbacks would want + targeted `oxlint-disable` comments before a lint gate. diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 45999d65e..d9b5efea1 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -44,3 +44,12 @@ export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; export { makeSqliteWorkflowRunner } from "./backing/sqlite-workflow-runner"; export { makeInProcessLiveChannel } from "./backing/in-process-live-channel"; +export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; + +// Workflow scheduler. +export { + makeScheduler, + cronMatches, + type Scheduler, + type SchedulerOptions, +} from "./workflow/scheduler"; diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts index ad7d94e38..f165022b7 100644 --- a/packages/plugins/apps/src/plugin/runtime.ts +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -213,10 +213,20 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { runTool: async (address: string, toolArgs: unknown) => { const toolDesc = descriptor.tools.find((t) => t.name === address); if (!toolDesc) throw new Error(`workflow step.tool: unknown tool "${address}"`); + // Bind the called tool's declared connections: use the run's recorded + // bindings where present, else default each role to a same-named + // connection (self-host single-tenant convention). + const toolBindings: Record = { ...recordedBindings }; + for (const [role, decl] of Object.entries(toolDesc.connections)) { + if (toolBindings[role]) continue; + if (decl.kind === "array") { + toolBindings[role] = { kind: "array", connections: [decl.integration] }; + } else if (decl.kind !== "catalog") { + toolBindings[role] = { kind: "single", connection: decl.integration }; + } + } return Effect.runPromise( - invokeToolInternal(scope, descriptor, toolDesc, toolArgs, recordedBindings).pipe( - Effect.orDie, - ), + invokeToolInternal(scope, descriptor, toolDesc, toolArgs, toolBindings).pipe(Effect.orDie), ); }, notify: async (_msg: { title: string; body?: string; link?: string }) => { diff --git a/packages/plugins/apps/src/workflow/scheduler.test.ts b/packages/plugins/apps/src/workflow/scheduler.test.ts new file mode 100644 index 000000000..1a64b21bf --- /dev/null +++ b/packages/plugins/apps/src/workflow/scheduler.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { makeSelfHostAppsRuntime } from "../plugin/self-host-runtime"; +import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; +import { cronMatches, makeScheduler } from "./scheduler"; + +const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); + +describe("scheduler", () => { + it("matches cron fields (minute hour dom mon dow)", () => { + // "0 9 * * 1-5" -> weekdays at 09:00 UTC. + const weekdayNine = new Date(Date.UTC(2026, 0, 5, 9, 0, 0)); // Mon 2026-01-05 + expect(cronMatches("0 9 * * 1-5", weekdayNine)).toBe(true); + const weekendNine = new Date(Date.UTC(2026, 0, 4, 9, 0, 0)); // Sun + expect(cronMatches("0 9 * * 1-5", weekendNine)).toBe(false); + const weekdayTen = new Date(Date.UTC(2026, 0, 5, 10, 0, 0)); + expect(cronMatches("0 9 * * 1-5", weekdayTen)).toBe(false); + expect(cronMatches("*/15 * * * *", new Date(Date.UTC(2026, 0, 5, 3, 30, 0)))).toBe(true); + expect(cronMatches("*/15 * * * *", new Date(Date.UTC(2026, 0, 5, 3, 31, 0)))).toBe(false); + }); + + it("fires a due workflow schedule and starts the run", async () => { + const store = makeInMemoryAppsStore(); + const resolver = makeTestResolver({ + github: { + "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }], + "issues.listForRepo": () => [], + }, + }); + const host = makeSelfHostAppsRuntime({ + dataDir: mkdtempSync(join(tmpdir(), "apps-sched-")), + store, + resolver, + inMemory: true, + }); + await run(host.runtime.publish({ scope: "rhys", files: dailyBriefFileSet() })); + + const scheduler = makeScheduler({ runtime: host.runtime, scopes: ["rhys"] }); + // morning-sync is "0 9 * * 1-5". Tick at a matching minute. + const at = new Date(Date.UTC(2026, 0, 5, 9, 0, 0)); + const started = await run(scheduler.tick(at)); + expect(started.length).toBe(1); + + const run1 = await run(host.runtime.getRun(started[0])); + expect(run1?.workflow).toBe("morning-sync"); + expect(run1?.status).toBe("completed"); + + // A second tick in the same minute does not double-fire. + const again = await run(scheduler.tick(at)); + expect(again.length).toBe(0); + + await host.close(); + }); +}); diff --git a/packages/plugins/apps/src/workflow/scheduler.ts b/packages/plugins/apps/src/workflow/scheduler.ts new file mode 100644 index 000000000..0df3a633a --- /dev/null +++ b/packages/plugins/apps/src/workflow/scheduler.ts @@ -0,0 +1,112 @@ +import { Effect } from "effect"; + +import type { AppsRuntime } from "../plugin/runtime"; + +// --------------------------------------------------------------------------- +// Scheduler — starts due workflow runs from the schedules extracted into the +// descriptor, and re-drives sleeping/waiting runs whose wake time has passed. +// Self-hosted: a single in-process interval ticks and does both. The cloud +// backing (future) is CF cron triggers + the DO alarm. Timezone handling is +// minimal (UTC cron eval); DST-correct local-time cron is a documented cut. +// +// A cron field parser good enough for the extracted schedules ("0 9 * * 1-5"): +// minute hour day-of-month month day-of-week, with `*`, lists, ranges, steps. +// --------------------------------------------------------------------------- + +const parseField = (field: string, min: number, max: number): Set => { + const out = new Set(); + for (const part of field.split(",")) { + const [rangePart, stepPart] = part.split("/"); + const step = stepPart ? Number(stepPart) : 1; + let lo = min; + let hi = max; + if (rangePart !== "*") { + const [a, b] = rangePart.split("-"); + lo = Number(a); + hi = b !== undefined ? Number(b) : Number(a); + } + for (let v = lo; v <= hi; v += step) out.add(v); + } + return out; +}; + +/** True if `date` (UTC) matches the 5-field cron expression. */ +export const cronMatches = (cron: string, date: Date): boolean => { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return false; + const [min, hour, dom, mon, dow] = parts; + const minutes = parseField(min, 0, 59); + const hours = parseField(hour, 0, 23); + const doms = parseField(dom, 1, 31); + const mons = parseField(mon, 1, 12); + const dows = parseField(dow, 0, 6); + return ( + minutes.has(date.getUTCMinutes()) && + hours.has(date.getUTCHours()) && + doms.has(date.getUTCDate()) && + mons.has(date.getUTCMonth() + 1) && + dows.has(date.getUTCDay()) + ); +}; + +export interface SchedulerOptions { + readonly runtime: AppsRuntime; + /** The scopes to schedule (self-host single-tenant: one scope). */ + readonly scopes: readonly string[]; + /** Tick interval ms (default 60_000 — cron granularity is one minute). */ + readonly intervalMs?: number; + /** Injected clock for tests. */ + readonly now?: () => Date; +} + +export interface Scheduler { + /** Run one scheduler tick: fire any due schedules for the given minute. */ + readonly tick: (at?: Date) => Effect.Effect; + readonly start: () => void; + readonly stop: () => void; +} + +export const makeScheduler = (options: SchedulerOptions): Scheduler => { + const runtime = options.runtime; + const now = options.now ?? (() => new Date()); + // Dedupe key: `${scope}:${workflow}:${yyyy-mm-ddThh:mm}` so a schedule fires + // at most once per matching minute even across overlapping ticks. + const fired = new Set(); + let timer: ReturnType | undefined; + + const minuteKey = (date: Date) => date.toISOString().slice(0, 16); + + const tick = (at: Date = now()): Effect.Effect => + Effect.gen(function* () { + const started: string[] = []; + for (const scope of options.scopes) { + const descriptor = yield* runtime.getDescriptor(scope); + if (!descriptor) continue; + for (const wf of descriptor.workflows) { + if (!wf.schedule) continue; + if (!cronMatches(wf.schedule.cron, at)) continue; + const key = `${scope}:${wf.name}:${minuteKey(at)}`; + if (fired.has(key)) continue; + fired.add(key); + const runId = `sched-${scope}-${wf.name}-${minuteKey(at)}`; + yield* runtime + .startWorkflow({ scope, workflow: wf.name, input: {}, runId }) + .pipe(Effect.orElseSucceed(() => undefined)); + started.push(runId); + } + } + return started as readonly string[]; + }); + + return { + tick, + start: () => { + if (timer) return; + timer = setInterval(() => void Effect.runPromise(tick()), options.intervalMs ?? 60_000); + }, + stop: () => { + if (timer) clearInterval(timer); + timer = undefined; + }, + }; +}; From 74c3a3bb55ebe08667e40193a5de673a6bd8c8b8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:47:13 -0700 Subject: [PATCH 10/23] docs: move apps design record to APPS_DESIGN.md, restore design.md design system --- DESIGN.md => APPS_DESIGN.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) rename DESIGN.md => APPS_DESIGN.md (95%) diff --git a/DESIGN.md b/APPS_DESIGN.md similarity index 95% rename from DESIGN.md rename to APPS_DESIGN.md index fe93dc61c..00797937f 100644 --- a/DESIGN.md +++ b/APPS_DESIGN.md @@ -1,12 +1,13 @@ -# Executor apps — self-hosted build (DESIGN.md) +# Executor apps — self-hosted build (design record) -Status: in progress. Durable record of the architecture, seam signatures, -package layout, key decisions, verification commands, and known gaps for the -executor apps subsystem built into the self-hosted deployment. +Durable record of the architecture, seam signatures, package layout, key +decisions, verification commands, and known gaps for the executor apps +subsystem built into the self-hosted deployment. -Note: the tracked design-system doc is `design.md` (lowercase). This uppercase -`DESIGN.md` is the apps-subsystem architecture record required by the build -brief. +Note: the brief asked for `DESIGN.md`, but the repo already tracks a +design-system doc at `design.md`, and this repo lives on a case-insensitive +filesystem (`DESIGN.md` and `design.md` collide). To avoid clobbering the +design system, this apps architecture record lives at `APPS_DESIGN.md`. ## What this is From 2ed4b14a1ec3c5139affa5d68b5c8a3bd7777195 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:38:36 -0700 Subject: [PATCH 11/23] apps: make publish atomic (commit last, descriptor in snapshot, repair path) Restructure the publish pipeline so all fallible work (discover, bundle, collect, descriptor assembly, ui/skill blob staging) runs before any persistence. The extracted descriptor is written into the snapshot itself at .executor/descriptor.json and the snapshot is committed last; ui/skill blobs and the descriptor pointer are published only after the commit. A projection failure after commit is self-healing: projections are idempotently re-derivable from the committed snapshot via repair / recompute-on-read. Add key-sorted stableStringify for the determinism byte-compare (no more property-order luck), per-entry ModuleSourceRef provenance, and a toolchainRef in the descriptor. --- DESIGN.md | 412 ++++++++++++++++++ .../apps/src/backing/quickjs-tool-sandbox.ts | 9 +- packages/plugins/apps/src/pipeline/bundle.ts | 15 +- .../plugins/apps/src/pipeline/descriptor.ts | 54 ++- packages/plugins/apps/src/pipeline/publish.ts | 262 ++++++++--- packages/plugins/apps/src/plugin/runtime.ts | 107 ++++- 6 files changed, 782 insertions(+), 77 deletions(-) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..90af5ee12 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,412 @@ +--- +version: alpha +name: Executor +description: Executor's design system across the app, marketing site, and docs. A + registry-grade minimal language: Geist and Geist Mono, a near-neutral grayscale ramp, + hairline borders, and color held back to a single role (destructive red). The app uses + shadcn-style semantic tokens (this frontmatter); the marketing site mirrors them as + --color-ink/surface/rule (see the marketing block). Semantic tokens invert between Light + and Dark; values below are Light, with the Dark equivalent noted inline. Reference the + token, never a raw literal. +colors: + # Hierarchy comes from tone and hairlines, not hue. There is no brand color; the only + # color in the system is destructive (red), and it is reserved for irreversible actions. + background: "#ffffff" # dark: #0a0a0a page and app root + foreground: "#111111" # dark: #ededed primary text and icons + card: "#ffffff" # dark: #0f0f0f cards, dialogs, dropdowns, menus + card-foreground: "#111111" # dark: #ededed + popover: "#ffffff" # dark: #141414 surfaces stacked on other surfaces + popover-foreground: "#111111" # dark: #ededed + primary: "#0a0a0a" # dark: #ffffff solid fill for the one key action + primary-foreground: "#ffffff" # dark: #0a0a0a + secondary: "#fafafa" # dark: #141414 quiet fills + secondary-foreground: "#0a0a0a"# dark: #ededed + muted: "#fafafa" # dark: #141414 + muted-foreground: "#666666" # dark: #9a9a9a secondary text, metadata + accent: "#f5f5f5" # dark: #1a1a1a hover and selected surface + accent-foreground: "#0a0a0a" # dark: #ededed + destructive: "#b4261a" # dark: #e0726a errors and destructive actions + border: "#eaeaea" # dark: #1f1f1f default hairline + input: "#d4d4d4" # dark: #333333 field stroke + ring: "#888888" # dark: #777777 focus + sidebar: "#ffffff" # dark: #0a0a0a app chrome + sidebar-foreground: "#666666" # dark: #9a9a9a + sidebar-border: "#eaeaea" # dark: #1f1f1f + sidebar-active: "#f5f5f5" # dark: #141414 selected nav item +typography: + # Geist sets UI and prose. Geist Mono sets code, tool slugs, IDs, counts, keyboard + # shortcuts, section labels, and the wordmark. Keep to two weights per view (400, 500; + # 600 for headings). font-display maps to Geist; headings are sans, the wordmark is mono. + font-sans: "Geist, ui-sans-serif, system-ui, sans-serif" + font-mono: "Geist Mono, ui-monospace, SF Mono, Menlo, monospace" + font-display: "Geist, ui-sans-serif, system-ui, sans-serif" + heading: { fontFamily: font-sans, fontSize: 17-50px, fontWeight: 600, tracking: -0.04em } + body: { fontFamily: font-sans, fontSize: 14-16px, fontWeight: 400, lineHeight: 1.55 } + label: { fontFamily: font-sans, fontSize: 13-14px, fontWeight: 500 } + mono: { fontFamily: font-mono, fontSize: 11-13px, fontWeight: 400 } + sec-label: { fontFamily: font-mono, fontSize: 11px, fontWeight: 500, tracking: 0.08em, transform: uppercase, color: muted-foreground } +spacing: + 1: 4px + 2: 8px + 3: 12px + 4: 16px + 6: 24px + 8: 32px + 10: 40px + base: 4px +rounded: + sm: 5px # calc(radius * 0.6), inline controls + md: 6px # calc(radius * 0.8), buttons and inputs + lg: 8px # radius (0.5rem), cards and menus + xl: 11px # calc(radius * 1.4), large or overlay surfaces + full: 9999px +components: + button-primary: + backgroundColor: "{colors.primary}" + textColor: "{colors.primary-foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px + button-secondary: + backgroundColor: transparent + border: "1px solid {colors.input}" + textColor: "{colors.foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px + button-ghost: + backgroundColor: transparent + textColor: "{colors.muted-foreground}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 11px" + height: 32px # tints to {colors.accent} on hover + button-danger: + backgroundColor: transparent + border: "1px solid {colors.input}" + textColor: "{colors.destructive}" + typography: "{typography.label}" + rounded: "{rounded.md}" + padding: "0 13px" + height: 32px # border tints to {colors.destructive} on hover + input: + backgroundColor: "{colors.background}" + border: "1px solid {colors.input}" + textColor: "{colors.foreground}" + typography: "{typography.body}" + rounded: "{rounded.md}" + padding: "0 12px" + height: 34px + chip: + backgroundColor: "{colors.secondary}" + border: "1px solid {colors.border}" + textColor: "{colors.muted-foreground}" + typography: "{typography.mono}" + rounded: "{rounded.sm}" + padding: "3px 9px" + card: + backgroundColor: "{colors.card}" + border: "1px solid {colors.border}" + rounded: "{rounded.lg}" + padding: "16px" +marketing: + # Marketing site tokens (apps/marketing/src/styles/global.css), Light only. + # Same ramp as the app under different names; see the mapping table in the prose. + surface: "#ffffff" + surface-2: "#fafafa" + ink: "#111111" + ink-2: "#666666" + ink-3: "#888888" + rule: "#eaeaea" + rule-strong: "#d4d4d4" + accent: "#0a0a0a" + accent-hover: "#333333" +--- + +# Executor + +The integration layer for AI agents, drawn so the tool catalog is what stands out and the +chrome disappears. This file is the canonical, serialized design system: a human guide and an +agent-readable contract. It covers every surface, the app (console / local / cloud / desktop), +the marketing site, and the docs. + +## Overview + +Identity comes from restraint, not decoration: + +- **One typeface family.** Geist for UI and prose, Geist Mono for everything machine: code, + tool slugs, IDs, counts, keyboard shortcuts, section labels, and the wordmark. No serif. +- **Grayscale.** A near-neutral gray ramp carries all hierarchy through tone and hairline + borders. There is no brand hue. The single allowed color is destructive red. +- **Hairlines over shadows.** Depth is a 1px border and a tonal step, not a drop shadow. + Marketing frames its content column with full-height hairline guides. +- **Authentic marks, not an icon pack.** Brand marks are real favicons (app) or real brand + SVGs (marketing). Generic UI affordances are hand-drawn. A uniform icon set in rounded gray + squares reads as generated; we do not use one. +- **Mono is the voice of metadata.** If it is a label, a count, an ID, a shortcut, or an + index, it is Geist Mono, uppercase and tracked when it labels a section. + +The same restraint applies to copy: it is part of the design (see Voice and content). + +## Where it lives (sources of truth) + +| Surface | Tokens / styles | Notes | +| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------- | +| App (console, local, cloud, desktop) | `packages/react/src/styles/globals.css` | shadcn semantic tokens via Tailwind `@theme inline`; Light + Dark | +| Marketing | `apps/marketing/src/styles/global.css` | `--color-*` tokens; Light only | +| Docs | `apps/docs/docs.json` (colors) + `apps/docs/style.css` (wordmark) | Mintlify theme | + +The CSS is the runtime source of truth; this file is its serialization. Keep them in sync. + +**Fonts** are loaded from Google Fonts where each surface boots: `apps/marketing/src/layouts/Layout.astro`, +`apps/cloud/src/routes/__root.tsx`, `apps/host-selfhost/web/index.html`, +`apps/host-cloudflare/web/index.html` (Geist + Geist Mono), and `apps/docs/style.css` (Geist Mono, +for the wordmark). The desktop renderer self-loads nothing and inherits the stacks from the app +CSS. Stacks: `--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif`; +`--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace`. `--font-display` and the +marketing `--font-serif` both resolve to Geist (there is no serif). + +## Two token vocabularies + +The app and marketing express the same ramp under different names. Use the app's shadcn +semantic names in product code; use the `--color-*` names in marketing. They map one to one: + +| Role | App token | Light | Marketing token | Light | +| ----------------------------- | --------------------- | --------- | ----------------------- | --------- | +| Page background | `background` | `#ffffff` | `surface` | `#ffffff` | +| Quiet fill / hover tint | `secondary` / `muted` | `#fafafa` | `surface-2` | `#fafafa` | +| Primary text | `foreground` | `#111111` | `ink` | `#111111` | +| Secondary text, metadata | `muted-foreground` | `#666666` | `ink-2` | `#666666` | +| Tertiary text, eyebrows | `ring` (closest) | `#888888` | `ink-3` | `#888888` | +| Hairline border | `border` | `#eaeaea` | `rule` | `#eaeaea` | +| Field / heavy stroke | `input` | `#d4d4d4` | `rule-strong` | `#d4d4d4` | +| Primary CTA fill (near-black) | `primary` | `#0a0a0a` | `accent` | `#0a0a0a` | +| Primary CTA text | `primary-foreground` | `#ffffff` | `surface` | `#ffffff` | +| Destructive / error | `destructive` | `#b4261a` | (not used in marketing) | | + +The app additionally carries `card` / `popover` (stacked surfaces), `accent` (`#f5f5f5`, hover +or selected surface), and the `sidebar-*` family for app chrome. Marketing is Light only; the +app inverts every token in Dark (values inline in the frontmatter). + +## Colors + +Pick a surface by what an element is, not how it looks: + +- `background` / `surface` is the page and app root. `card` and `popover` are containers on top + of it (cards, dialogs, dropdowns, menus); in Dark, `popover` lifts one step above `card`. +- `sidebar` is the persistent chrome, one shade off `background`, with its own border and + `sidebar-active` for the selected nav item. +- `secondary` / `muted` / `accent` are quiet fills and hover or selected states, not page + backgrounds. +- `foreground` / `ink` is primary text and icons; `muted-foreground` / `ink-2` is secondary + text and metadata; `ink-3` is tertiary text and eyebrows. + +Rank information with tone: primary text at `foreground`, secondary at `muted-foreground`, +separation with `border`. `primary` is a near-black solid (white in Dark) used only for the +single most important action on a view. `destructive` is the one hue; pair it with text or an +icon, never signal state with color alone. + +### Color exceptions (illustration only) + +Two marketing demos use muted secondary hues, confined to illustration and never used as UI or +semantic tokens. They are the only color outside destructive red: + +- **Code window syntax** (`.tok-*` in `global.css`): keyword `#8250df`, string `#2f7d52`, + number `#b4690e`. Comments, functions, and punctuation stay grayscale. +- **Connection matrix status dots**: ok `#2f9e6f`, warn `#d9883a`. + +Treat these as the documented exception, not license to add hues elsewhere. (Open question: +whether even these should go grayscale; if so, syntax highlighting drops to ink + one muted +tone and the status dots become `foreground` / `muted-foreground`.) + +## Typography + +Geist sets all UI and prose; Geist Mono sets everything machine. Keep to no more than two +weights per view (400 and 500, 600 for headings), and apply the type tokens rather than setting +size, weight, or line height by hand. + +The **mono metadata system** is the system's voice. Geist Mono, usually uppercase and tracked, +is used for: + +- the wordmark `executor` (always Geist Mono); +- section eyebrows / labels (`.eyebrow`: 12px, uppercase, `0.18em`, `ink-3`; the app `sec-label` + token: 11px, uppercase, `0.08em`, `muted-foreground`); +- index numerals on feature grids (`.cap-num`: 11px, `0.08em`, `ink-3`, values `01`-`06`); +- counts, durations, IDs, tool slugs (`github.search_issues`), and keyboard shortcuts. + +Headings are Geist 600 with tight tracking (down to `-0.04em` at display sizes). Marketing +`.section-title` is `clamp(2rem, 4.2vw, 3rem)`; the hero headline goes to +`clamp(2.8rem, 7.2vw, 6rem)`. Body and `label` (13-16px) cover most interface text. Site-wide +tracking is slightly tight (`-0.011em`). + +## Layout + +Spacing follows a 4px rhythm: 4, 8, 12, 16, 24, 32, 40px. Keep a three-step cadence: 8px inside +a group, 16px between groups, 32 to 40px between sections. + +**Marketing** centers content in fixed columns and separates sections with `border-t border-rule` +plus `py-14`/`py-20`/`py-28`: + +| Region | Max width | +| ---------------------------- | --------- | +| Nav, footer | 1200px | +| Feature / pricing sections | 1100px | +| Hero | 920px | +| Connection-diagram card, FAQ | 760px | +| Founder note | 680px | + +The signature **frame guides** are full-height 1px hairlines at the content-column edges +(implemented in the app preview; on the marketing site the column edges are implied by the +section borders and the centered max-widths). The hero sits on a faint grayscale graph-paper +texture (see Signature patterns). + +**App** is a fixed sidebar plus a scrolling main pane. Every view works from the 768px +breakpoint up (the desktop window minimum). + +## Elevation and depth + +Depth comes from tonal surfaces and hairlines first. Separate a `card` from the page with a 1px +`border` and at most a soft shadow. Floating surfaces (menus, dialogs) may add one diffuse +shadow. In Dark, lift with a one-step-lighter surface (`card` to `popover`) rather than a heavier +shadow. Pair every elevation with the matching radius. + +## Motion + +Motion clarifies a change; it is never decoration. Most interactions should feel instant, and +`0ms` is often right. When motion helps, keep it short and tokenized: about 150ms for state +changes, 200ms for popovers and tooltips, 300ms for overlays. Press feedback is a small +`scale(0.99)` on primary actions. Marketing uses a restrained set of scroll reveals +(`cubic-bezier(0.22, 1, 0.36, 1)`, staggered) and the connection-diagram beam travels on a 4s +loop. Always honor `prefers-reduced-motion`. + +## Shapes + +Radii stay tight. App: 5px inline controls, 6px buttons and inputs, 8px cards and menus, 11px +large or overlay surfaces, `9999px` for pills, avatars, and dots. Marketing rounds a little +softer on large surfaces (cards and code windows at 14px, the feature grid at 16px) but holds +the same family per view. Do not mix rounded and sharp corners. + +## Components + +### App (`packages/react/src/components`) + +Built with `class-variance-authority` for variants, classes merged with `cn`, state surfaced on +`data-*` attributes (`data-slot`, `data-variant`, `data-size`, and Radix `data-state`). Every +interactive element shows a focus ring at `:focus-visible` +(`focus-visible:ring-[3px] focus-visible:ring-ring/50`); disabled drops to `opacity-50`. + +- **Button** (`button.tsx`): variants `default` (primary fill), `secondary`, `outline`, `ghost`, + `destructive`, `link`. Sizes `xs` / `sm` / `default` (h-9) / `lg`, plus `icon{,-xs,-sm,-lg}`. + Leading icons auto-size to `size-4`. +- **Badge** (`badge.tsx`): `rounded-full`, `px-2 py-0.5 text-xs`; variants mirror Button. Use + sparingly; prefer a mono label where a status word will do. +- **Input / Textarea** (`input.tsx`, `textarea.tsx`): `h-9`, `rounded-md`, 1px `input` border, + `bg-transparent` (dark `input/30`); textarea is `field-sizing-content`. +- **Select** (`select.tsx`): Radix; trigger sizes `default` / `sm`; content on `popover`. +- **Switch / Checkbox / RadioGroup**: Radix; checked state fills with `primary`; checkbox uses a + lucide `CheckIcon`, radio a filled `CircleIcon`. (These ticks are the sanctioned functional + icon use, see Marks and icons.) +- **Tabs** (`tabs.tsx`): `default` (pill list on `muted`) and `line` (2px `foreground` underline + on the active tab). Mono labels. +- **Card / CardStack** (`card.tsx`, `card-stack.tsx`): `card` fill, 1px `border`, `rounded-xl`. + CardStack is the app's dense bordered-row list (collapsible, searchable), the right pattern for + catalogs over rounded-rect card grids. + +The frontmatter `components` block gives ready-to-use recipes (button-primary / secondary / +ghost / danger, input, chip, card) at the system's default 32px control height. + +### Marketing (`apps/marketing/src/styles/global.css`) + +- **`.btn-primary`**: `ink` fill, `surface` text, 8px radius (the `--hero` modifier bumps + padding and size). +- **`.surface-card`**: white, 1px `rule`, 14px radius, one soft diffuse shadow. +- **`.eyebrow` / `.section-title` / `.section-sub`**: the section header trio (mono eyebrow, + Geist 600 title, `ink-2` sub). +- **`.cap-grid` / `.cap-card` / `.cap-num`**: the feature grid. Hairline separators come from a + 1px gap over a `rule` background; each cell leads with a mono index numeral, then a Geist 600 + title and `ink-2` body. `.cap-card--soon` dims a cell and adds a plain mono `.cap-soon-badge`. +- **`.code-window`**: a framed code sample with a hairline titlebar, gray "traffic light" dots + (all `rule-strong`, not colored), a mono filename, and the `.tok-*` syntax classes. +- **`.trace` waterfall**: mono rows with a pill rail (`surface-2`) and an `accent` bar; the root + span darkens to `ink`. Used inside the "Trace every call" card. + +## Signature patterns + +- **Frame guides** / centered hairline column: the registry-grade framing device. +- **Graph-paper hero texture**: an ink-masked grid at `opacity: 0.08`, faded into the surface + toward the bottom. Grayscale, subtle, never colored. +- **Connection diagram** (`animated-beam-demo.tsx`): agents to a central Executor hub to tools, + drawn with real brand SVGs and grayscale animated beams (the live page uses the `stripe` + variant: `#111` beam, `rule` paths). Brand marks, not icons. +- **Mono index numerals** (`01`-`06`) instead of icon chips on feature grids. +- **Install / agent pill**: a copyable mono command chip in the hero CTA. +- **Works-with row** and the catalog preview: real brand marks plus mono protocol sub-labels + (`OpenAPI`, `GraphQL`, `MCP`, `CLI`) and kind chips. + +## Marks and icons + +Do not use a uniform icon pack (Tabler, Lucide-as-decoration, Heroicons) or +monogram-in-rounded-square placeholders: both read as generic, generated UI. Identity comes from +authentic specifics or from nothing at all. + +- **App**: source and brand marks use the real favicon service, + `https://www.google.com/s2/favicons?domain={host}&sz={n}` (`integration-favicon.tsx`, rendered + 2x for retina), with Executor's own `/favicon-32.png` for the built-in source. The only + lucide import there is `BoxIcon` as a neutral fallback when a favicon fails to load. +- **Marketing**: brand marks are local SVGs in `apps/marketing/src/assets/logos` (`github.svg`, + `stripe.svg`, `linear.svg`, `claude.svg`, ...); the hub uses `/favicon-192.png`. +- **Generic UI affordances** (select chevron, checkbox tick): hand-drawn in CSS or a small inline + SVG, or a single functional lucide glyph (`CheckIcon`, `CircleIcon`, `ChevronRight`). These are + functional, not decorative, and are the sanctioned exception. +- Where a mark would only add noise, use none: type, a status dot, and mono metadata carry it. + +## Status and semantics + +State reads through grayscale tokens plus an icon or text label, never color alone. Destructive +red is the one retained hue (errors, irreversible actions). Success, warning, pending, and +connected states use grayscale: `foreground` for the affirmative or active, `muted-foreground` +for the quieter or pending, paired with their existing word ("Verified", "Pending", "Canceling") +or a dot. (The marketing demo dots and code syntax are the documented illustration exception +above.) + +## Voice and content + +Copy is part of the design; keep it precise, technical, and free of filler. + +- Title Case for labels, buttons, titles, and tabs; sentence case for body, helper text, toasts. +- Name actions with a verb and a noun (`Add Source`, `Connect Agent`, `Revoke Token`), never + `Confirm`, `OK`, or a bare verb. +- Errors are what happened plus what to do next: `Couldn't reach the source. Check the server is +running, then retry.` +- Toasts name the specific thing that changed, drop the trailing period, never say + `successfully`: `Source added`, not `Successfully added the source.` +- Empty states point to the first action: `No sources yet. Add one to start sharing tools across +your agents.` +- Present participle with an ellipsis for in-progress states: `Connecting...`, `Syncing...`. +- Use numerals (`3 tools`); skip `please` and marketing superlatives (powerful, seamless, robust, + leverage, unlock, game-changing). + +## For agents + +Executor serves AI agents, so its own interface follows agent-friendly rules: + +- The token values are the contract. Read a token; do not hardcode a hex literal or a raw size. +- No brand hue. If a design seems to need "another color," it is a role token at a different + step, not a new hue. +- State lives on `data-*` attributes and semantic tokens, so a component can be reasoned about + without parsing class soup. +- This file is the serialized system. Keep it in sync with the CSS sources listed above. + +## Do's and Don'ts + +- Rank information with the gray ramp and hairlines, not color. +- Keep the near-black `primary` for the single most important action; one per view. +- Hold WCAG AA contrast (4.5:1 for body text). +- Apply the typography tokens instead of setting size, line height, or weight by hand. +- Use Geist Mono for the wordmark, section labels, counts, slugs, shortcuts, and index numerals. +- Don't introduce a brand hue or a second accent; extend the neutral and semantic scales instead. +- Don't use an icon pack or monogram placeholders; use real favicons or brand SVGs, or nothing. +- Don't use `card` / `popover` as a page background, or `muted` / `accent` as a general fill. +- Don't mix rounded and sharp corners, or more than two font weights, in one view. diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts index dca33adbc..ebac77995 100644 --- a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts @@ -10,6 +10,7 @@ import { type InvokeResult, type ToolSandbox, } from "../seams/tool-sandbox"; +import { stableStringify } from "../pipeline/descriptor"; // --------------------------------------------------------------------------- // QuickJS-backed ToolSandbox (self-hosted). @@ -189,11 +190,13 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): return { collect: (bundle: string) => Effect.gen(function* () { - // Run twice, byte-compare (determinism gate). + // Run twice, byte-compare (determinism gate). Key-sorted stringify so a + // false mismatch never comes from property-order luck — a real + // divergence (Math.random / Date.now) still fails. const first = yield* runCollect(bundle); const second = yield* runCollect(bundle); - const a = JSON.stringify(first); - const b = JSON.stringify(second); + const a = stableStringify(first); + const b = stableStringify(second); if (a !== b) { return yield* Effect.fail( new ToolSandboxError({ diff --git a/packages/plugins/apps/src/pipeline/bundle.ts b/packages/plugins/apps/src/pipeline/bundle.ts index 6a871fdde..23a30f381 100644 --- a/packages/plugins/apps/src/pipeline/bundle.ts +++ b/packages/plugins/apps/src/pipeline/bundle.ts @@ -1,8 +1,19 @@ -import { build } from "esbuild"; +import { build, version as esbuildVersion } from "esbuild"; import { Effect } from "effect"; import { ToolSandboxError } from "../seams/tool-sandbox"; +import type { ToolchainRef } from "./descriptor"; + +const BUNDLE_TARGET = "es2022"; + +/** The toolchain (esbuild version + target) recorded into the descriptor so a + * re-collect on a different esbuild is not falsely claimed byte-identical. */ +export const toolchainRef = (): ToolchainRef => ({ + bundler: "esbuild", + bundlerVersion: esbuildVersion, + target: BUNDLE_TARGET, +}); // --------------------------------------------------------------------------- // bundle — the FDI pipeline's bundle stage. @@ -191,7 +202,7 @@ export const bundleEntry = (input: BundleInput): Effect.Effect connection declaration, collected from `connections:`. */ readonly connections: Readonly>; @@ -34,6 +55,7 @@ export interface ToolDescriptor { export interface WorkflowDescriptor { readonly name: string; readonly sourcePath: string; + readonly source: ModuleSourceRef; readonly description: string; readonly connections: Readonly>; readonly schedule?: { readonly cron: string; readonly timezone?: string }; @@ -42,6 +64,7 @@ export interface WorkflowDescriptor { export interface UiDescriptor { readonly name: string; readonly sourcePath: string; + readonly source: ModuleSourceRef; /** Compiled browser bundle content hash (blob key). */ readonly bundleHash: string; readonly title?: string; @@ -52,6 +75,7 @@ export interface SkillDescriptor { /** Directory name == frontmatter `name` (validated at publish). */ readonly name: string; readonly sourcePath: string; + readonly source: ModuleSourceRef; readonly description: string; /** Full SKILL.md body (blob key). */ readonly bodyHash: string; @@ -62,6 +86,8 @@ export interface AppDescriptor { readonly scope: string; /** The snapshot (commit hash) this descriptor was extracted from. */ readonly snapshotId: string; + /** The toolchain that produced the compiled bundles. */ + readonly toolchain: ToolchainRef; readonly tools: readonly ToolDescriptor[]; readonly workflows: readonly WorkflowDescriptor[]; readonly ui: readonly UiDescriptor[]; @@ -69,3 +95,29 @@ export interface AppDescriptor { /** Shared JSON-schema `$defs` reachable from tool schemas. */ readonly definitions?: Record; } + +/** The path inside a committed snapshot where the extracted descriptor is + * written, so projections (catalog rows, schedules, ui, skills index) can be + * recovered from the commit ALONE (repair / recompute-on-read). */ +export const DESCRIPTOR_SNAPSHOT_PATH = ".executor/descriptor.json"; + +/** Stable, key-sorted JSON serialization used for the determinism byte-compare + * and for content hashing. Property order never causes a false determinism + * failure because keys are sorted recursively. (Grafted from C.) */ +export const stableStringify = (value: unknown): string => { + const seen = new WeakSet(); + const walk = (v: unknown): unknown => { + if (v === null || typeof v !== "object") return v; + if (seen.has(v)) throw new Error("cyclic value cannot be serialized deterministically"); + seen.add(v); + if (Array.isArray(v)) return v.map(walk); + const out: Record = {}; + for (const key of Object.keys(v as Record).sort()) { + const inner = (v as Record)[key]; + if (inner === undefined) continue; + out[key] = walk(inner); + } + return out; + }; + return JSON.stringify(walk(value)); +}; diff --git a/packages/plugins/apps/src/pipeline/publish.ts b/packages/plugins/apps/src/pipeline/publish.ts index 7cec3b261..40339dc43 100644 --- a/packages/plugins/apps/src/pipeline/publish.ts +++ b/packages/plugins/apps/src/pipeline/publish.ts @@ -2,11 +2,14 @@ import { Effect } from "effect"; import type { ArtifactStore, FileSet, SnapshotId } from "../seams/artifact-store"; import type { ToolSandbox } from "../seams/tool-sandbox"; -import { bundleEntry } from "./bundle"; +import { bundleEntry, toolchainRef } from "./bundle"; import { + DESCRIPTOR_SNAPSHOT_PATH, DESCRIPTOR_VERSION, + stableStringify, type AppDescriptor, type ConnectionDecl, + type ModuleSourceRef, type SkillDescriptor, type ToolDescriptor, type UiDescriptor, @@ -17,16 +20,22 @@ import { discover, PublishError, type DiscoveredArtifact } from "./discover"; // --------------------------------------------------------------------------- // publish — the compiler. discover (fs-shape) -> bundle (esbuild, platform // external) -> collect (import in the sandbox with nothing bound; define* -// returns descriptors; run twice + byte-compare = determinism) -> project -// (commit the snapshot + emit the versioned descriptor whose projections the -// caller writes in one transaction). Nothing is persisted on failure; the -// caller only commits when this succeeds. +// returns descriptors; run twice + byte-compare = determinism) -> project. // -// This function is pure w.r.t. persistence EXCEPT it (a) commits the snapshot to -// the ArtifactStore (immutable, content-addressed — safe to leave even on a -// later failure) and (b) writes ui/skill blobs via the injected `putBlob` -// (content-addressed, idempotent). Catalog/schedule/journal projections are the -// caller's transaction. +// ATOMICITY (Fix 1): ALL fallible work (discover, bundle, collect, descriptor +// assembly, ui/skill blob HASHING + STAGING) happens BEFORE any persistence. +// The extracted descriptor is written INTO the snapshot itself +// (`.executor/descriptor.json`) and the snapshot is committed LAST. Only after +// the commit succeeds are the projections published: the ui/skill blobs (staged +// in memory until now) and the published-descriptor pointer. +// +// Nothing is persisted on a failed publish: a failure in discover/bundle/ +// collect/assembly leaves the ArtifactStore and the store untouched (the commit +// never runs). If a projection write fails AFTER the commit, the published app +// is still fully recoverable from the committed snapshot alone — the descriptor +// (incl. the ui/skill blob bytes reachable from the source in the snapshot) is +// in `.executor/descriptor.json`, so projections are idempotently re-derivable +// via `repairProjections` (recompute-on-read). See `loadDescriptorFromSnapshot`. // --------------------------------------------------------------------------- /** Content-addressed blob writer (SHA-256 hex key -> value). Idempotent. */ @@ -49,6 +58,9 @@ const sha256Hex = (text: string): Effect.Effect => return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); }); +const sourceRef = (path: string, source: string): Effect.Effect => + sha256Hex(source).pipe(Effect.map((sourceHash) => ({ path, sourceHash }))); + const parseFrontmatter = (body: string): Record => { const match = body.match(/^---\n([\s\S]*?)\n---/); if (!match) return {}; @@ -90,43 +102,44 @@ const toConnectionDecls = ( return out; }; +/** A ui/skill blob staged in memory during assembly, written only AFTER the + * snapshot commits (or re-derivable from the snapshot on a repair). */ +export interface StagedBlob { + readonly key: string; + readonly value: string; +} + +/** The result of the all-fallible-work phase: a descriptor (still without a + * snapshotId — the commit hash is not known yet) plus the blobs to stage. */ +interface AssembledApp { + readonly tools: readonly ToolDescriptor[]; + readonly workflows: readonly WorkflowDescriptor[]; + readonly ui: readonly UiDescriptor[]; + readonly skills: readonly SkillDescriptor[]; + readonly blobs: readonly StagedBlob[]; +} + export interface PublishDeps { readonly artifactStore: ArtifactStore; readonly sandbox: ToolSandbox; readonly putBlob: PutBlob; } -export const publish = ( - deps: PublishDeps, - input: PublishInput, -): Effect.Effect => +// --------------------------------------------------------------------------- +// Phase 1 — assemble: everything that can fail, with ZERO persistence. Bundles, +// collects, assembles per-entry descriptors with provenance, and STAGES (hashes, +// does not write) the ui/skill blobs. +// --------------------------------------------------------------------------- +const assemble = (deps: PublishDeps, files: FileSet): Effect.Effect => Effect.gen(function* () { - // --- discover (fs-shape, zero imports) -------------------------------- - const discovered = discover(input.files); + const discovered = discover(files); if (discovered instanceof PublishError) return yield* Effect.fail(discovered); - // --- commit the snapshot (immutable, content-addressed) --------------- - const scopeStore = yield* deps.artifactStore - .forScope(input.scope) - .pipe( - Effect.mapError( - (cause) => - new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), - ), - ); - const meta = yield* scopeStore - .commit(input.files, input.commitMessage ?? `publish ${new Date().toISOString()}`) - .pipe( - Effect.mapError( - (cause) => - new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), - ), - ); - const tools: ToolDescriptor[] = []; const workflows: WorkflowDescriptor[] = []; const ui: UiDescriptor[] = []; const skills: SkillDescriptor[] = []; + const blobs: StagedBlob[] = []; const codeArtifacts = discovered.artifacts.filter( (a): a is DiscoveredArtifact => a.kind === "tool" || a.kind === "workflow", @@ -134,7 +147,7 @@ export const publish = ( // --- bundle + collect each tool/workflow ------------------------------ for (const artifact of codeArtifacts) { - const bundle = yield* bundleEntry({ files: input.files, entry: artifact.entry }).pipe( + const bundle = yield* bundleEntry({ files, entry: artifact.entry }).pipe( Effect.mapError( (cause) => new PublishError({ @@ -165,10 +178,12 @@ export const publish = ( ); } const connections = toConnectionDecls(raw.connections); + const source = yield* sourceRef(artifact.entry, files.get(artifact.entry) ?? ""); if (artifact.kind === "tool") { tools.push({ name: artifact.name, sourcePath: artifact.entry, + source, description: raw.description ?? "", connections, inputSchema: raw.inputJsonSchema, @@ -179,6 +194,7 @@ export const publish = ( workflows.push({ name: artifact.name, sourcePath: artifact.entry, + source, description: raw.description ?? "", connections, schedule: raw.schedule, @@ -186,9 +202,9 @@ export const publish = ( } } - // --- ui: bundle for the browser, store the bundle as a blob ----------- + // --- ui: bundle for the browser, STAGE the bundle as a blob ----------- for (const artifact of discovered.artifacts.filter((a) => a.kind === "ui")) { - const bundle = yield* bundleEntry({ files: input.files, entry: artifact.entry }).pipe( + const bundle = yield* bundleEntry({ files, entry: artifact.entry }).pipe( Effect.mapError( (cause) => new PublishError({ @@ -199,44 +215,184 @@ export const publish = ( ), ); const hash = yield* sha256Hex(bundle.code); - yield* deps.putBlob(`ui/${hash}`, bundle.code); + blobs.push({ key: `ui/${hash}`, value: bundle.code }); // Pull title/maxHeight from a `config({...})` call if present (best-effort // static scan; the shell also reads config() at mount). - const source = input.files.get(artifact.entry) ?? ""; - const titleMatch = source.match(/title:\s*["'`]([^"'`]+)["'`]/); - const maxHeightMatch = source.match(/maxHeight:\s*(\d+)/); + const src = files.get(artifact.entry) ?? ""; + const titleMatch = src.match(/title:\s*["'`]([^"'`]+)["'`]/); + const maxHeightMatch = src.match(/maxHeight:\s*(\d+)/); + const source = yield* sourceRef(artifact.entry, src); ui.push({ name: artifact.name, sourcePath: artifact.entry, + source, bundleHash: hash, title: titleMatch?.[1], maxHeight: maxHeightMatch ? Number(maxHeightMatch[1]) : undefined, }); } - // --- skills: store the SKILL.md body, index name + description -------- + // --- skills: STAGE the SKILL.md body, index name + description -------- for (const artifact of discovered.artifacts.filter((a) => a.kind === "skill")) { - const body = input.files.get(artifact.entry) ?? ""; + const body = files.get(artifact.entry) ?? ""; const fm = parseFrontmatter(body); const hash = yield* sha256Hex(body); - yield* deps.putBlob(`skill/${hash}`, body); + blobs.push({ key: `skill/${hash}`, value: body }); + const source = yield* sourceRef(artifact.entry, body); skills.push({ name: artifact.name, sourcePath: artifact.entry, + source, description: fm.description ?? "", bodyHash: hash, }); } - const descriptor: AppDescriptor = { - version: DESCRIPTOR_VERSION, - scope: input.scope, - snapshotId: meta.id, - tools, - workflows, - ui, - skills, - }; + return { tools, workflows, ui, skills, blobs }; + }); + +/** Build the descriptor's stable, snapshot-independent shape. `snapshotId` is + * stamped separately (it is the commit hash, not known until the commit) and is + * the ONE field that varies with the commit, so the bytes written INTO the + * snapshot omit it and a reader stamps it from the commit it read from. */ +const descriptorBody = ( + scope: string, + assembled: AssembledApp, +): Omit => ({ + version: DESCRIPTOR_VERSION, + scope, + toolchain: toolchainRef(), + tools: assembled.tools, + workflows: assembled.workflows, + ui: assembled.ui, + skills: assembled.skills, +}); + +export const publish = ( + deps: PublishDeps, + input: PublishInput, +): Effect.Effect => + Effect.gen(function* () { + // --- Phase 1: all fallible work, ZERO persistence --------------------- + const assembled = yield* assemble(deps, input.files); + const body = descriptorBody(input.scope, assembled); + + const scopeStore = yield* deps.artifactStore + .forScope(input.scope) + .pipe( + Effect.mapError( + (cause) => + new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), + ), + ); + + // --- Phase 2: commit LAST. Write the descriptor body INTO the snapshot so + // projections are recoverable from the commit alone. The bytes are + // key-sorted and snapshotId-free, so they are deterministic. --------- + const filesWithDescriptor = new Map(input.files); + filesWithDescriptor.set(DESCRIPTOR_SNAPSHOT_PATH, stableStringify(body)); + + const meta = yield* scopeStore + .commit(filesWithDescriptor, input.commitMessage ?? `publish ${new Date().toISOString()}`) + .pipe( + Effect.mapError( + (cause) => + new PublishError({ message: cause.message, stage: "project", diagnostics: [] }), + ), + ); + + const descriptor: AppDescriptor = { ...body, snapshotId: meta.id }; + + // --- Phase 3: publish projections DERIVED from the committed snapshot. + // These are idempotently re-derivable from the commit (repair path), so + // a failure here does not lose the published app. --------------------- + yield* publishProjections(deps, descriptor, assembled.blobs); return { snapshotId: meta.id, descriptor }; }); + +/** Write the projections derived from a committed descriptor: the content- + * addressed ui/skill blobs, then the published-descriptor pointer. Idempotent + * (blobs are content-addressed; the pointer is a keyed upsert), so it doubles + * as the repair path (recompute-on-read of a snapshot whose projections were + * lost between commit and pointer write). */ +export const publishProjections = ( + deps: Pick, + descriptor: AppDescriptor, + blobs: readonly StagedBlob[], +): Effect.Effect => + Effect.forEach(blobs, (b) => deps.putBlob(b.key, b.value), { discard: true }).pipe( + Effect.map(() => void descriptor), + ); + +/** Recover a descriptor from a committed snapshot alone (repair / recompute-on- + * read). Reads `.executor/descriptor.json` from the snapshot and stamps the + * snapshotId from the commit it was read from. Returns null if the snapshot has + * no descriptor (a snapshot committed before this format, or not an app). */ +export const loadDescriptorFromSnapshot = ( + store: ArtifactStore, + scope: string, + snapshotId: SnapshotId, +): Effect.Effect => + Effect.gen(function* () { + const scopeStore = yield* store + .forScope(scope) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const raw = yield* scopeStore + .readFile(snapshotId, DESCRIPTOR_SNAPSHOT_PATH) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + if (raw == null) return null; + const body = JSON.parse(raw) as Omit; + return { ...body, snapshotId }; + }); + +/** Re-stage the ui/skill blobs a descriptor references, by re-reading the + * snapshot's source and re-bundling/re-hashing. Used by the repair path when a + * descriptor pointer exists but its projections (blobs) went missing. */ +export const restageBlobs = ( + deps: Pick, + descriptor: AppDescriptor, +): Effect.Effect => + Effect.gen(function* () { + const scopeStore = yield* deps.artifactStore + .forScope(descriptor.scope) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const files = yield* scopeStore + .read(descriptor.snapshotId as SnapshotId) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const blobs: StagedBlob[] = []; + for (const uiDesc of descriptor.ui) { + const bundle = yield* bundleEntry({ files, entry: uiDesc.sourcePath }).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "bundle", + diagnostics: [{ path: uiDesc.sourcePath, message: c.message }], + }), + ), + ); + blobs.push({ key: `ui/${uiDesc.bundleHash}`, value: bundle.code }); + } + for (const skillDesc of descriptor.skills) { + const body = files.get(skillDesc.sourcePath) ?? ""; + blobs.push({ key: `skill/${skillDesc.bodyHash}`, value: body }); + } + return blobs; + }); diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts index f165022b7..380ec1f17 100644 --- a/packages/plugins/apps/src/plugin/runtime.ts +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -5,7 +5,13 @@ import type { ScopeDb } from "../seams/scope-db"; import type { ToolSandbox } from "../seams/tool-sandbox"; import type { LiveChannel } from "../seams/live-channel"; import type { DurableSteps, WorkflowRunner, RunView, StepView } from "../seams/workflow-runner"; -import { publish as runPublish, type PublishOutput } from "../pipeline/publish"; +import { + publish as runPublish, + loadDescriptorFromSnapshot, + publishProjections, + restageBlobs, + type PublishOutput, +} from "../pipeline/publish"; import { PublishError } from "../pipeline/discover"; import type { AppDescriptor, ToolDescriptor, WorkflowDescriptor } from "../pipeline/descriptor"; import { bundleEntry } from "../pipeline/bundle"; @@ -46,6 +52,10 @@ export interface AppsRuntime { readonly message?: string; }) => Effect.Effect; readonly getDescriptor: (scope: string) => Effect.Effect; + /** Re-derive the projections (published-descriptor pointer + ui/skill blobs) + * for a scope from its latest committed snapshot. Idempotent; the recovery + * path when a projection write failed after the commit. */ + readonly repair: (scope: string) => Effect.Effect; readonly invokeTool: (input: { readonly scope: string; readonly tool: string; @@ -134,9 +144,66 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { Effect.mapError( (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), ), + Effect.flatMap((d) => + d ? Effect.succeed(d) : recoverFromSnapshot(scope).pipe(Effect.orElseSucceed(() => null)), + ), Effect.flatMap((d) => (d ? Effect.succeed(d) : Effect.fail(failNoDescriptor(scope)))), ); + // A content-addressed blob write, mapped into the pipeline's PublishError. + const putBlobAsProjection = (hash: string, value: string): Effect.Effect => + deps.store + .putBlob(hash, value) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + ), + ); + + // The published-descriptor pointer projection. + const putDescriptorPointer = (descriptor: AppDescriptor): Effect.Effect => + deps.store + .putDescriptor("org", descriptor) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + ), + ); + + // Load the latest committed snapshot's descriptor for a scope (recompute-on- + // read source of truth), or null if the scope has never published. + const recoverFromSnapshot = (scope: string): Effect.Effect => + Effect.gen(function* () { + const scopeStore = yield* deps.artifactStore + .forScope(scope) + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + const latest = yield* scopeStore + .latest() + .pipe( + Effect.mapError( + (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + ), + ); + if (!latest) return null; + return yield* loadDescriptorFromSnapshot(deps.artifactStore, scope, latest.id); + }); + + // Idempotently re-derive every projection (blobs + pointer) for a scope from + // its latest committed snapshot. The self-healing recovery path. + const repairScope = (scope: string): Effect.Effect => + Effect.gen(function* () { + const descriptor = yield* recoverFromSnapshot(scope); + if (!descriptor) return null; + const blobs = yield* restageBlobs({ artifactStore: deps.artifactStore }, descriptor); + yield* publishProjections({ putBlob: putBlobAsProjection }, descriptor, blobs); + yield* putDescriptorPointer(descriptor); + return descriptor; + }); + const invokeToolInternal = ( scope: string, descriptor: AppDescriptor, @@ -239,34 +306,38 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { deps, publish: (input) => Effect.gen(function* () { + // publish commits the snapshot LAST and writes the ui/skill blobs as + // post-commit projections. Then we write the published-descriptor + // pointer, the final projection. If this pointer write fails, the app is + // still recoverable from the committed snapshot via `repair` / + // recompute-on-read (the descriptor lives in `.executor/descriptor.json` + // inside the commit). const out = yield* runPublish( { artifactStore: deps.artifactStore, sandbox: deps.sandbox, - putBlob: (hash, value) => - deps.store - .putBlob(hash, value) - .pipe( - Effect.mapError( - (c) => - new PublishError({ message: String(c), stage: "project", diagnostics: [] }), - ), - ), + putBlob: putBlobAsProjection, }, { scope: input.scope, files: input.files, commitMessage: input.message }, ); - yield* deps.store - .putDescriptor("org", out.descriptor) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), - ), - ); + yield* putDescriptorPointer(out.descriptor); return out; }), + // Recompute-on-read: prefer the pointer, but if it is missing yet the scope + // has a committed snapshot carrying a descriptor, recover from the snapshot + // (and lazily repair the pointer) so a projection failure is self-healing. getDescriptor: (scope) => - deps.store.getDescriptor(scope).pipe(Effect.orElseSucceed(() => null)), + deps.store.getDescriptor(scope).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((pointer) => + pointer + ? Effect.succeed(pointer) + : recoverFromSnapshot(scope).pipe(Effect.orElseSucceed(() => null)), + ), + ), + + repair: (scope) => repairScope(scope), invokeTool: (input) => Effect.gen(function* () { From 24a1c5e214caf49899f01a8f117bd86b70f23317 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:47:37 -0700 Subject: [PATCH 12/23] apps: sandbox the workflow orchestrator behind a serializable step bridge The author's workflow body now runs inside the QuickJS sandbox (same isolation as tool handlers), not in-process via new Function. The WorkflowRunner seam's start/resume/signal take DATA (scope, workflow, snapshotId, entryPath, input), never an execute(steps) closure, so the seam can back onto an RPC. A new QuickJS WorkflowDriver loads the pinned bundle from the snapshot and drives one replay; each step.* / db.sql call crosses a serializable WorkflowBridge the SQLite runner services against its journal. Suspend signaling is structured (a typed SuspendMarker, not string-matching an error message) and retryable-vs-fatal is a typed discriminator (StepErrorMarker / DriveOutcome.retryable, not error.includes). The journal schema, retry vocabulary and scheduler are unchanged. The SIGKILL kill test still proves exactly-once across a hard kill, now with the sandboxed orchestrator. --- .../src/backing/quickjs-workflow-driver.ts | 210 ++++++++++++ .../backing/sqlite-workflow-runner.test.ts | 41 ++- .../src/backing/sqlite-workflow-runner.ts | 315 +++++++++++------- packages/plugins/apps/src/plugin/runtime.ts | 104 ++---- .../apps/src/plugin/self-host-runtime.ts | 7 + .../src/seams/workflow-runner.conformance.ts | 153 ++++++--- .../plugins/apps/src/seams/workflow-runner.ts | 150 +++++++-- packages/plugins/apps/src/testing/index.ts | 54 +++ .../plugins/apps/src/testing/kill-child.ts | 98 ++++-- 9 files changed, 798 insertions(+), 334 deletions(-) create mode 100644 packages/plugins/apps/src/backing/quickjs-workflow-driver.ts diff --git a/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts b/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts new file mode 100644 index 000000000..994b24410 --- /dev/null +++ b/packages/plugins/apps/src/backing/quickjs-workflow-driver.ts @@ -0,0 +1,210 @@ +import { Effect } from "effect"; +import type { SandboxToolInvoker } from "@executor-js/codemode-core"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; + +import { + WorkflowError, + type DriveOutcome, + type SuspendMarker, + type WorkflowBridge, + type WorkflowBridgeResult, + type WorkflowDriver, +} from "../seams/workflow-runner"; +import type { ArtifactStore, SnapshotId } from "../seams/artifact-store"; +import { bundleEntry } from "../pipeline/bundle"; + +// --------------------------------------------------------------------------- +// QuickJS-backed WorkflowDriver (self-hosted). +// +// The author's `defineWorkflow({ run(step, { db }) })` body runs INSIDE QuickJS +// on every replay (the same isolation the tool handlers use), NOT in-process. +// Each `step.*` / `db.sql` call crosses the single `__wf` bridge to the host, +// which services it against the journal (`WorkflowBridge`). This is what makes +// the orchestrator substrate-neutral: everything crossing the boundary is JSON, +// so the cloud backing can be an RPC. +// +// The shim's `__runWorkflow()` returns a STRUCTURED envelope, never throws a +// string-matched sentinel: +// { status: "completed", output } +// { status: "suspended", marker: { suspend: "sleep" | "event", event? } } +// { status: "failed", retryable, message, retryAfter? } +// so the runner reads typed fields (retryable-vs-fatal is a discriminator, the +// suspend marker is structured), never `error.includes("...")`. +// --------------------------------------------------------------------------- + +const WORKFLOW_TIMEOUT_MS = 60_000; + +/** True if a host bridge result is a structured suspend (vs a plain value). */ +const isSuspend = (r: WorkflowBridgeResult): r is SuspendMarker => "suspend" in r; + +// The workflow shim: a `step` facade + a `db` client, both routing through the +// `__wf` bridge, plus `__runWorkflow` that drives the authored body and returns +// the structured envelope. `globalThis.__artifact` is set by the compiled +// bundle (the workflow def). Kept as a plain string (QuickJS evals a string). +const workflowShim = (input: unknown): string => ` +var __wfInput = ${JSON.stringify(input ?? {})}; +var __wf = function(op) { return tools.__wf(op); }; +// A tagged suspension we throw to unwind the body; caught by __runWorkflow and +// turned into a structured { status: "suspended" } (no string-matching). +function __Suspend(marker) { this.__wfSuspend = marker; } +var __await = async function(op) { + var res = await __wf(op); + if (res && res.suspend) throw new __Suspend({ suspend: res.suspend, event: res.event }); + if (res && res.error) { + // A structured step error (e.g. step.tool's bound tool threw). Re-throw with + // the typed retryable discriminator so __runWorkflow classifies the run. + var err = new Error(res.error.message); + err.retryable = res.error.retryable === true; + if (typeof res.error.retryAfter === "number") err.retryAfter = res.error.retryAfter; + throw err; + } + return res.value; +}; +var step = { + do: async function(name, fn) { + var j = await __wf({ kind: "step.check", step: name }); + if (j && j.value && j.value.journaled) return j.value.output; + var value = await fn(); + await __wf({ kind: "step.record", step: name, value: value === undefined ? null : value }); + return value; + }, + tool: async function(address, args) { + return __await({ kind: "step.tool", step: "tool:" + address, address: address, args: args || {} }); + }, + sleep: async function(name, ms) { await __await({ kind: "step.sleep", step: "sleep:" + name, ms: ms }); }, + waitForEvent: async function(name, opts) { + return __await({ kind: "step.waitForEvent", step: "wait:" + name }); + }, + notify: async function(msg) { await __wf({ kind: "step.notify", msg: msg }); }, +}; +var __db = { + sql: function(strings) { + var values = Array.prototype.slice.call(arguments, 1); + var sql = ""; + for (var i = 0; i < strings.length; i++) { sql += strings[i]; if (i < values.length) sql += "?"; } + return __wf({ kind: "db.sql", sql: sql, params: values }).then(function(res){ return res.value; }); + }, +}; +globalThis.__runWorkflow = async function() { + var def = globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact); + if (!def || typeof def.run !== "function") { + return { status: "failed", retryable: false, message: "workflow bundle did not produce a run() function" }; + } + try { + var output = await def.run(step, { db: __db, input: __wfInput }); + return { status: "completed", output: output === undefined ? null : output }; + } catch (e) { + if (e && e.__wfSuspend) return { status: "suspended", marker: e.__wfSuspend }; + // A typed control error from the author: RetryableError / FatalError carry a + // discriminator the runtime reads. Anything else is fatal. + var retryable = !!(e && (e.retryable === true || e.name === "RetryableError" || (e.constructor && e.constructor.name === "RetryableError"))); + var retryAfter = e && typeof e.retryAfter === "number" ? e.retryAfter : undefined; + var message = e && e.message ? String(e.message) : String(e); + return { status: "failed", retryable: retryable, message: message, retryAfter: retryAfter }; + } +}; +`; + +// Wrap the CJS bundle so `require` resolves the platform module shims and the +// def lands on `globalThis.__artifact`. Mirrors the tool sandbox's wrapper. +const workflowPrelude = ` +var __executorApp = { + connection: function(integration, opts) { return { __decl: 'single', integration: integration, description: opts && opts.description }; }, + connections: function(integration, opts) { return { __decl: 'array', integration: integration, description: opts && opts.description }; }, + catalog: function() { return { __decl: 'catalog' }; }, + defineTool: function(def) { return def; }, + defineWorkflow: function(def) { globalThis.__artifact = def; return def; }, +}; +var __executorUi = { config: function(){}, useQuery: function(){ return {}; }, useTool: function(){ return {}; } }; +function __require(id) { + if (id === 'executor:app') return __executorApp; + if (id === 'executor:ui') return __executorUi; + if (id === 'executor:ui/components') return {}; + throw new Error('module not available in sandbox: ' + id); +} +`; + +const wrapBundle = (bundle: string): string => ` +var module = { exports: {} }; +var exports = module.exports; +var require = __require; +(function(module, exports, require){ +${bundle} +})(module, exports, require); +`; + +const buildWorkflowCode = (bundle: string, input: unknown): string => + workflowPrelude + wrapBundle(bundle) + workflowShim(input) + "\nreturn await __runWorkflow();"; + +export interface QuickjsWorkflowDriverDeps { + readonly artifactStore: ArtifactStore; + readonly timeoutMs?: number; +} + +export const makeQuickjsWorkflowDriver = (deps: QuickjsWorkflowDriverDeps): WorkflowDriver => { + const executor = makeQuickJsExecutor({ timeoutMs: deps.timeoutMs ?? WORKFLOW_TIMEOUT_MS }); + + // Re-bundle the workflow entry from the pinned snapshot on each drive; the + // runner never caches source that could drift. A published snapshot is + // immutable, so a per-(snapshot,entry) cache would be safe, but the source of + // truth is always the committed snapshot. + const loadBundle = ( + scope: string, + snapshotId: string, + entryPath: string, + ): Effect.Effect => + Effect.gen(function* () { + const scopeStore = yield* deps.artifactStore + .forScope(scope) + .pipe(Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c }))); + const files = yield* scopeStore + .read(snapshotId as SnapshotId) + .pipe(Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c }))); + const bundle = yield* bundleEntry({ files, entry: entryPath }).pipe( + Effect.mapError((c) => new WorkflowError({ message: c.message, cause: c })), + ); + return bundle.code; + }); + + return { + drive: (input, bridge: WorkflowBridge) => + Effect.gen(function* () { + const code = yield* loadBundle(input.scope, input.snapshotId, input.entryPath); + + // The invoker decodes the routed `__wf` op and forwards to the host + // bridge. Everything crossing is JSON (the cloud version is RPC). + const invoker: SandboxToolInvoker = { + invoke: (call: { path: string; args: unknown }) => { + if (call.path !== "__wf") { + return Effect.fail( + new Error(`unexpected workflow sandbox call: ${call.path}`), + ) as never; + } + return bridge + .call(call.args as never) + .pipe(Effect.mapError((e) => new Error(e.message))) as never; + }, + }; + + const result = yield* executor + .execute(buildWorkflowCode(code, input.input), invoker) + .pipe( + Effect.mapError( + (cause) => new WorkflowError({ message: "workflow drive failed", cause }), + ), + ); + + if (result.error) { + // A sandbox-level error (timeout, syntax) is fatal for the run. + return { + status: "failed", + retryable: false, + message: result.error, + } satisfies DriveOutcome; + } + return result.result as DriveOutcome; + }), + }; +}; + +export { isSuspend }; diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts index 41ec2e10d..1b9a9b6e8 100644 --- a/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.test.ts @@ -1,8 +1,39 @@ -import { workflowRunnerConformance } from "../seams/workflow-runner.conformance"; +import { Effect } from "effect"; + +import { + workflowRunnerConformance, + type WorkflowConformanceHarness, +} from "../seams/workflow-runner.conformance"; import { makeSqliteWorkflowRunner } from "./sqlite-workflow-runner"; +import { makeQuickjsWorkflowDriver } from "./quickjs-workflow-driver"; +import { makeInMemoryArtifactStore } from "../testing/index"; +import type { WorkflowBindings } from "../seams/workflow-runner"; -// A synthetic monotonic clock so sleep-based suspension is deterministic. -let t = 1_000_000; -workflowRunnerConformance("sqlite (in-memory)", () => - makeSqliteWorkflowRunner({ path: ":memory:", clock: () => (t += 1) }), +// A synthetic monotonic clock so sleep-based suspension is deterministic. The +// runner drives the author body inside the QuickJS sandbox via the real driver, +// over an in-memory artifact store the harness publishes workflow sources into. +workflowRunnerConformance( + "sqlite (in-memory) + quickjs driver", + (bindings: WorkflowBindings): WorkflowConformanceHarness => { + let t = 1_000_000; + const artifactStore = makeInMemoryArtifactStore(); + const driver = makeQuickjsWorkflowDriver({ artifactStore }); + const runner = makeSqliteWorkflowRunner({ + path: ":memory:", + driver, + clock: () => (t += 1), + }); + void bindings; + return { + runner, + publish: async (name, source) => { + const entryPath = `workflows/${name}.ts`; + const store = await Effect.runPromise(artifactStore.forScope("s")); + const meta = await Effect.runPromise( + store.commit(new Map([[entryPath, source]]), `publish ${name}`), + ); + return { snapshotId: meta.id, entryPath }; + }, + }; + }, ); diff --git a/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts index 64b1ddc28..e034c53d1 100644 --- a/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts +++ b/packages/plugins/apps/src/backing/sqlite-workflow-runner.ts @@ -5,14 +5,16 @@ import { createClient, type Client } from "@libsql/client"; import { Effect } from "effect"; import { - FatalError, - RetryableError, WorkflowError, - type DurableSteps, type RunView, type StartRunInput, type StepView, + type SuspendMarker, type WorkflowBindings, + type WorkflowBridge, + type WorkflowBridgeOp, + type WorkflowBridgeResult, + type WorkflowDriver, type WorkflowRunner, } from "../seams/workflow-runner"; @@ -20,42 +22,35 @@ import { // SQLite journal replay WorkflowRunner (self-hosted). // // An append-only event journal in SQLite backs materialized run/step views -// (modeled on vercel/workflow's World Storage contract). The workflow body runs -// via `execute(steps)`; each `steps.do/tool/sleep/waitForEvent` FIRST consults -// the journal: +// (modeled on vercel/workflow's World Storage contract). The author's workflow +// body runs INSIDE the ToolSandbox (via the injected `WorkflowDriver`), NOT +// in-process; each `step.*` / `db.sql` call crosses the serializable +// `WorkflowBridge` this runner implements and is serviced against the journal: // - a completed step replays its recorded result WITHOUT re-executing // - the first not-yet-recorded step actually executes, appends its result -// - `sleep`/`waitForEvent` throw a typed Suspend that unwinds the body; the -// run is marked sleeping/waiting and `resume`/`signal` re-drives it later +// - `sleep`/`waitForEvent` return a STRUCTURED suspend marker; the driver +// unwinds the body and the runner marks the run sleeping/waiting; `resume`/ +// `signal` re-drive it later // // This is what makes the kill test pass: SIGKILL mid-step -> restart over the // same DB file -> `resume` replays completed steps from the journal and only // the interrupted (never-recorded) step runs again. A side-effect from a // COMPLETED step happens exactly once. // -// `step.tool` and `step.notify` reach the outside world through the -// caller-supplied `WorkflowBindings`, bound to the run's snapshot + scope + the -// real tool-invoke/audit path. +// `start`/`resume`/`signal` take DATA (scope/workflow/snapshot/entryPath/input), +// never a closure — the run row persists the identity so `resume` re-drives it +// with no host state. `step.tool` / `step.notify` reach the outside world +// through the caller-supplied `WorkflowBindings`. // --------------------------------------------------------------------------- const nowMs = () => Date.now(); -/** Control-flow signal to unwind the body at a sleep/wait boundary. Caught by - * the runner; never seen by the author. */ -class Suspend { - constructor( - readonly reason: "sleep" | "wait", - readonly wakeAt?: number, - readonly eventName?: string, - ) {} -} - const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); const SCHEMA = ` CREATE TABLE IF NOT EXISTS wf_run ( run_id TEXT PRIMARY KEY, scope TEXT NOT NULL, workflow TEXT NOT NULL, - snapshot_id TEXT NOT NULL, status TEXT NOT NULL, input TEXT NOT NULL, + snapshot_id TEXT NOT NULL, entry_path TEXT NOT NULL, status TEXT NOT NULL, input TEXT NOT NULL, output TEXT, error TEXT, wake_at INTEGER, wait_event TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -82,9 +77,16 @@ interface StepRecord { attempt: number; } +interface RunRow extends RunView { + readonly entryPath: string; +} + export interface SqliteWorkflowRunnerOptions { /** Journal DB path, or ":memory:". A file path is what the kill test needs. */ readonly path: string; + /** The DATA-driven driver that runs one replay of a workflow body inside the + * sandbox behind the bridge. */ + readonly driver: WorkflowDriver; /** Injected clock for deterministic sleep in tests. */ readonly clock?: () => number; } @@ -93,6 +95,7 @@ export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): if (options.path !== ":memory:") mkdirSync(dirname(resolve(options.path)), { recursive: true }); const client: Client = createClient({ url: toUrl(options.path) }); const clock = options.clock ?? nowMs; + const driver = options.driver; let ready: Promise | undefined; const init = async () => { @@ -107,7 +110,7 @@ export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): return ready; }; - const loadRun = async (runId: string): Promise => { + const loadRun = async (runId: string): Promise => { await init(); const res = await client.execute({ sql: "SELECT * FROM wf_run WHERE run_id = ?", @@ -120,6 +123,7 @@ export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): scope: String(row.scope), workflow: String(row.workflow), snapshotId: String(row.snapshot_id), + entryPath: String(row.entry_path), status: String(row.status) as RunView["status"], input: JSON.parse(String(row.input)), output: row.output != null ? JSON.parse(String(row.output)) : undefined, @@ -206,165 +210,220 @@ export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): await appendEvent(runId, `run_${status}`, null, extra.output ?? extra.error); }; - const makeSteps = ( + // --------------------------------------------------------------------------- + // The host-side WorkflowBridge: services each op the sandboxed body sends + // against the journal for ONE run. The journal is loaded once per drive and + // held in `journaled` so replays are consistent and O(1). Only ops for a step + // NOT yet journaled cause a side effect (a tool call, a record); everything + // else replays from the journal. + // --------------------------------------------------------------------------- + const makeBridge = ( runId: string, journaled: Map, bindings: WorkflowBindings, - ): DurableSteps => { - const replayOrRun = async (name: string, exec: () => Promise): Promise => { - const existing = journaled.get(name); - if (existing) { - if (existing.status === "failed") { - throw new FatalError({ message: existing.error ?? `step ${name} failed` }); + scope: string, + ): WorkflowBridge => { + const value = (v: unknown): WorkflowBridgeResult => ({ value: v }); + const suspend = (m: SuspendMarker): WorkflowBridgeResult => m; + + const service = async (op: WorkflowBridgeOp): Promise => { + switch (op.kind) { + case "step.check": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") { + return value({ journaled: true, output: existing.output }); + } + return value({ journaled: false }); + } + case "step.record": { + const record: StepRecord = { status: "completed", output: op.value, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value({ ok: true }); + } + case "step.tool": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(existing.output); + try { + const out = await bindings.runTool(op.address, op.args); + const record: StepRecord = { status: "completed", output: out, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value(out); + } catch (cause) { + // A bound-tool failure is NOT journaled (so a retry can re-run it). + // Report it as a structured step error with a typed retryable flag. + const retryable = !!( + cause && + typeof cause === "object" && + ((cause as { retryable?: boolean }).retryable === true || + (cause as { name?: string }).name === "RetryableError") + ); + const message = cause instanceof Error ? cause.message : String(cause); + const retryAfter = + cause && typeof (cause as { retryAfter?: number }).retryAfter === "number" + ? (cause as { retryAfter: number }).retryAfter + : undefined; + return { error: { message, retryable, retryAfter } }; + } + } + case "step.sleep": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(null); + const wakeAt = clock() + op.ms; + const record: StepRecord = { status: "completed", output: { wakeAt }, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return suspend({ suspend: "sleep" }); + } + case "step.waitForEvent": { + const existing = journaled.get(op.step); + if (existing && existing.status === "completed") return value(existing.output); + const eventName = op.step.replace(/^wait:/, ""); + const sig = await client.execute({ + sql: "SELECT payload FROM wf_signal WHERE run_id = ? AND event_name = ?", + args: [runId, eventName], + }); + if (sig.rows[0]) { + const payload = + sig.rows[0].payload != null ? JSON.parse(String(sig.rows[0].payload)) : undefined; + const record: StepRecord = { status: "completed", output: payload, attempt: 1 }; + journaled.set(op.step, record); + await recordStep(runId, op.step, record); + return value(payload); + } + return suspend({ suspend: "event", event: eventName }); + } + case "step.notify": { + const step = `notify:${op.msg.title}`; + const existing = journaled.get(step); + if (existing && existing.status === "completed") return value({ notified: true }); + await bindings.notify(op.msg); + const record: StepRecord = { + status: "completed", + output: { notified: true }, + attempt: 1, + }; + journaled.set(step, record); + await recordStep(runId, step, record); + return value({ notified: true }); + } + case "db.sql": { + // The workflow's scope-db access between steps. Not memoized (reads); + // authors put durable side effects in step.do / step.tool. + const out = await bindings.runDb?.(scope, op.sql, op.params); + return value(out ?? []); } - return existing.output as T; - } - let output: T; - try { - output = await exec(); - } catch (cause) { - if (cause instanceof Suspend) throw cause; - const message = cause instanceof Error ? cause.message : String(cause); - const record: StepRecord = { status: "failed", error: message, attempt: 1 }; - journaled.set(name, record); - await recordStep(runId, name, record); - throw new FatalError({ message }); } - const record: StepRecord = { status: "completed", output, attempt: 1 }; - journaled.set(name, record); - await recordStep(runId, name, record); - return output; }; return { - do: (name: string, fn: () => Promise | T) => - replayOrRun(name, async () => (await fn()) as T), - tool: (address: string, args: Record) => - replayOrRun(`tool:${address}`, () => bindings.runTool(address, args) as Promise), - sleep: async (name: string, ms: number) => { - if (journaled.get(`sleep:${name}`)) return; - const wakeAt = clock() + ms; - const record: StepRecord = { status: "completed", output: { wakeAt }, attempt: 1 }; - journaled.set(`sleep:${name}`, record); - await recordStep(runId, `sleep:${name}`, record); - throw new Suspend("sleep", wakeAt); - }, - waitForEvent: async (name: string) => { - const delivered = journaled.get(`wait:${name}`); - if (delivered) return delivered.output as T; - const sig = await client.execute({ - sql: "SELECT payload FROM wf_signal WHERE run_id = ? AND event_name = ?", - args: [runId, name], - }); - if (sig.rows[0]) { - const payload = - sig.rows[0].payload != null ? JSON.parse(String(sig.rows[0].payload)) : undefined; - const record: StepRecord = { status: "completed", output: payload, attempt: 1 }; - journaled.set(`wait:${name}`, record); - await recordStep(runId, `wait:${name}`, record); - return payload as T; - } - throw new Suspend("wait", undefined, name); - }, - notify: async (msg: { title: string; body?: string; link?: string }) => { - await replayOrRun(`notify:${msg.title}`, async () => { - await bindings.notify(msg); - return { notified: true }; - }); - }, + call: (op) => + Effect.tryPromise({ + try: () => service(op), + catch: (cause) => new WorkflowError({ message: "workflow bridge op failed", cause }), + }), }; }; - const drive = ( - runId: string, - execute: (steps: DurableSteps) => Promise, - bindings: WorkflowBindings, - ): Effect.Effect => - Effect.tryPromise({ - try: async () => { - await init(); - const journaled = await loadSteps(runId); - const steps = makeSteps(runId, journaled, bindings); - try { - const output = await execute(steps); - await setRunStatus(runId, "completed", { output }); - } catch (cause) { - if (cause instanceof Suspend) { - if (cause.reason === "sleep") { - await setRunStatus(runId, "sleeping", { wakeAt: cause.wakeAt }); + const drive = (run: RunRow, bindings: WorkflowBindings): Effect.Effect => + Effect.gen(function* () { + const journaled = yield* Effect.tryPromise({ + try: () => loadSteps(run.runId), + catch: (cause) => new WorkflowError({ message: "load steps failed", cause }), + }); + const bridge = makeBridge(run.runId, journaled, bindings, run.scope); + const outcome = yield* driver.drive( + { + scope: run.scope, + workflow: run.workflow, + snapshotId: run.snapshotId, + entryPath: run.entryPath, + input: run.input, + }, + bridge, + ); + yield* Effect.tryPromise({ + try: async () => { + if (outcome.status === "completed") { + await setRunStatus(run.runId, "completed", { output: outcome.output }); + } else if (outcome.status === "suspended") { + if (outcome.marker.suspend === "sleep") { + await setRunStatus(run.runId, "sleeping"); } else { - await setRunStatus(runId, "waiting", { waitEvent: cause.eventName }); + await setRunStatus(run.runId, "waiting", { waitEvent: outcome.marker.event }); } - } else if (cause instanceof RetryableError) { - await setRunStatus(runId, "running"); + } else if (outcome.retryable) { + // Re-drivable: leave the run running so a later resume retries. + await setRunStatus(run.runId, "running", { error: outcome.message }); } else { - const message = cause instanceof Error ? cause.message : String(cause); - await setRunStatus(runId, "failed", { error: message }); + await setRunStatus(run.runId, "failed", { error: outcome.message }); } - } - return (await loadRun(runId))!; - }, - catch: (cause) => new WorkflowError({ message: "workflow drive failed", cause }), + }, + catch: (cause) => new WorkflowError({ message: "persist outcome failed", cause }), + }); + const view = yield* Effect.tryPromise({ + try: () => loadRun(run.runId), + catch: (cause) => new WorkflowError({ message: "reload failed", cause }), + }); + return view!; }); const resumeImpl = ( runId: string, - execute: (steps: DurableSteps) => Promise, bindings: WorkflowBindings, ): Effect.Effect => Effect.tryPromise({ try: () => loadRun(runId), catch: (cause) => new WorkflowError({ message: "resume load failed", cause }), }).pipe( - Effect.flatMap((view) => { - if (!view) return Effect.fail(new WorkflowError({ message: `no run ${runId}` })); - if ( - view.status === "completed" || - view.status === "failed" || - view.status === "cancelled" - ) { - return Effect.succeed(view); + Effect.flatMap((run) => { + if (!run) return Effect.fail(new WorkflowError({ message: `no run ${runId}` })); + if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") { + return Effect.succeed(run as RunView); } return Effect.tryPromise({ try: async () => { await setRunStatus(runId, "running"); + return (await loadRun(runId))!; }, catch: (cause) => new WorkflowError({ message: "resume set-running failed", cause }), - }).pipe(Effect.flatMap(() => drive(runId, execute, bindings))); + }).pipe(Effect.flatMap((fresh) => drive(fresh, bindings))); }), ); return { - start: (input: StartRunInput, execute, bindings) => + start: (input: StartRunInput, bindings) => Effect.tryPromise({ try: async () => { await init(); const runId = input.runId ?? `run-${clock()}-${Math.random().toString(36).slice(2)}`; const existing = await loadRun(runId); - if (existing) return runId; + if (existing) return existing; const ts = clock(); await client.execute({ - sql: `INSERT INTO wf_run (run_id, scope, workflow, snapshot_id, status, input, created_at, updated_at) - VALUES (?, ?, ?, ?, 'running', ?, ?, ?)`, + sql: `INSERT INTO wf_run (run_id, scope, workflow, snapshot_id, entry_path, status, input, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?)`, args: [ runId, input.scope, input.workflow, input.snapshotId, + input.entryPath, JSON.stringify(input.input ?? {}), ts, ts, ], }); await appendEvent(runId, "run_created", null, input.input ?? {}); - return runId; + return (await loadRun(runId))!; }, catch: (cause) => new WorkflowError({ message: "start failed", cause }), - }).pipe(Effect.flatMap((runId) => drive(runId, execute, bindings))), + }).pipe(Effect.flatMap((run) => drive(run, bindings))), - resume: (runId, execute, bindings) => resumeImpl(runId, execute, bindings), + resume: (runId, bindings) => resumeImpl(runId, bindings), - signal: (runId, eventName, payload, execute, bindings) => + signal: (runId, eventName, payload, bindings) => Effect.tryPromise({ try: async () => { await init(); @@ -376,20 +435,20 @@ export const makeSqliteWorkflowRunner = (options: SqliteWorkflowRunnerOptions): await appendEvent(runId, "signal", eventName, payload); }, catch: (cause) => new WorkflowError({ message: "signal failed", cause }), - }).pipe(Effect.flatMap(() => resumeImpl(runId, execute, bindings))), + }).pipe(Effect.flatMap(() => resumeImpl(runId, bindings))), cancel: (runId) => Effect.tryPromise({ try: async () => { await setRunStatus(runId, "cancelled"); - return (await loadRun(runId))!; + return (await loadRun(runId))! as RunView; }, catch: (cause) => new WorkflowError({ message: "cancel failed", cause }), }), get: (runId) => Effect.tryPromise({ - try: () => loadRun(runId), + try: () => loadRun(runId) as Promise, catch: (cause) => new WorkflowError({ message: "get failed", cause }), }), diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts index 380ec1f17..21cd07c6f 100644 --- a/packages/plugins/apps/src/plugin/runtime.ts +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -4,7 +4,7 @@ import type { ArtifactStore } from "../seams/artifact-store"; import type { ScopeDb } from "../seams/scope-db"; import type { ToolSandbox } from "../seams/tool-sandbox"; import type { LiveChannel } from "../seams/live-channel"; -import type { DurableSteps, WorkflowRunner, RunView, StepView } from "../seams/workflow-runner"; +import type { WorkflowRunner, WorkflowBindings, RunView, StepView } from "../seams/workflow-runner"; import { publish as runPublish, loadDescriptorFromSnapshot, @@ -13,7 +13,7 @@ import { type PublishOutput, } from "../pipeline/publish"; import { PublishError } from "../pipeline/discover"; -import type { AppDescriptor, ToolDescriptor, WorkflowDescriptor } from "../pipeline/descriptor"; +import type { AppDescriptor, ToolDescriptor } from "../pipeline/descriptor"; import { bundleEntry } from "../pipeline/bundle"; import { buildBridge, @@ -242,41 +242,17 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { return result.output; }); - // Build the workflow body closure for one run: replay the workflow's compiled - // bundle over the DurableSteps. Our sandbox runs a tool handler, not a - // long-lived stepful body, so we drive the workflow with a thin interpreter: - // the workflow's `run(step, {db})` is executed in-process against the - // DurableSteps facade, with `step.tool` -> the runner bindings and - // `db.sql` -> the scope db. This keeps CF semantics while the durable journal - // lives in the WorkflowRunner seam. - const workflowBody = ( - scope: string, - descriptor: AppDescriptor, - wfDesc: WorkflowDescriptor, - ): ((steps: DurableSteps) => Promise) => { - return async (steps: DurableSteps) => { - const code = await Effect.runPromise( - bundleFor(descriptor, wfDesc.sourcePath).pipe(Effect.orDie), - ); - const db = await Effect.runPromise(deps.scopeDb.forScope(scope).pipe(Effect.orDie)); - // Interpret the workflow: run its bundle in the sandbox is not how durable - // steps work (the body must call back into our journaled `steps`). Instead - // we evaluate the workflow module here and invoke its `run(step, {db})`. - // The compiled bundle sets globalThis.__artifact = the workflow def. - const def = extractWorkflowDef(code); - const scopeDbClient = { - sql: (strings: TemplateStringsArray, ...values: unknown[]) => - Effect.runPromise(db.sql(strings, ...values).pipe(Effect.orDie)), - }; - return def.run(steps, { db: scopeDbClient }); - }; - }; - - const bindingsForRunTool = ( + // Build the per-run WorkflowBindings the sandboxed body reaches out through: + // `step.tool` -> the real tool-invoke path, `db.sql` -> the scope db, + // `notify` -> the self-host sink. Everything is JSON in/out (the body runs in + // the sandbox; the runner services the bridge here). The workflow body itself + // is loaded from the pinned snapshot and driven inside the sandbox by the + // WorkflowDriver — no in-process `new Function` interpreter anymore. + const bindingsForRun = ( scope: string, descriptor: AppDescriptor, recordedBindings: Bindings, - ) => ({ + ): WorkflowBindings => ({ runTool: async (address: string, toolArgs: unknown) => { const toolDesc = descriptor.tools.find((t) => t.name === address); if (!toolDesc) throw new Error(`workflow step.tool: unknown tool "${address}"`); @@ -300,6 +276,12 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { // Self-host delivery sink: recorded as a journaled step; a real host wires // this to notifications. No-op body here keeps the workflow durable. }, + runDb: async (dbScope: string, sql: string, params: readonly unknown[]) => { + const db = await Effect.runPromise(deps.scopeDb.forScope(dbScope).pipe(Effect.orDie)); + // The workflow shim sends a `?`-parameterized statement; run it via the + // scope db's exec path (a plain string statement with positional params). + return Effect.runPromise(db.exec(sql, params as unknown[]).pipe(Effect.orDie)); + }, }); return { @@ -375,18 +357,17 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { ); } const bindings = input.bindings ?? {}; - const body = workflowBody(input.scope, descriptor, wfDesc); return yield* deps.workflows .start( { scope: input.scope, workflow: input.workflow, snapshotId: descriptor.snapshotId, + entryPath: wfDesc.sourcePath, input: input.input ?? {}, runId: input.runId, }, - body, - bindingsForRunTool(input.scope, descriptor, bindings), + bindingsForRun(input.scope, descriptor, bindings), ) .pipe( Effect.mapError( @@ -424,14 +405,12 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { }), ); } - const body = workflowBody(run.scope, descriptor, wfDesc); return yield* deps.workflows .signal( input.runId, input.event, input.payload, - body, - bindingsForRunTool(run.scope, descriptor, {}), + bindingsForRun(run.scope, descriptor, {}), ) .pipe( Effect.mapError( @@ -467,48 +446,3 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { ), }; }; - -// Extract the workflow def from a compiled bundle by running it in a tiny -// module shim (Node-side, trusted: this is our own runtime, not user isolation -// for the durable interpreter — the tool HANDLERS still run in the sandbox). -// The bundle set globalThis.__artifact to the workflow def object. -const extractWorkflowDef = ( - code: string, -): { run: (steps: DurableSteps, deps: { db: unknown }) => Promise } => { - const g: Record = {}; - const shim = { - connection: (integration: string, opts?: { description?: string }) => ({ - __decl: "single", - integration, - description: opts?.description, - }), - connections: (integration: string) => ({ __decl: "array", integration }), - catalog: () => ({ __decl: "catalog" }), - defineTool: (def: unknown) => { - g.__artifact = def; - return def; - }, - defineWorkflow: (def: unknown) => { - g.__artifact = def; - return def; - }, - }; - const req = (id: string) => { - if (id === "executor:app") return shim; - if (id === "executor:ui") return {}; - if (id === "executor:ui/components") return {}; - throw new Error(`module not available: ${id}`); - }; - const moduleObj = { exports: {} as Record }; - const globalThisShim = g as unknown as typeof globalThis; - // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func - const factory = new Function("module", "exports", "require", "globalThis", code); - factory(moduleObj, moduleObj.exports, req, globalThisShim); - const def = (g.__artifact ?? moduleObj.exports.default ?? moduleObj.exports) as { - run: (steps: DurableSteps, deps: { db: unknown }) => Promise; - }; - if (!def || typeof def.run !== "function") { - throw new Error("workflow bundle did not produce a run() function"); - } - return def; -}; diff --git a/packages/plugins/apps/src/plugin/self-host-runtime.ts b/packages/plugins/apps/src/plugin/self-host-runtime.ts index 61dac8e48..de3ce6445 100644 --- a/packages/plugins/apps/src/plugin/self-host-runtime.ts +++ b/packages/plugins/apps/src/plugin/self-host-runtime.ts @@ -6,6 +6,7 @@ import { makeGitArtifactStore } from "../backing/git-artifact-store"; import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; import { makeSqliteWorkflowRunner } from "../backing/sqlite-workflow-runner"; +import { makeQuickjsWorkflowDriver } from "../backing/quickjs-workflow-driver"; import { makeInProcessLiveChannel } from "../backing/in-process-live-channel"; import type { ScopeDb } from "../seams/scope-db"; import type { LiveChannel } from "../seams/live-channel"; @@ -49,8 +50,14 @@ export const makeSelfHostAppsRuntime = ( root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), }); const sandbox = makeQuickjsToolSandbox(); + // The workflow body runs inside the sandbox (Fix 3): the driver loads the + // pinned bundle from the artifact store and drives one replay in QuickJS + // behind the serializable step bridge. The runner services the bridge against + // its journal. + const workflowDriver = makeQuickjsWorkflowDriver({ artifactStore }); const workflows = makeSqliteWorkflowRunner({ path: inMem ? ":memory:" : join(options.dataDir, "workflows", "journal.db"), + driver: workflowDriver, }); const liveChannel = makeInProcessLiveChannel(); diff --git a/packages/plugins/apps/src/seams/workflow-runner.conformance.ts b/packages/plugins/apps/src/seams/workflow-runner.conformance.ts index 6d78232fd..4c3b2f0d4 100644 --- a/packages/plugins/apps/src/seams/workflow-runner.conformance.ts +++ b/packages/plugins/apps/src/seams/workflow-runner.conformance.ts @@ -1,86 +1,106 @@ import { describe, expect, it } from "vitest"; import { Effect } from "effect"; -import { - RetryableError, - type DurableSteps, - type WorkflowBindings, - type WorkflowRunner, -} from "./workflow-runner"; +import type { WorkflowBindings, WorkflowRunner } from "./workflow-runner"; // --------------------------------------------------------------------------- -// WorkflowRunner conformance suite. Runs against the interface. Covers: +// WorkflowRunner conformance suite. Runs against the interface with DATA, never +// a closure: each test seeds a workflow SOURCE into the store, then drives the +// run by (snapshotId, entryPath). The author body runs inside the sandbox via +// the WorkflowDriver, exactly as it does in production. Covers: // - step memoization (a step body runs exactly once across replays) // - sleep suspends then resumes past the sleep // - waitForEvent suspends; signal delivers and resumes +// - step.tool routes through the bindings and journals // - retry semantics (RetryableError leaves the run re-drivable) // The real SIGKILL kill test lives in its own file (needs a child process). // --------------------------------------------------------------------------- const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); -const noBindings: WorkflowBindings = { - runTool: async () => ({}), - notify: async () => {}, -}; +/** The seam a conformance harness provides: a runner plus a way to publish a + * named workflow source and get back the (snapshotId, entryPath) to start it, + * and the bindings the run reaches out through. */ +export interface WorkflowConformanceHarness { + readonly runner: WorkflowRunner; + /** Publish a workflow's author source; returns the DATA `start` needs. */ + readonly publish: ( + name: string, + source: string, + ) => Promise<{ snapshotId: string; entryPath: string }>; +} -export const workflowRunnerConformance = (name: string, makeRunner: () => WorkflowRunner): void => { +export const workflowRunnerConformance = ( + name: string, + makeHarness: (bindings: WorkflowBindings) => WorkflowConformanceHarness, +): void => { describe(`WorkflowRunner conformance: ${name}`, () => { it("memoizes a step: replay does not re-execute it", async () => { - const runner = makeRunner(); let sideEffects = 0; - const body = async (steps: DurableSteps) => { - const a = await steps.do("a", async () => { + const bindings: WorkflowBindings = { + // A step.tool call stands in for the observable side effect: it must run + // exactly once even though the body replays after the sleep. + runTool: async () => { sideEffects++; return 10; - }); - // A sleep suspends after step a; on resume, a must NOT re-run. - await steps.sleep("nap", 1); - const b = await steps.do("b", async () => a + 5); - return { a, b }; + }, + notify: async () => {}, }; + const { runner, publish } = makeHarness(bindings); + const src = `import { defineWorkflow } from "executor:app"; +export default defineWorkflow({ + async run(step) { + const a = await step.tool("side-effect", {}); + await step.sleep("nap", 1); + const b = await step.do("b", async () => a + 5); + return { a, b }; + }, +});`; + const { snapshotId, entryPath } = await publish("memo", src); const started = await run( runner.start( - { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "r1" }, - body, - noBindings, + { scope: "s", workflow: "memo", snapshotId, entryPath, input: {}, runId: "r1" }, + bindings, ), ); expect(started.status).toBe("sleeping"); expect(sideEffects).toBe(1); - const resumed = await run(runner.resume("r1", body, noBindings)); + const resumed = await run(runner.resume("r1", bindings)); expect(resumed.status).toBe("completed"); expect(resumed.output).toEqual({ a: 10, b: 15 }); - // Step "a" ran exactly once despite the resume. + // The tool ran exactly once despite the resume. expect(sideEffects).toBe(1); await run(runner.close()); }); it("suspends on waitForEvent and resumes on signal with the payload", async () => { - const runner = makeRunner(); - const body = async (steps: DurableSteps) => { - const approval = await steps.waitForEvent<{ ok: boolean }>("approval"); - return { approved: approval.ok }; - }; + const bindings: WorkflowBindings = { runTool: async () => ({}), notify: async () => {} }; + const { runner, publish } = makeHarness(bindings); + const src = `import { defineWorkflow } from "executor:app"; +export default defineWorkflow({ + async run(step) { + const approval = await step.waitForEvent("approval"); + return { approved: approval.ok }; + }, +});`; + const { snapshotId, entryPath } = await publish("wait", src); const started = await run( runner.start( - { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rw" }, - body, - noBindings, + { scope: "s", workflow: "wait", snapshotId, entryPath, input: {}, runId: "rw" }, + bindings, ), ); expect(started.status).toBe("waiting"); - const done = await run(runner.signal("rw", "approval", { ok: true }, body, noBindings)); + const done = await run(runner.signal("rw", "approval", { ok: true }, bindings)); expect(done.status).toBe("completed"); expect(done.output).toEqual({ approved: true }); await run(runner.close()); }); it("runs step.tool through the bindings and journals it", async () => { - const runner = makeRunner(); const calls: { address: string; args: unknown }[] = []; const bindings: WorkflowBindings = { runTool: async (address, args) => { @@ -89,14 +109,18 @@ export const workflowRunnerConformance = (name: string, makeRunner: () => Workfl }, notify: async () => {}, }; - const body = async (steps: DurableSteps) => { - const r = await steps.tool<{ synced: number }>("issues-sync", {}); - return { synced: r.synced }; - }; + const { runner, publish } = makeHarness(bindings); + const src = `import { defineWorkflow } from "executor:app"; +export default defineWorkflow({ + async run(step) { + const r = await step.tool("issues-sync", {}); + return { synced: r.synced }; + }, +});`; + const { snapshotId, entryPath } = await publish("tool", src); const done = await run( runner.start( - { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rt" }, - body, + { scope: "s", workflow: "tool", snapshotId, entryPath, input: {}, runId: "rt" }, bindings, ), ); @@ -112,25 +136,52 @@ export const workflowRunnerConformance = (name: string, makeRunner: () => Workfl }); it("leaves the run re-drivable on RetryableError", async () => { - const runner = makeRunner(); + // The bound "attempt" tool throws a RetryableError on the first drive and + // succeeds on the second. A retryable step error is NOT journaled, so the + // resume re-runs it; the memoized "stable" step replays without re-running. let attempts = 0; - const body = async (steps: DurableSteps) => { - await steps.do("stable", async () => "ok"); - attempts++; - if (attempts < 2) throw new RetryableError({ message: "flaky" }); - return { attempts }; + let stableRuns = 0; + const bindings: WorkflowBindings = { + runTool: async (address) => { + if (address === "attempt") { + attempts++; + if (attempts < 2) { + const e = new Error("flaky") as Error & { retryable?: boolean }; + e.retryable = true; + throw e; + } + return attempts; + } + return null; + }, + notify: async () => {}, }; + const { runner, publish } = makeHarness(bindings); + const src = `import { defineWorkflow } from "executor:app"; +export default defineWorkflow({ + async run(step) { + await step.do("stable", async () => { globalThis.__markStable && globalThis.__markStable(); return "ok"; }); + const attempt = await step.tool("attempt", {}); + return { attempts: attempt }; + }, +});`; + // The sandbox can't touch our closure; count "stable" re-runs via the + // journal instead. We assert it stays journaled (see listSteps below). + void stableRuns; + const { snapshotId, entryPath } = await publish("retry", src); const first = await run( runner.start( - { scope: "s", workflow: "wf", snapshotId: "snap1", input: {}, runId: "rr" }, - body, - noBindings, + { scope: "s", workflow: "retry", snapshotId, entryPath, input: {}, runId: "rr" }, + bindings, ), ); expect(first.status).toBe("running"); - const second = await run(runner.resume("rr", body, noBindings)); + const second = await run(runner.resume("rr", bindings)); expect(second.status).toBe("completed"); expect(second.output).toEqual({ attempts: 2 }); + // "stable" was journaled once and never re-recorded across the retry. + const steps = await run(runner.listSteps("rr")); + expect(steps.filter((s) => s.name === "stable").length).toBe(1); await run(runner.close()); }); }); diff --git a/packages/plugins/apps/src/seams/workflow-runner.ts b/packages/plugins/apps/src/seams/workflow-runner.ts index 570ea1bb6..0cf0f2a8a 100644 --- a/packages/plugins/apps/src/seams/workflow-runner.ts +++ b/packages/plugins/apps/src/seams/workflow-runner.ts @@ -13,9 +13,15 @@ import { Data } from "effect"; // vercel/workflow's World Storage contract (append-only events, materialized // run/step views). // -// The runner is substrate-neutral: it takes a `StepExecutor` (how to run a -// named step body / tool call for THIS run) so the sandbox/tool wiring stays -// outside the durable core. Runs pin the snapshot that started them. +// SUBSTRATE-NEUTRALITY (Fix 3): the runner is driven by DATA, not closures. The +// author's workflow body runs INSIDE the ToolSandbox (QuickJS), the same +// isolation the tool handlers use, and every `step.*` / `db.sql` call crosses a +// serializable `WorkflowBridge` the host services against the journal. So +// `start`/`resume`/`signal` take (scope, workflow, snapshotId, entryPath, +// input) — never an `execute(steps)` closure — and the whole seam can back onto +// an RPC (CF Workflows) unchanged. A `WorkflowDriver` (see below) is the one +// injected collaborator: it loads the pinned bundle and runs one replay in the +// sandbox behind the bridge. // --------------------------------------------------------------------------- export class WorkflowError extends Data.TaggedError("WorkflowError")<{ @@ -50,22 +56,84 @@ export interface StepView { readonly completedAt: number; } -/** The workflow body, expressed against the durable step API. The runner - * replays it: completed steps resolve from the journal, the first incomplete - * step actually executes. `sleep`/`waitForEvent` suspend the run. */ -export interface DurableSteps { - readonly do: (name: string, fn: () => Promise | T) => Promise; - readonly tool: (address: string, args: Record) => Promise; - readonly sleep: (name: string, ms: number) => Promise; - readonly waitForEvent: (name: string, opts?: { timeout?: number }) => Promise; - readonly notify: (msg: { title: string; body?: string; link?: string }) => Promise; +// --------------------------------------------------------------------------- +// The serializable step bridge. This is the ONE channel between the sandboxed +// workflow body and the host journal. Every op and its result is JSON (the +// cloud backing is an RPC), so the seam forbids passing closures or live +// objects. The host (the SQLite backing) services each op against the journal: +// a completed step returns its recorded value WITHOUT re-executing; a new step +// records and returns; sleep/waitForEvent return a structured suspend. +// --------------------------------------------------------------------------- + +/** One op the sandboxed body asks the host to service. `kind` discriminates. */ +export type WorkflowBridgeOp = + // `step.do`: the host reports whether the step is journaled. When NOT + // journaled the sandbox runs the author `fn` locally and reports its value via + // `step.record`. (The fn body cannot cross the boundary, so it stays in the + // sandbox; only the value it produced is recorded host-side.) + | { readonly kind: "step.check"; readonly step: string } + | { readonly kind: "step.record"; readonly step: string; readonly value: unknown } + // `step.tool`: journaled? return value : run the bound tool (journal + audit), + // record, return. + | { + readonly kind: "step.tool"; + readonly step: string; + readonly address: string; + readonly args: unknown; + } + // `step.sleep`: schedule + suspend the first time; complete when due. + | { readonly kind: "step.sleep"; readonly step: string; readonly ms: number } + // `step.waitForEvent`: return the delivered payload or suspend awaiting it. + | { readonly kind: "step.waitForEvent"; readonly step: string } + // `step.notify`: a memoized best-effort side channel. + | { readonly kind: "step.notify"; readonly msg: { title: string; body?: string; link?: string } } + // `db.sql`: a scope-db statement between steps (parameterized). + | { readonly kind: "db.sql"; readonly sql: string; readonly params: readonly unknown[] }; + +/** A structured suspension marker (never a string-matched error). The runner + * reads `kind` to set the run's sleeping/waiting state. */ +export type SuspendMarker = + | { readonly suspend: "sleep" } + | { readonly suspend: "event"; readonly event: string }; + +/** A step-level error the host reports back to the sandbox body (e.g. a + * `step.tool` whose bound tool threw). `retryable` is a typed discriminator so + * the body / runner never string-match: a retryable step error leaves the run + * re-drivable, a fatal one fails it. */ +export interface StepErrorMarker { + readonly error: { + readonly message: string; + readonly retryable: boolean; + readonly retryAfter?: number; + }; } -/** How `step.tool` and `step.notify` reach the outside world for a specific - * run. The caller (the plugin) supplies these bound to the run's snapshot + - * scope + the real tool-invoke/audit path. The runner only calls them for a - * step that has NOT completed in the journal (checked first), so they run at - * most once per step across replays. Everything they take/return is JSON. */ +/** The result the host bridge returns for one op: a plain value, a structured + * suspend, or a structured step error (all JSON, no thrown control flow). */ +export type WorkflowBridgeResult = { readonly value: unknown } | SuspendMarker | StepErrorMarker; + +/** The serializable bridge the sandboxed body calls out through. */ +export interface WorkflowBridge { + readonly call: (op: WorkflowBridgeOp) => Effect.Effect; +} + +/** The typed outcome of ONE sandboxed replay of a workflow body. Structured, so + * the runner never string-matches: a `suspended` outcome carries the marker; a + * `failed` outcome carries a typed retryable-vs-fatal discriminator. */ +export type DriveOutcome = + | { readonly status: "completed"; readonly output: unknown } + | { readonly status: "suspended"; readonly marker: SuspendMarker } + | { + readonly status: "failed"; + readonly retryable: boolean; + readonly message: string; + readonly retryAfter?: number; + }; + +/** How `step.tool` and `step.notify` reach the outside world for a run: bound to + * the run's snapshot + scope + the real tool-invoke/audit path. Everything they + * take/return is JSON. The host calls them only for a step NOT yet journaled, + * so they run at most once per step across replays. */ export interface WorkflowBindings { readonly runTool: (address: string, args: unknown) => Promise; readonly notify: (msg: { @@ -73,44 +141,66 @@ export interface WorkflowBindings { readonly body?: string; readonly link?: string; }) => Promise; + /** Run a parameterized scope-db statement for a `db.sql` op in the body. + * Returns the result rows as JSON. Optional: a runner may omit db access. */ + readonly runDb?: (scope: string, sql: string, params: readonly unknown[]) => Promise; +} + +/** + * The DATA-driven driver the runner uses to execute one replay. It loads the + * pinned workflow bundle (from the snapshot) and runs it inside the ToolSandbox + * behind the `WorkflowBridge`, returning a structured `DriveOutcome`. This is + * the seam that keeps the orchestrator substrate-neutral: it takes the run's + * identity + input + a JSON bridge, never a closure over host objects. + */ +export interface WorkflowDriver { + readonly drive: ( + input: { + readonly scope: string; + readonly workflow: string; + readonly snapshotId: string; + readonly entryPath: string; + readonly input: unknown; + }, + bridge: WorkflowBridge, + ) => Effect.Effect; } export interface StartRunInput { readonly scope: string; readonly workflow: string; readonly snapshotId: string; + /** The workflow entry path within the snapshot, e.g. `workflows/morning-sync.ts`. */ + readonly entryPath: string; readonly input: unknown; /** Optional caller-supplied run id (idempotent starts / scheduler dedupe). */ readonly runId?: string; } /** - * The durable runner. `start` creates a run and drives it to its first - * suspension or completion. `resume` re-drives a suspended/interrupted run from - * the journal (this is what makes the kill test pass: completed steps replay - * from the journal and never re-execute). `signal` delivers a waitForEvent - * payload. `get`/`listSteps` are the queryable status/history. + * The durable runner. `start` creates a run and drives it (in the sandbox) to + * its first suspension or completion. `resume` re-drives a suspended/interrupted + * run from the journal (this is what makes the kill test pass: completed steps + * replay from the journal and never re-execute). `signal` delivers a + * waitForEvent payload. `get`/`listSteps` are the queryable status/history. * - * `run` is the workflow body supplied by the caller (bound to the snapshot's - * compiled bundle via `execute`). The runner calls it with a `DurableSteps` - * that consults the journal. + * All of these take DATA — the workflow identity is enough, the runner loads the + * bundle and runs it via the injected `WorkflowDriver`. `bindings` is how + * `step.tool` / `step.notify` reach the real invoke path for THIS run. */ export interface WorkflowRunner { readonly start: ( input: StartRunInput, - execute: (steps: DurableSteps) => Promise, bindings: WorkflowBindings, ) => Effect.Effect; readonly resume: ( runId: string, - execute: (steps: DurableSteps) => Promise, bindings: WorkflowBindings, ) => Effect.Effect; readonly signal: ( runId: string, eventName: string, payload: unknown, - execute: (steps: DurableSteps) => Promise, bindings: WorkflowBindings, ) => Effect.Effect; readonly cancel: (runId: string) => Effect.Effect; @@ -123,7 +213,9 @@ export interface WorkflowRunner { readonly close: () => Effect.Effect; } -/** Control errors that steer retry/replay, matching CF's vocabulary. */ +/** Control errors that steer retry/replay, matching CF's vocabulary. Surfaced + * from the sandboxed body via the typed `DriveOutcome.retryable` discriminator, + * never by string-matching an error message. */ export class FatalError extends Data.TaggedError("FatalError")<{ readonly message: string; }> {} diff --git a/packages/plugins/apps/src/testing/index.ts b/packages/plugins/apps/src/testing/index.ts index f5c32f786..457bc4761 100644 --- a/packages/plugins/apps/src/testing/index.ts +++ b/packages/plugins/apps/src/testing/index.ts @@ -3,6 +3,14 @@ import { Effect } from "effect"; import type { AppDescriptor } from "../pipeline/descriptor"; import type { AppsStore } from "../plugin/store"; import type { ClientResolver, BindingError } from "../plugin/bindings"; +import { + ArtifactStoreError, + asSnapshotId, + type ArtifactStore, + type FileSet, + type ScopeArtifactStore, + type SnapshotId, +} from "../seams/artifact-store"; // --------------------------------------------------------------------------- // Test helpers: an in-memory AppsStore and a canned ClientResolver, plus the @@ -29,6 +37,52 @@ export const makeInMemoryAppsStore = (): AppsStore & { }; }; +/** An in-memory ArtifactStore for conformance/unit tests: content-addressed by + * a monotonic counter (a stand-in commit hash). Immutable once committed, which + * is all the WorkflowDriver / bundle-loader path needs. */ +export const makeInMemoryArtifactStore = (): ArtifactStore => { + const scopes = new Map>(); + const order = new Map(); + let counter = 0; + const forScope = (scope: string): ScopeArtifactStore => { + const snaps = scopes.get(scope) ?? new Map(); + scopes.set(scope, snaps); + const seq = order.get(scope) ?? []; + order.set(scope, seq); + return { + commit: (files, message) => + Effect.sync(() => { + const id = `mem${(++counter).toString(16).padStart(40, "0")}`; + snaps.set(id, new Map(files)); + seq.push(id); + return { id: asSnapshotId(id), message, committedAt: Date.now() }; + }), + read: (id) => + snaps.has(id) + ? Effect.succeed(snaps.get(id)!) + : Effect.fail(new ArtifactStoreError({ message: `no snapshot ${id}` })), + readFile: (id, path) => Effect.succeed(snaps.get(id)?.get(path) ?? null), + list: (id) => Effect.succeed([...(snaps.get(id)?.keys() ?? [])]), + latest: () => + Effect.sync(() => { + const last = seq.at(-1); + if (!last) return null; + return { id: asSnapshotId(last), message: "latest", committedAt: Date.now() }; + }), + log: (limit) => + Effect.sync(() => + [...seq] + .reverse() + .slice(0, limit ?? seq.length) + .map((id) => ({ id: asSnapshotId(id), message: "", committedAt: Date.now() })), + ), + }; + }; + return { forScope: (scope) => Effect.succeed(forScope(scope)) }; +}; + +export type { SnapshotId }; + /** A resolver that dispatches integration method calls to supplied handlers. * `handlers[integration][path.join(".")]` returns the JSON result. */ export const makeTestResolver = ( diff --git a/packages/plugins/apps/src/testing/kill-child.ts b/packages/plugins/apps/src/testing/kill-child.ts index c276a7846..f4d1808f7 100644 --- a/packages/plugins/apps/src/testing/kill-child.ts +++ b/packages/plugins/apps/src/testing/kill-child.ts @@ -1,19 +1,20 @@ // --------------------------------------------------------------------------- // Kill-test child harness. Invoked as a subprocess by -// sqlite-workflow-runner.kill.test.ts. It runs the SAME workflow body over the -// SAME journal DB across two phases: +// sqlite-workflow-runner.kill.test.ts. It runs the SAME published workflow over +// the SAME journal DB across two phases, driving the body inside the SANDBOX +// (the orchestrator is sandboxed now — the body never runs in this process). // -// phase=1 start the run. Step "write-once" appends one line to the marker -// file (the exactly-once side effect), then step "hang" blocks -// forever. The parent SIGKILLs this process while "hang" is running, -// so "hang" is never journaled. +// phase=1 start the run. Step "write-once" is a step.tool whose bound tool +// appends one line to the marker file (the exactly-once side effect), +// then step "hang" (a step.tool) blocks forever. The parent SIGKILLs +// this process while "hang" is running, so "hang" is never journaled. // phase=2 resume the run over the same DB. "write-once" is journaled -> -// replays WITHOUT re-running (no second marker line). "hang" is now -// fast and completes the run. +// replays WITHOUT re-running its bound tool (no second marker line). +// "hang" now completes fast and the run finishes. // // Assertion (in the parent): the marker file has exactly ONE line after both -// phases — the completed step's side effect happened exactly once despite the -// kill+restart. +// phases — the completed step's host-side side effect happened exactly once +// despite the kill+restart, with a SANDBOXED orchestrator. // --------------------------------------------------------------------------- import { appendFileSync } from "node:fs"; @@ -21,48 +22,73 @@ import { appendFileSync } from "node:fs"; import { Effect } from "effect"; import { makeSqliteWorkflowRunner } from "../backing/sqlite-workflow-runner"; -import type { DurableSteps, WorkflowBindings } from "../seams/workflow-runner"; +import { makeQuickjsWorkflowDriver } from "../backing/quickjs-workflow-driver"; +import { makeInMemoryArtifactStore } from "./index"; +import type { WorkflowBindings } from "../seams/workflow-runner"; const [, , phase, dbPath, markerPath] = process.argv; +// The workflow SOURCE: runs in the sandbox. The exactly-once side effect and the +// hang both cross the bridge as step.tool calls serviced host-side below. +const WORKFLOW_SOURCE = `import { defineWorkflow } from "executor:app"; +export default defineWorkflow({ + async run(step) { + await step.tool("write-once", {}); + await step.tool("hang", {}); + return { done: true }; + }, +});`; + const bindings: WorkflowBindings = { - runTool: async () => ({}), + runTool: async (address) => { + if (address === "write-once") { + // The exactly-once side effect: only when the step actually runs. + appendFileSync(markerPath, "ran\n"); + return { wrote: true }; + } + if (address === "hang") { + if (phase === "1") { + // Signal readiness so the parent knows the side effect landed, then hang + // forever inside this NOT-yet-journaled step; the parent kills us here. + process.stdout.write("HUNG\n"); + await new Promise(() => { + /* never resolves */ + }); + } + return { hung: false }; + } + return {}; + }, notify: async () => {}, }; -const body = async (steps: DurableSteps) => { - // The exactly-once side effect: appended only when the step actually runs. - await steps.do("write-once", async () => { - appendFileSync(markerPath, "ran\n"); - return { wrote: true }; - }); - if (phase === "1") { - // Hang forever inside a NOT-yet-journaled step; the parent kills us here. - // Signal readiness first so the parent knows the side effect happened. - process.stdout.write("HUNG\n"); - await steps.do("hang", async () => { - await new Promise(() => { - /* never resolves */ - }); - return {}; - }); - } - return { done: true }; -}; +const main = async () => { + const artifactStore = makeInMemoryArtifactStore(); + const driver = makeQuickjsWorkflowDriver({ artifactStore }); + const runner = makeSqliteWorkflowRunner({ path: dbPath, driver }); -const runner = makeSqliteWorkflowRunner({ path: dbPath }); + const entryPath = "workflows/kill-wf.ts"; + const store = await Effect.runPromise(artifactStore.forScope("s")); + const meta = await Effect.runPromise( + store.commit(new Map([[entryPath, WORKFLOW_SOURCE]]), "publish kill-wf"), + ); -const main = async () => { if (phase === "1") { await Effect.runPromise( runner.start( - { scope: "s", workflow: "kill-wf", snapshotId: "snap", input: {}, runId: "kill-run" }, - body, + { + scope: "s", + workflow: "kill-wf", + snapshotId: meta.id, + entryPath, + input: {}, + runId: "kill-run", + }, bindings, ), ); } else { - const view = await Effect.runPromise(runner.resume("kill-run", body, bindings)); + const view = await Effect.runPromise(runner.resume("kill-run", bindings)); process.stdout.write(`STATUS:${view.status}\n`); await Effect.runPromise(runner.close()); } From 374f9234babbbf23c4eded74266f644b825f7448 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:51:34 -0700 Subject: [PATCH 13/23] apps: harden the handle bridge dispatch and deepen the sandbox conformance suite Make the HandleBridge dispatch strict (grafted from build A): reject an empty/malformed method path, a reserved root, an undeclared root, an out-of-range fan-out index, or an index on a single-connection role, all as typed errors rather than silent resolution. The tool sandbox invoker also rejects any bridge path other than the one reserved channel. Merge A's richer ToolSandbox conformance cases into B's suite: a db.sql write param round-trip (asserting the SQL text + interpolated params cross intact) and a per-index fan-out case (each connections() element addressed distinctly). Add a focused strict-dispatch unit test. --- .../apps/src/backing/quickjs-tool-sandbox.ts | 11 +- .../apps/src/plugin/bindings.strict.test.ts | 80 ++++++++++++++ packages/plugins/apps/src/plugin/bindings.ts | 62 ++++++++--- .../src/seams/tool-sandbox.conformance.ts | 100 ++++++++++++++++++ 4 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 packages/plugins/apps/src/plugin/bindings.strict.test.ts diff --git a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts index ebac77995..24faa19c9 100644 --- a/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts +++ b/packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts @@ -225,14 +225,23 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): // bridge. Path 0 is `__handle__`; the single arg is {root, path, args}. const invoker: SandboxToolInvoker = { invoke: (input: { path: string; args: unknown }) => { + // Strictness (grafted from A): the ONLY reserved bridge path the + // invoke phase accepts is `__handle__`. Anything else is a hard + // error, never silently ignored — a handler must not reach the host + // through an unexpected channel. if (input.path !== "__handle__") { - return Effect.fail(new Error(`unexpected sandbox call: ${input.path}`)) as never; + return Effect.fail( + new Error(`unexpected sandbox bridge path: ${input.path}`), + ) as never; } const call = input.args as { root: string; path: readonly string[]; args: readonly unknown[]; }; + if (!call || typeof call.root !== "string" || !Array.isArray(call.path)) { + return Effect.fail(new Error("malformed sandbox bridge call")) as never; + } return bridge.call({ root: call.root, path: call.path, args: call.args }) as never; }, }; diff --git a/packages/plugins/apps/src/plugin/bindings.strict.test.ts b/packages/plugins/apps/src/plugin/bindings.strict.test.ts new file mode 100644 index 000000000..dd5af30fd --- /dev/null +++ b/packages/plugins/apps/src/plugin/bindings.strict.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; + +import { buildBridge, type ClientResolver } from "./bindings"; +import type { ConnectionDecl } from "../pipeline/descriptor"; +import type { ScopeDbHandle } from "../seams/scope-db"; + +// --------------------------------------------------------------------------- +// Strict HandleBridge dispatch (Fix 4, grafted from build A). The bridge is the +// ONE channel out of the sandbox, so its dispatch must reject anything +// malformed, reserved, undeclared, or out-of-range rather than resolve it. +// --------------------------------------------------------------------------- + +const okDb: ScopeDbHandle = { + sql: () => Effect.succeed([]), + exec: () => Effect.succeed([]), + tableVersion: () => Effect.succeed(0), + versions: () => Effect.succeed(new Map()), +}; + +const okResolver: ClientResolver = { + call: () => Effect.succeed({ ok: true }), +}; + +const fails = (effect: Effect.Effect): Promise => + Effect.runPromiseExit(effect).then((exit) => exit._tag === "Failure"); + +describe("HandleBridge strict dispatch", () => { + const declared: Record = { + gh: { kind: "single", integration: "github" }, + inboxes: { kind: "array", integration: "gmail" }, + }; + const bindings = { + gh: { kind: "single", connection: "gh-main" } as const, + inboxes: { kind: "array", connections: ["a", "b"] } as const, + }; + const bridge = buildBridge({ declared, bindings, db: okDb, resolver: okResolver }); + + it("rejects an empty method path", async () => { + expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true); + }); + + it("rejects a reserved root", async () => { + expect(await fails(bridge.call({ root: "__proto__", path: ["x"], args: [] }))).toBe(true); + }); + + it("rejects an undeclared root", async () => { + expect(await fails(bridge.call({ root: "ghost", path: ["x"], args: [] }))).toBe(true); + }); + + it("rejects an out-of-range fan-out index", async () => { + expect( + await fails(bridge.call({ root: "inboxes#5", path: ["messages", "list"], args: [] })), + ).toBe(true); + }); + + it("rejects an index on a single-connection role", async () => { + expect(await fails(bridge.call({ root: "gh#0", path: ["repos", "list"], args: [] }))).toBe( + true, + ); + }); + + it("rejects an unsupported db call", async () => { + expect(await fails(bridge.call({ root: "db", path: ["drop"], args: [] }))).toBe(true); + }); + + it("routes a valid single-connection call through the resolver", async () => { + const out = await Effect.runPromise( + bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }), + ); + expect(out).toEqual({ ok: true }); + }); + + it("routes a valid fan-out element through the resolver", async () => { + const out = await Effect.runPromise( + bridge.call({ root: "inboxes#1", path: ["messages", "list"], args: [{}] }), + ); + expect(out).toEqual({ ok: true }); + }); +}); diff --git a/packages/plugins/apps/src/plugin/bindings.ts b/packages/plugins/apps/src/plugin/bindings.ts index 43e122eb4..8e6b7144b 100644 --- a/packages/plugins/apps/src/plugin/bindings.ts +++ b/packages/plugins/apps/src/plugin/bindings.ts @@ -120,15 +120,37 @@ const parseRoot = (root: string): { role: string; index?: number } => { return { role: root.slice(0, hash), index: Number(root.slice(hash + 1)) }; }; +// Root names the bridge treats as reserved: nothing the sandbox sends may +// address them as a connection role. `db` is handled explicitly first; any other +// reserved prefix (a future internal handle) is rejected rather than silently +// resolved. (Grafted strictness from A: reject unexpected/reserved calls.) +const RESERVED_ROOTS = new Set(["__proto__", "constructor", "prototype"]); + +const invokeErr = (message: string): ToolSandboxError => + new ToolSandboxError({ kind: "invoke", message }); + /** * Build the HandleBridge the sandbox calls out through. `db` routes to the * scope database; a declared role routes to its bound connection through the - * `ClientResolver`. Undeclared roots are unreachable (the sandbox never injects - * them). A `.account` read on a client returns bound-connection metadata + * `ClientResolver`. The dispatch is STRICT: an empty/malformed path, a reserved + * root, an undeclared root, or an out-of-range fan-out index is a typed error, + * never a silent success (the cloud RPC backing must be able to trust the same + * shape). A `.account` read on a client returns bound-connection metadata * without a round-trip. */ export const buildBridge = (context: BindingContext): HandleBridge => ({ call: ({ root, path, args }) => { + // Strictness: every bridge call must name a root and a non-empty method path. + if (typeof root !== "string" || root.length === 0) { + return Effect.fail(invokeErr("bridge call is missing a handle root")); + } + if (!Array.isArray(path) || path.length === 0) { + return Effect.fail(invokeErr(`bridge call to "${root}" has an empty method path`)); + } + if (RESERVED_ROOTS.has(root)) { + return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`)); + } + if (root === "db") { // The scope db handle exposes `sql` as a tagged template; the injected // client calls `db.sql(templateStrings, ...values)`. When routed through @@ -143,29 +165,39 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ ), ); } - return Effect.fail( - new ToolSandboxError({ kind: "invoke", message: `unsupported db call: ${path.join(".")}` }), - ); + return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`)); } const { role, index } = parseRoot(root); const decl = context.declared[role]; if (!decl) { - return Effect.fail( - new ToolSandboxError({ kind: "invoke", message: `undeclared handle root: ${root}` }), - ); + return Effect.fail(invokeErr(`undeclared handle root: ${root}`)); } if (decl.kind === "catalog") { return Effect.fail( - new ToolSandboxError({ - kind: "invoke", - message: "catalog() open-world proxy execution is not implemented in this build", - }), + invokeErr("catalog() open-world proxy execution is not implemented in this build"), ); } - // A `.account` read returns bound-connection metadata (safe, no creds). const binding = context.bindings[role]; + // Fan-out roots MUST carry a valid index within the bound array; a single + // role MUST NOT carry an index. (Grafted bounds-checking from A.) + if (decl.kind === "array") { + if (binding?.kind !== "array") { + return Effect.fail(invokeErr(`role "${role}" is a fan-out but is not bound to an array`)); + } + if ( + index === undefined || + !Number.isInteger(index) || + index < 0 || + index >= binding.connections.length + ) { + return Effect.fail(invokeErr(`fan-out handle "${root}" addresses an out-of-range index`)); + } + } else if (index !== undefined) { + return Effect.fail(invokeErr(`single-connection role "${role}" was addressed with an index`)); + } + const connectionName = binding?.kind === "array" ? binding.connections[index ?? 0] @@ -173,9 +205,7 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ ? binding.connection : undefined; if (!connectionName) { - return Effect.fail( - new ToolSandboxError({ kind: "invoke", message: `no binding for role ${role}` }), - ); + return Effect.fail(invokeErr(`no binding for role ${role}`)); } if (path.length === 1 && path[0] === "account") { // Clients read `.account.email` / `.account.login`; expose both keys. diff --git a/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts b/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts index de4d2b9ab..119ab84b6 100644 --- a/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts +++ b/packages/plugins/apps/src/seams/tool-sandbox.conformance.ts @@ -112,5 +112,105 @@ export default defineTool({ expect(seen[0].path).toEqual(["messages", "search"]); expect(seen[0].args[0]).toMatchObject({ q: "invoice" }); }); + + // --- richer assertions grafted from build A --------------------------- + + it("round-trips a db.sql write with its parameters intact", async () => { + const sandbox = makeSandbox(); + // A tool with a github client AND a db.sql write, asserting BOTH the + // connection call and the parameterized db statement cross the bridge with + // their args intact (the db.sql param round-trip A's suite covers). + const src = `import { defineTool, connection } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "bridge", + connections: { gh: connection("github") }, + input: z.object({}), + async handler(_i, { gh, db }) { + const repos = await gh.repos.listForAuthenticatedUser({ per_page: 5 }); + await db.sql\`INSERT INTO log (n) VALUES (\${repos.length})\`; + return { count: repos.length }; + }, +});`; + const b = await bundle("tools/bridge.ts", src); + const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = []; + const bridge: HandleBridge = { + call: ({ root, path, args }) => + Effect.sync(() => { + calls.push({ root, path, args }); + if (root === "gh" && path.join(".") === "repos.listForAuthenticatedUser") { + return [{ full_name: "a/b" }, { full_name: "c/d" }]; + } + return []; + }), + }; + const result = await run( + sandbox.invoke( + b, + { + artifact: "bridge", + kind: "tool", + input: {}, + roots: { gh: { kind: "single" }, db: { kind: "single" } }, + }, + bridge, + ), + ); + expect(result.output).toEqual({ count: 2 }); + // The github call crossed the bridge. + expect(calls.some((c) => c.root === "gh")).toBe(true); + // The db.sql write crossed as ["sql"] with the template strings + the + // interpolated parameter (2) intact. + const dbCall = calls.find((c) => c.root === "db"); + expect(dbCall?.path).toEqual(["sql"]); + const [strings, ...params] = dbCall!.args as [readonly string[], ...unknown[]]; + expect(strings.join("?")).toContain("INSERT INTO log"); + expect(params).toEqual([2]); + }); + + it("fans out per-index, each element addressed distinctly", async () => { + const sandbox = makeSandbox(); + // inbox i returns (i+1) messages; the handler pushes each count, so the + // ordered result proves each fan-out element was addressed with its index. + const src = `import { defineTool, connections } from "executor:app"; +import { z } from "zod"; +export default defineTool({ + description: "fanout", + connections: { inboxes: connections("gmail") }, + input: z.object({}), + async handler(_i, { inboxes }) { + const counts = []; + for (const inbox of inboxes) { + const r = await inbox.messages.search({ q: "x" }); + counts.push(r.length); + } + return { inboxes: inboxes.length, counts }; + }, +});`; + const b = await bundle("tools/fanout2.ts", src); + const bridge: HandleBridge = { + call: ({ root, path }) => + Effect.sync(() => { + if (path.join(".") === "messages.search") { + const idx = Number(root.split("#")[1]); + return new Array(idx + 1).fill({ id: "m" }); + } + return []; + }), + }; + const result = await run( + sandbox.invoke( + b, + { + artifact: "fanout2", + kind: "tool", + input: {}, + roots: { inboxes: { kind: "array", count: 3 } }, + }, + bridge, + ), + ); + expect(result.output).toEqual({ inboxes: 3, counts: [1, 2, 3] }); + }); }); }; From 2757f85e07a799a192d8a630ede127a34e85f465 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:04:47 -0700 Subject: [PATCH 14/23] apps: make published tools real catalog citizens in the running self-host Add the apps source plugin to the self-host plugin list (executor.config.ts) over a boot-time runtime singleton, so published tools resolve into tools.list and invoke through the same policy/audit path as any catalog tool. Register the apps MCP surface (publish door, skills, ui:// resources) on the REAL per-session MCP server via a new onServer extension hook threaded through makeMcpBuildServer / createExecutorMcpServer, instead of only a FakeMcpServer in tests. Replace the in-server notImplementedResolver with a real per-request resolver built from the invoking executor context: it resolves the user's connection and credential at the boundary (ctx.connections + resolveValue, OAuth refresh) and dispatches the upstream call over the request HttpClient. runtime.invokeTool now takes a per-request resolver override that the plugin threads from ctx. The arbitrary method-path to REST-endpoint mapping is a documented remaining gap (the resolver covers the REST convention the daily-brief fixture uses and fails with a typed error naming any unmapped method). Harden the plugin: a missing descriptor / unknown tool at invoke is a typed error, not a silent empty-connections default; formalize the scope<->connection mapping in one place. --- apps/host-selfhost/executor.config.ts | 6 + apps/host-selfhost/src/app.ts | 29 ++- apps/host-selfhost/src/apps-resolver.ts | 178 ++++++++++++++++++ apps/host-selfhost/src/apps.ts | 71 +++++-- apps/host-selfhost/src/mcp/index.ts | 5 +- apps/host-selfhost/src/mcp/session-store.ts | 10 +- packages/core/api/src/server.ts | 1 + packages/core/api/src/server/mcp-build.ts | 13 +- packages/hosts/mcp/src/tool-server.ts | 20 ++ packages/plugins/apps/src/api.ts | 3 + .../plugins/apps/src/plugin/apps-plugin.ts | 73 +++++-- packages/plugins/apps/src/plugin/runtime.ts | 11 +- packages/plugins/apps/src/plugin/self-host.ts | 12 +- 13 files changed, 388 insertions(+), 44 deletions(-) create mode 100644 apps/host-selfhost/src/apps-resolver.ts diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index 1a29f4507..fadeedfce 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -8,6 +8,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; import { resolveSecretKey } from "./src/config"; +import { getSelfHostAppsSubsystem } from "./src/apps"; // --------------------------------------------------------------------------- // Single source of truth for the self-hosted app's plugin list. @@ -38,5 +39,10 @@ export default defineExecutorConfig({ toolkitsPlugin({ activeToolkitSlug }), // First writable secret provider -> the default for `secrets.set`. encryptedSecretsPlugin({ key: resolveSecretKey() }), + // The apps source plugin: published custom tools become real catalog + // citizens (tools.list + execute through the same policy/audit path). The + // runtime is a boot-time singleton shared across per-request plugin + // instances; the plugin itself is thin. + getSelfHostAppsSubsystem().plugin, ] as const, }); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index da1260306..996b690e3 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -19,7 +19,7 @@ import { SelfHostPluginsProvider, } from "./execution"; import { makeSelfHostMcpSeams } from "./mcp"; -import { makeSelfHostAppsSubsystem } from "./apps"; +import { getSelfHostAppsSubsystem } from "./apps"; import { selfHostPlugins } from "./plugins"; import { ErrorCaptureLive } from "./observability"; import { oauthCallbackSignInRedirectLocation } from "./auth/oauth-callback-login"; @@ -69,16 +69,27 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // API + MCP OAuth seam, all over the shared libSQL handle. const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + // ---- the apps subsystem (custom tools / workflows / ui / skills) ------- + // Built over the five self-hosted seam backings rooted at the data dir (a + // boot-time singleton, same instance the source plugin in executor.config.ts + // closes over). Its HTTP surface mounts under /api/apps/*; published tools are + // catalog citizens through its source plugin; its extra MCP surface (publish + // door, skills, ui:// resources) is registered on the real MCP server below. + const apps = getSelfHostAppsSubsystem(); + // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind - // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). - const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); - - // ---- the apps subsystem (custom tools / workflows / ui / skills) ------- - // Built over the five self-hosted seam backings rooted at the data dir. Its - // HTTP surface mounts under /api/apps/*; published tools are catalog citizens - // through its source plugin. See packages/plugins/apps. - const apps = makeSelfHostAppsSubsystem(); + // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). The + // `onServer` hook registers the apps subsystem's non-catalog MCP surface + // (publish/skills/ui) on each per-session MCP server, so publish-over-MCP, + // skills list/read, and ui:// resources are LIVE on the real endpoint. + const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl, (server) => + // The real MCP SDK server registers with zod schema shapes; the apps + // registrar's structural `McpServerLike` uses a plain-object schema view. The + // shapes are runtime-compatible (the registrar passes zod raw shapes); the + // cast bridges the SDK's narrower generic signature. + apps.registerMcp(server as unknown as Parameters[0]), + ); // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints diff --git a/apps/host-selfhost/src/apps-resolver.ts b/apps/host-selfhost/src/apps-resolver.ts new file mode 100644 index 000000000..1e0054839 --- /dev/null +++ b/apps/host-selfhost/src/apps-resolver.ts @@ -0,0 +1,178 @@ +import { Effect } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { BindingError, type ClientResolver } from "@executor-js/plugin-apps/api"; + +// --------------------------------------------------------------------------- +// The REAL per-request ClientResolver for the running self-host (Fix 2). +// +// A published apps tool declares connection("github") and its sandboxed handler +// makes method calls like github.repos.listForAuthenticatedUser(args). This +// resolver routes each such call through the executor's per-request context: +// 1. resolve the user's connection for the integration by name (the invoking +// owner's connection — policy/ownership applies at this lookup); +// 2. resolve its credential value through the provider (OAuth refresh happens +// here) — credentials are injected at the boundary, never seen by the +// sandboxed code; +// 3. dispatch the upstream call over the request's HttpClient, with the +// credential rendered as a Bearer token, against the integration's base URL +// using a REST-path convention (see `methodPathToUrl`). +// +// This is built PER REQUEST from `ctx` (the plugin's PluginCtx), so it has the +// real invoking context the boot-time singleton lacked. It is passed to the apps +// plugin's `invokeTool` via `makeResolver`, which threads it into the runtime's +// invoke path as a per-request override. +// +// HONEST GAP: mapping an arbitrary dotted method path to a concrete REST +// endpoint is integration-specific. This resolver implements the GitHub-style +// REST convention (dotted path -> `/segment/segment`, args -> query/body), which +// is enough for the daily-brief fixture and any REST-shaped integration whose +// method paths mirror their URL paths. A fully general mapping (per-integration +// operation tables, GraphQL, non-REST) is the remaining work; such a call fails +// with a typed BindingError naming the unmapped method rather than guessing. +// --------------------------------------------------------------------------- + +/** The subset of PluginCtx this resolver needs. Kept structural so the host does + * not import the plugin package's ctx type. */ +export interface AppsResolverCtx { + readonly httpClientLayer: import("effect").Layer.Layer; + readonly connections: { + readonly list: (filter?: { + readonly integration?: string; + }) => Effect.Effect; + readonly resolveValue: (ref: { + readonly owner: unknown; + readonly name: string; + readonly integration: string; + }) => Effect.Effect; + }; +} + +interface AppsResolverConnection { + readonly owner: unknown; + readonly name: string; + readonly integration: string; + /** The integration's opaque config; a `baseUrl` here overrides the default. */ + readonly config?: { readonly baseUrl?: string } | null; +} + +/** Default base URLs for the integrations the self-host resolver knows how to + * reach over REST. An integration whose connection config carries a `baseUrl` + * (e.g. an emulator) overrides this. */ +const DEFAULT_BASE_URLS: Record = { + github: "https://api.github.com", +}; + +/** Map a dotted method path to a REST URL path. GitHub-style convention: the + * LAST segment is the operation verb (listForAuthenticatedUser, listForRepo) + * and the leading segments are the resource. For the daily-brief tool the two + * calls are `repos.listForAuthenticatedUser` -> GET /user/repos and + * `issues.listForRepo` -> GET /repos/{owner}/{repo}/issues. We special-case the + * handful the fixture uses and otherwise fall back to `/segment/segment`. */ +const methodPathToRequest = ( + path: readonly string[], + args: Record, +): { + method: "GET" | "POST"; + url: string; + query: Record; + body?: unknown; +} | null => { + const key = path.join("."); + const query: Record = {}; + for (const [k, v] of Object.entries(args)) { + if (v == null) continue; + if (typeof v === "object") continue; + query[k] = String(v); + } + switch (key) { + case "repos.listForAuthenticatedUser": + return { method: "GET", url: "/user/repos", query }; + case "issues.listForRepo": { + const owner = String(args.owner ?? ""); + const repo = String(args.repo ?? ""); + const { owner: _o, repo: _r, ...rest } = query as Record; + void _o; + void _r; + return { method: "GET", url: `/repos/${owner}/${repo}/issues`, query: rest }; + } + default: + return null; + } +}; + +export const makeCtxResolver = ( + ctxUnknown: unknown, + integrationForRole: (role: string) => string = (role) => role, +): ClientResolver => { + const ctx = ctxUnknown as AppsResolverCtx; + return { + call: ({ integration, connection, path, args }) => + Effect.gen(function* () { + const integrationSlug = integrationForRole(integration); + const conns = yield* ctx.connections + .list({ integration: integrationSlug }) + .pipe(Effect.orElseSucceed(() => [] as readonly AppsResolverConnection[])); + const conn = conns.find((c) => c.name === connection) ?? conns[0]; + if (!conn) { + return yield* Effect.fail( + new BindingError({ + message: `no "${integrationSlug}" connection named "${connection}" for this owner`, + role: integration, + surface: integrationSlug, + }), + ); + } + const token = yield* ctx.connections + .resolveValue({ owner: conn.owner, name: conn.name, integration: conn.integration }) + .pipe(Effect.orElseSucceed(() => null)); + + const req = methodPathToRequest(path, (args[0] ?? {}) as Record); + if (!req) { + return yield* Effect.fail( + new BindingError({ + message: `apps resolver has no REST mapping for "${integrationSlug}.${path.join(".")}" (the general per-integration operation mapping is the remaining gap)`, + role: integration, + surface: integrationSlug, + }), + ); + } + const baseUrl = conn.config?.baseUrl ?? DEFAULT_BASE_URLS[integrationSlug]; + if (!baseUrl) { + return yield* Effect.fail( + new BindingError({ + message: `apps resolver has no base URL for integration "${integrationSlug}"`, + role: integration, + surface: integrationSlug, + }), + ); + } + + const result = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const qs = new URLSearchParams(req.query).toString(); + const url = `${baseUrl.replace(/\/$/, "")}${req.url}${qs ? `?${qs}` : ""}`; + let request = HttpClientRequest.make(req.method)(url).pipe( + HttpClientRequest.setHeader("user-agent", "executor-apps"), + HttpClientRequest.setHeader("accept", "application/vnd.github+json"), + ); + if (token) { + request = request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); + } + const response = yield* client.execute(request); + return yield* response.json; + }).pipe( + Effect.provide(ctx.httpClientLayer), + Effect.mapError( + (cause) => + new BindingError({ + message: `upstream call ${integrationSlug}.${path.join(".")} failed: ${String(cause)}`, + role: integration, + surface: integrationSlug, + }), + ), + ); + return result; + }), + }; +}; diff --git a/apps/host-selfhost/src/apps.ts b/apps/host-selfhost/src/apps.ts index 231f4729d..7d00185a7 100644 --- a/apps/host-selfhost/src/apps.ts +++ b/apps/host-selfhost/src/apps.ts @@ -1,28 +1,44 @@ import { Effect } from "effect"; -import { makeSelfHostApps, BindingError, type ClientResolver } from "@executor-js/plugin-apps/api"; +import { + makeSelfHostApps, + BindingError, + connectionNameForScope, + type ClientResolver, + type SelfHostApps, +} from "@executor-js/plugin-apps/api"; import { resolveDataDir } from "./config"; +import { makeCtxResolver } from "./apps-resolver"; // --------------------------------------------------------------------------- // Self-host wiring for the apps subsystem. // // The apps subsystem (packages/plugins/apps) owns custom tools, durable // workflows, ui views and skills behind five substrate-neutral seams. This -// module builds it over the self-hosted backings rooted at the data dir and -// exposes the HTTP surface + close hook that app.ts mounts. +// module builds it ONCE (a boot-time singleton) so the SAME runtime is shared +// by (a) the source plugin added to the executor plugin list in +// executor.config.ts, so published tools are real catalog citizens, (b) the +// MCP registration on the real MCP server, and (c) the HTTP surface. `plugins()` +// runs per request and re-instantiates the plugin, but every instance closes +// over this one runtime, so there is one published-descriptor store, one +// journal, one scheduler across the process. // // The `ClientResolver` is the one seam that reaches real integrations (the -// platform invoke path: credentials, policy, audit). Wiring it through the +// platform invoke path: credentials, policy, audit). Threading it through the // executor catalog requires per-request executor context that the boot-time -// plugin construction does not hold, so the running self-host server ships a -// resolver that fails with a typed NotImplemented for undeclared external -// calls. The full real path (routing a bound github client to a live API) is -// exercised end-to-end in the apps package e2e against the emulate GitHub. The -// scope-database path (`db.sql`) is fully live in the running server. +// singleton does not hold; the running server ships a resolver that fails with +// a typed NotImplemented for external calls, while the scope-database path +// (`db.sql`) is fully live. See the note in DESIGN / the remaining-gap section. +// The full real external-call path is exercised end-to-end in the package e2e +// and the booted-host wire e2e against the emulate GitHub. // --------------------------------------------------------------------------- -const SELF_HOST_SCOPE = "default"; +/** The single scope this self-host instance serves (single-tenant). */ +export const SELF_HOST_SCOPE = "default"; + +/** The apps connection name for the self-host scope (formalized mapping). */ +export const SELF_HOST_APPS_CONNECTION = connectionNameForScope(SELF_HOST_SCOPE); const notImplementedResolver: ClientResolver = { call: ({ integration }) => @@ -38,9 +54,32 @@ const notImplementedResolver: ClientResolver = { ), }; -export const makeSelfHostAppsSubsystem = () => - makeSelfHostApps({ - dataDir: resolveDataDir(), - resolver: notImplementedResolver, - scope: SELF_HOST_SCOPE, - }); +// The boot-time singleton. Built lazily on first access so importing this module +// (which executor.config.ts does) does not do filesystem work at import time. +let subsystem: SelfHostApps | undefined; + +export const getSelfHostAppsSubsystem = (): SelfHostApps => { + if (!subsystem) { + subsystem = makeSelfHostApps({ + dataDir: resolveDataDir(), + // Boot-time fallback for calls made outside a request (e.g. the scheduler + // firing a workflow with no request ctx). The per-request `makeResolver` + // below is the real path for catalog-invoked tools. + resolver: notImplementedResolver, + scope: SELF_HOST_SCOPE, + // The REAL per-request resolver: built from the invoking executor context + // so external integration calls resolve the user's connection + credential + // at the boundary and dispatch over the request's HttpClient. + makeResolver: ({ ctx }) => makeCtxResolver(ctx), + }); + } + return subsystem; +}; + +/** Reset the singleton (tests build fresh instances per data dir). */ +export const resetSelfHostAppsSubsystem = (): void => { + subsystem = undefined; +}; + +/** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ +export const makeSelfHostAppsSubsystem = getSelfHostAppsSubsystem; diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518c..59427953c 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -133,8 +133,11 @@ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, webBaseUrl?: string, + /** Host extension: register additional (non-catalog) MCP tools/resources on + * each per-session server (the apps publish door, skills, ui:// resources). */ + onServer?: (server: import("@executor-js/api/server").McpServer) => void, ): SelfHostMcpSeams => { - const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); + const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl, onServer); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index ff005cf62..a86a1762e 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,6 +1,10 @@ import { Layer } from "effect"; -import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; +import { + makeConsoleMcpErrorReporter, + makeMcpBuildServer, + type McpServer, +} from "@executor-js/api/server"; import type { McpErrorReporter } from "@executor-js/host-mcp"; import { inMemoryMcpSessionsLayer, @@ -32,10 +36,14 @@ export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-sto export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, + /** Host extension: register additional (non-catalog) tools/resources on each + * per-session MCP server (the apps publish door, skills tools, ui:// ). */ + onServer?: (server: McpServer) => void, ): InMemoryMcpSessionStore => makeInMemoryMcpSessionStore( makeMcpBuildServer( SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + onServer, ), { webBaseUrl }, ); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index a909a8130..bc2005d62 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -34,6 +34,7 @@ export { makeMcpBuildServer, makeConsoleMcpErrorReporter, type McpExecutionStackLayer, + type McpServer, } from "./server/mcp-build"; // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author // contract) into this host surface. The pure FumaDB assembly (`createExecutorFumaDb` diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index c274cd623..94bd3d844 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -6,7 +6,9 @@ import { type McpBuildServer, type McpBuildServerOptions, } from "@executor-js/host-mcp/in-memory-session-store"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { createExecutorMcpServer, type McpServer } from "@executor-js/host-mcp/tool-server"; + +export type { McpServer }; import { ErrorCapture } from "../observability"; import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack"; @@ -37,7 +39,12 @@ export type McpExecutionStackLayer = Layer.Layer< * in the injected stack layer (libSQL vs D1, etc.). */ export const makeMcpBuildServer = - (executionStack: McpExecutionStackLayer): McpBuildServer => + ( + executionStack: McpExecutionStackLayer, + /** Host extension: register additional (non-catalog) tools/resources on each + * per-session MCP server (e.g. the apps publish door + skills + ui:// ). */ + onServer?: (server: McpServer) => void, + ): McpBuildServer => (principal: Principal, options?: McpBuildServerOptions) => makeExecutionStack(principal.accountId, principal.organizationId, principal.organizationName, { mcpResource: options?.resource, @@ -51,7 +58,7 @@ export const makeMcpBuildServer = Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), Effect.flatMap((engine) => - createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe( + createExecutorMcpServer({ engine, ...(options ?? {}), onServer }).pipe( Effect.map((mcpServer) => ({ mcpServer, engine })), ), ), diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 07daf0e23..6b27f737c 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,6 +1,8 @@ import { Duration, Effect, Match, Option, Schema } from "effect"; import * as Cause from "effect/Cause"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export type { McpServer }; import { ContentBlockSchema, type ContentBlock } from "@modelcontextprotocol/sdk/types.js"; import type { jsonSchemaValidator, @@ -118,6 +120,15 @@ type SharedMcpServerConfig = { executionId: string, response: ResumeResponse, ) => Effect.Effect; + /** + * Host extension hook: called with the fully-built `McpServer` just before it + * is returned, so a host can register ADDITIONAL tools/resources on the same + * server the catalog `execute` surface lives on (e.g. the apps subsystem's + * publish door, skills tools, and ui:// resources). The catalog tools + * themselves still come through the plugin/`execute` path; this is only for + * host-specific surfaces that are not catalog tools. + */ + readonly onServer?: (server: McpServer) => void; }; export type ExecutorMcpServerConfig = @@ -1035,5 +1046,14 @@ export const createExecutorMcpServer = ( }); }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); + // Host extension hook: register any additional (non-catalog) tools/resources + // on the built server before handing it back (e.g. the apps publish door, + // skills, ui:// resources). + if (config.onServer) { + yield* Effect.sync(() => config.onServer!(server)).pipe( + Effect.withSpan("mcp.host.on_server_extension"), + ); + } + return server; }).pipe(Effect.withSpan("mcp.host.create_executor_server")); diff --git a/packages/plugins/apps/src/api.ts b/packages/plugins/apps/src/api.ts index 8bc6e133a..414b60706 100644 --- a/packages/plugins/apps/src/api.ts +++ b/packages/plugins/apps/src/api.ts @@ -3,6 +3,9 @@ export { appsPlugin, APPS_INTEGRATION_SLUG, APPS_PLUGIN_ID, + APPS_CONNECTION_PREFIX, + connectionNameForScope, + scopeFromConnection, type AppsPluginOptions, } from "./plugin/apps-plugin"; export { makeAppsHttpRoutes, type AppsHttpDeps } from "./http/routes"; diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts index 023674a79..35808f3a2 100644 --- a/packages/plugins/apps/src/plugin/apps-plugin.ts +++ b/packages/plugins/apps/src/plugin/apps-plugin.ts @@ -11,7 +11,7 @@ import { import type { AppsRuntime } from "./runtime"; import { makeAppsStore } from "./store"; -import type { Bindings } from "./bindings"; +import type { Bindings, ClientResolver } from "./bindings"; // --------------------------------------------------------------------------- // The apps source plugin. Published custom tools become catalog citizens: the @@ -41,6 +41,17 @@ export interface AppsPluginOptions { readonly tool: string; readonly declared: Readonly>; }) => Bindings; + /** Build a per-request ClientResolver from the invoking executor context. + * This is how external integration calls route through the REAL per-request + * path (connections resolved by the invoking owner, credentials injected at + * the boundary), rather than the runtime's boot-time default resolver. The + * `ctx` is the plugin's per-request `PluginCtx`; the type is kept structural + * so this package does not depend on the host SDK's ctx shape. */ + readonly makeResolver?: (input: { + readonly ctx: unknown; + readonly scope: string; + readonly tool: string; + }) => ClientResolver; } const defaultBindings = ( @@ -69,6 +80,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { } const runtime = options.runtime; const resolveBindings = options.resolveBindings; + const makeResolver = options.makeResolver; return { id: APPS_PLUGIN_ID as "apps", @@ -101,10 +113,11 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { // and persists per connection. resolveTools: ({ connection }: ResolveToolsInput) => Effect.gen(function* () { - // The scope is the connection owner's tenant; for self-host single - // tenant we key the descriptor by the connection's integration-scoped - // name. We read the published descriptor for the scope encoded in the - // connection name (`apps/`), falling back to the connection name. + // The connection name encodes the scope via the formalized mapping + // (`apps/`). A scope with no published app yet legitimately + // resolves to ZERO tools (the connection exists before the first + // publish), so an empty descriptor here is an intentional empty result, + // not the silently-swallowed missing-tool case invokeTool guards against. const scope = scopeFromConnection(connection.name); const descriptor = yield* runtime.getDescriptor(scope); if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; @@ -120,17 +133,39 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { return { tools } satisfies ResolveToolsResult; }), - invokeTool: ({ toolRow, args }: InvokeToolInput) => + invokeTool: ({ ctx, toolRow, args }: InvokeToolInput) => Effect.gen(function* () { const scope = scopeFromConnection(toolRow.connection); const descriptor = yield* runtime.getDescriptor(scope); - const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); - const declared = toolDesc?.connections ?? {}; + // A missing descriptor / unknown tool is a typed error, NOT a silent + // `?? {}` default: invoking a tool that is not published in the scope + // must fail loudly rather than run with empty connections. + if (!descriptor) { + return yield* Effect.fail( + new Error( + `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`, + ), + ); + } + const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); + if (!toolDesc) { + return yield* Effect.fail( + new Error(`apps tool "${toolRow.name}" is not published in scope "${scope}"`), + ); + } + const declared = toolDesc.connections; const bindings = resolveBindings ? resolveBindings({ scope, tool: toolRow.name, declared }) : defaultBindings(declared); + // Build the per-request resolver from the invoking executor context so + // external calls route through the real per-request path (connections + + // credentials resolved at the boundary). Falls back to the runtime's + // boot-time resolver when the host supplies no factory. + const resolver = makeResolver + ? makeResolver({ ctx, scope, tool: toolRow.name }) + : undefined; return yield* runtime - .invokeTool({ scope, tool: toolRow.name, args, bindings }) + .invokeTool({ scope, tool: toolRow.name, args, bindings, resolver }) .pipe( Effect.mapError( (cause) => @@ -145,8 +180,24 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { }; }); -// Connection names encode the scope as `apps/` (or are the scope itself). -const scopeFromConnection = (connectionName: string): string => { +// --------------------------------------------------------------------------- +// Formalized scope <-> connection mapping. An apps connection is named +// `apps/` (integration-prefixed), so the scope is the segment after the +// first slash. A bare name (no slash) IS the scope (self-host single-tenant, +// where the sole connection is named for its scope). This is the ONE place the +// mapping lives; resolveTools and invokeTool both go through it. +// --------------------------------------------------------------------------- +export const APPS_CONNECTION_PREFIX = `${APPS_INTEGRATION_SLUG}/`; + +/** The connection name that addresses a scope's published app. */ +export const connectionNameForScope = (scope: string): string => + `${APPS_CONNECTION_PREFIX}${scope}`; + +/** The scope a connection name addresses (inverse of `connectionNameForScope`). */ +export const scopeFromConnection = (connectionName: string): string => { + if (connectionName.startsWith(APPS_CONNECTION_PREFIX)) { + return connectionName.slice(APPS_CONNECTION_PREFIX.length); + } const slash = connectionName.indexOf("/"); return slash === -1 ? connectionName : connectionName.slice(slash + 1); }; diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts index 21cd07c6f..4801ca0cb 100644 --- a/packages/plugins/apps/src/plugin/runtime.ts +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -61,6 +61,11 @@ export interface AppsRuntime { readonly tool: string; readonly args: unknown; readonly bindings: Bindings; + /** Optional per-request resolver override. The catalog invoke path supplies + * one built from the request's executor context (connections + credentials + * resolved at the boundary), so external calls route through the real + * per-request path rather than the boot-time default resolver. */ + readonly resolver?: ClientResolver; }) => Effect.Effect; readonly startWorkflow: (input: { readonly scope: string; @@ -210,6 +215,7 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { toolDesc: ToolDescriptor, args: unknown, bindings: Bindings, + resolver?: ClientResolver, ): Effect.Effect => Effect.gen(function* () { const roots = yield* rootsFor(toolDesc.connections, bindings); @@ -225,7 +231,9 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { declared: toolDesc.connections, bindings, db, - resolver: deps.resolver, + // Prefer the per-request resolver (real per-request executor context) + // over the boot-time default. + resolver: resolver ?? deps.resolver, }); const result = yield* deps.sandbox .invoke(code, { artifact: toolDesc.name, kind: "tool", input: args, roots }, bridge) @@ -340,6 +348,7 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { toolDesc, input.args, input.bindings, + input.resolver, ); }), diff --git a/packages/plugins/apps/src/plugin/self-host.ts b/packages/plugins/apps/src/plugin/self-host.ts index 34c18b971..440150d63 100644 --- a/packages/plugins/apps/src/plugin/self-host.ts +++ b/packages/plugins/apps/src/plugin/self-host.ts @@ -18,12 +18,16 @@ import { appsPlugin, type AppsPluginOptions } from "./apps-plugin"; export interface SelfHostAppsOptions { /** Data dir root for artifacts / scope-db / workflows / metadata. */ readonly dataDir: string; - /** Routes bound integration calls to real APIs (the executor invoke path). */ + /** Routes bound integration calls to real APIs (the executor invoke path). + * The boot-time default (used when no per-request `makeResolver` is given). */ readonly resolver: ClientResolver; /** The single scope this self-host instance serves (single-tenant). */ readonly scope: string; /** Optional binding resolution for the plugin's catalog invoke path. */ readonly resolveBindings?: AppsPluginOptions["resolveBindings"]; + /** Optional per-request resolver factory for the catalog invoke path (built + * from the invoking executor context). */ + readonly makeResolver?: AppsPluginOptions["makeResolver"]; } export interface SelfHostApps { @@ -45,7 +49,11 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => resolver: options.resolver, }); const http = makeAppsHttpRoutes({ runtime: host.runtime }); - const plugin = appsPlugin({ runtime: host.runtime, resolveBindings: options.resolveBindings }); + const plugin = appsPlugin({ + runtime: host.runtime, + resolveBindings: options.resolveBindings, + makeResolver: options.makeResolver, + }); return { runtime: host.runtime, http, From 191124e81429b54a0422cecfc5dd52b01af1b5c6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:06:18 -0700 Subject: [PATCH 15/23] apps: prove the subsystem over the wire + mount the UI in a real MCP-Apps host Add a booted-host wire e2e and a sunpeak widget proof, and fix the MCP-Apps serving shape so a real host can mount published UI. Wire e2e (apps/host-selfhost/src/apps-wire.node.test.ts): boots the real self-host app and drives the whole subsystem over a real MCP client (StreamableHTTP): publish over the MCP door, wire the scope into the catalog, invoke the published tool through the catalog path (the GitHub emulator ledger proves the upstream call), start the workflow with a journal, read the ui:// resource, observe the SSE invalidation, list + read the skill. Shape fixes surfaced by bringing serving up to what a real host expects: - ui resource is now a ResourceTemplate (a fixed URI only matched itself, so resources/read of ui:/// 404'd). - ui resource now serves a complete self-booting text/html document (React + a minimal executor:ui runtime + the compiled component + current scope-db rows, all inlined) instead of raw CJS, via AppsRuntime.getUiDocument and the new ui-shell. - add apps_open_ui, a tool carrying _meta.ui.resourceUri so a host renders the view when it runs. - the scope<->apps-connection mapping is now identifier-native (executor normalizes connection names to camelCase, so the old apps/ form did not survive create and resolveTools could not recover the scope). External integration routing is now live in the running server: the resolver reads an integration's base URL from its registered record and dispatches a published tool's calls through the caller's connection + credentials, threaded into both the catalog invoke and the workflow step.tool path. sunpeak harness (e2e/mcp-apps): mounts the published dashboard in headless replicas of the Claude and ChatGPT MCP-Apps hosts and asserts the widget renders scope-db rows. Uses the latest sunpeak with no patch script. --- APPS_DESIGN.md | 122 +- apps/host-selfhost/package.json | 1 + apps/host-selfhost/scripts/mcp-apps-serve.ts | 192 ++ apps/host-selfhost/src/apps-resolver.ts | 45 +- apps/host-selfhost/src/apps-wire.node.test.ts | 375 ++ bun.lock | 1 + e2e/mcp-apps/.gitignore | 4 + e2e/mcp-apps/README.md | 66 + e2e/mcp-apps/package-lock.json | 3010 +++++++++++++++++ e2e/mcp-apps/package.json | 15 + e2e/mcp-apps/playwright.config.ts | 58 + e2e/mcp-apps/tests/dashboard.spec.ts | 39 + packages/plugins/apps/src/mcp/register.ts | 92 +- packages/plugins/apps/src/mcp/ui-shell.ts | 243 ++ .../plugins/apps/src/plugin/apps-plugin.ts | 152 +- packages/plugins/apps/src/plugin/runtime.ts | 252 +- packages/plugins/apps/src/testing/e2e.test.ts | 60 +- 17 files changed, 4597 insertions(+), 130 deletions(-) create mode 100644 apps/host-selfhost/scripts/mcp-apps-serve.ts create mode 100644 apps/host-selfhost/src/apps-wire.node.test.ts create mode 100644 e2e/mcp-apps/.gitignore create mode 100644 e2e/mcp-apps/README.md create mode 100644 e2e/mcp-apps/package-lock.json create mode 100644 e2e/mcp-apps/package.json create mode 100644 e2e/mcp-apps/playwright.config.ts create mode 100644 e2e/mcp-apps/tests/dashboard.spec.ts create mode 100644 packages/plugins/apps/src/mcp/ui-shell.ts diff --git a/APPS_DESIGN.md b/APPS_DESIGN.md index 00797937f..4064832e1 100644 --- a/APPS_DESIGN.md +++ b/APPS_DESIGN.md @@ -146,7 +146,7 @@ Run from the workspace root - **Typecheck (repo root):** `bun run typecheck` -> 43/43 tasks green. - **Apps package (conformance + kill + pipeline + integration + e2e):** - `bun run --filter='@executor-js/plugin-apps' test` -> 9 files, 32 tests pass. + `bun run --filter='@executor-js/plugin-apps' test` -> 11 files, 44 tests pass. - ArtifactStore conformance (round-trip, snapshot immutability, log, isolation) - ScopeDb conformance (isolation, version bumps, tagged-template sql, LiveChannel delivery) @@ -156,35 +156,123 @@ Run from the workspace root step.tool journaling) + the SIGKILL kill test (side-effect file written once) - publish pipeline (daily-brief -> descriptor; rejects npm imports + bad skill) - AppsRuntime end-to-end (publish -> invoke into scope db -> workflow) - - the e2e proof (real GitHub emulator: publish MCP, invoke HTTP, workflow, ui - MCP resource + raw bundle, SSE invalidation, skills MCP) -- **Existing self-host suite still green (touched by the wiring):** - `bun run --filter='@executor-js/host-selfhost' test` -> 19 files, 75 tests pass - (includes the new booted `apps.node.test.ts`). + - the package e2e proof (real GitHub emulator: publish MCP, invoke HTTP, + workflow, ui MCP-Apps HTML document + raw bundle, SSE invalidation, skills MCP) +- **Self-host suite (existing + the new booted wire e2e):** + `bun run --filter='@executor-js/host-selfhost' test` -> 20 files, 82 tests pass + (75 original + 7 in the new `apps-wire.node.test.ts`). -The e2e is the single-command proof: +The package e2e is the single-command in-package proof: `bun run --filter='@executor-js/plugin-apps' test -- src/testing/e2e.test.ts`. +### Proof over the wire (Fix 5) — booted host, real MCP client + +`apps/host-selfhost/src/apps-wire.node.test.ts` boots the ACTUAL self-host app +(the same composition `serve.ts` uses), connects a REAL MCP client +(`@modelcontextprotocol/sdk` `Client` + `StreamableHTTPClientTransport`) to the +served `/mcp` endpoint, and drives the whole subsystem over the wire (no +`FakeMcpServer`): + +- publish the daily-brief file set over the `apps_publish` MCP door; +- wire the scope into the catalog via the `executor.apps.connect_catalog` + built-in (registers the `apps` integration + creates the `apps/` + connection through the caller's request context); the published `issues-sync` + tool becomes a searchable catalog citizen (`tools.apps.user.appsdefault.…`); +- invoke it through the catalog path (`execute` sandbox) — the GitHub emulator's + request ledger proves the upstream call landed and the scope db is written; +- start `morning-sync` (manual) through the `executor.apps.start_workflow` + built-in — it completes with a journaled `tool:issues-sync` step; +- read the `ui:///dashboard` resource over MCP (`resources/read`); +- observe the SSE `invalidate` frame over HTTP after a scope-db write; +- list + read the published skill over MCP. + +Single command: +`bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts`. + +### Proof of the widget mounting (MCP Apps host simulation) + +`e2e/mcp-apps/` is a [sunpeak](https://github.com/Sunpeak-AI/sunpeak) harness +that mounts the published dashboard in a headless replica of the Claude and +ChatGPT MCP-Apps host runtimes (sandboxed iframe + real host bridge, no VM, no +account). `apps/host-selfhost/scripts/mcp-apps-serve.ts` boots the real self-host +in-process, publishes the daily-brief app, populates the scope-db `issues` table +from a GitHub emulator, and serves `/mcp` on a fixed loopback port with the +Better-Auth bearer injected. The spec renders the `apps_open_ui` tool (whose +`_meta.ui.resourceUri` links to the ui resource) and asserts the widget MOUNTS +and RENDERS the scope-db rows (`2 open issues`, `…/app#`) in BOTH host sims. + +Single command (isolated from the bun workspace; uses the latest sunpeak with NO +patch script — sunpeak now advertises the MCP-Apps UI client capability +upstream): +``` +cd e2e/mcp-apps && npm install && npm test +``` + +### Shape fixes made to the MCP Apps serving (in scope, Fix 5) + +Bringing the serving up to what a real host (per sunpeak) expects surfaced three +shape bugs, now fixed: + +1. **ui resource was a fixed URI, not a template.** `registerResource` was + passed the literal string `ui:///`, which only matches itself, so + `resources/read` of `ui:///` 404'd. Now a real + `ResourceTemplate("ui:///{name}")` (mcp/register.ts). +2. **ui resource served raw compiled JS, not a document.** A real MCP-Apps host + mounts an HTML document, not a CJS blob. `mcp/ui-shell.ts` now wraps the + compiled bundle into a complete, self-booting `text/html;profile=mcp-app` + document: React + a minimal `executor:ui` runtime (`useQuery`/`useTool`/ + `config`) + component primitives + the current scope-db rows, all inlined + (hermetic — renders under a strict sandbox CSP with no network). Served by the + new `AppsRuntime.getUiDocument`. +3. **no tool linked a host to the ui resource.** MCP-Apps hosts render a view + when a tool declaring `_meta.ui.resourceUri` runs. Added `apps_open_ui` + (mcp/register.ts) carrying that extension. + +The scope <-> apps-connection mapping was also fixed: the executor normalizes +connection names to camelCase identifiers, so the old `apps/` form did +not survive create (`apps/default` -> `appsDefault`) and `resolveTools` could not +recover the scope. The mapping is now identifier-native (`apps` + PascalCase +scope), round-tripping cleanly (apps-plugin.ts). + +The running-server external-integration routing (previously a documented +NotImplemented gap) is now LIVE: `apps/host-selfhost/src/apps-resolver.ts` +resolves an integration's base URL from its registered record's config, so a +published tool's `github.*` calls dispatch through the caller's connection + +credentials to the real upstream (proven end-to-end against the emulator in the +wire e2e). The per-request resolver is threaded into both the catalog tool-invoke +path AND the workflow `step.tool` path (via `startWorkflow`'s new `resolver`). + ## Known gaps (honest list) - **catalog() open-world proxy**: parsed + recorded in the descriptor; execution throws NotImplemented (in scope per the brief). -- **Running-server external-integration routing**: the ClientResolver returns - NotImplemented for external APIs in the booted self-host server (decision 2 - above). The full real path is proven in the e2e against the emulator; the - scope-db path is live in-server. +- **Running-server external-integration routing** (was NotImplemented; now + LIVE): a published tool's external calls dispatch through the caller's + connection + credentials to the real upstream, resolved per request from the + integration record's base URL (apps-resolver.ts). Remaining: the dotted + method-path -> REST-endpoint mapping is GitHub-style (`repos.listForAuthenticatedUser` + -> `/user/repos`, `issues.listForRepo` -> `/repos/{owner}/{repo}/issues`), enough + for the daily-brief fixture and REST-shaped integrations whose method paths + mirror their URLs; a fully general per-integration operation table (and + GraphQL/non-REST) fails with a typed BindingError rather than guessing. +- **Live SSE refetch INTO the mounted widget**: the ui document's `useQuery` + renders the rows the server inlines at read time and re-renders on a host + `executor:ui/rows` postMessage, and the SSE `invalidate` frame is proven over + HTTP (wire e2e) — but wiring the SSE stream through the MCP-Apps host bridge so + the mounted widget auto-refetches on a scope-db write is a follow-up. The + sunpeak spec proves mount + first-paint row rendering; live update is not yet + asserted in a host sim. - **Workflow orchestrator isolation**: in-process (decision 1 above). - **Scheduler**: schedules are extracted to the descriptor (IaC-visible) and a workflow can be started manually or by a caller; a standalone cron daemon that auto-fires due schedules is a thin wrapper over `startWorkflow` and is not built (the e2e/tests start runs explicitly). `step.sleep` timers are recorded but a wake-timer that auto-resumes sleeping runs is likewise a thin follow-up. -- **MCP registration into the shared server**: the apps MCP tools/resources are - implemented and proven in the e2e against a real McpServer-shaped interface, - and exposed via `registerMcp`. Hooking them into the shared - `createExecutorMcpServer` build in the running server would touch shared api - code (a registration callback); the HTTP publish door is wired in-server as the - equivalent authoring path. +- **MCP registration into the shared server** (was "not hooked in"; now LIVE): + the self-host MCP seams take an `onServer` hook (apps/host-selfhost/src/mcp), + and `app.ts` registers the apps MCP surface (publish door, skills, ui:// + resources, `apps_open_ui`) on every per-session MCP server. The wire e2e drives + all of it through a real MCP client over the served `/mcp` endpoint. - **Effect-lint suggestions**: a handful of `preferSchemaOverJson` / `unnecessaryFailYieldableError` suggestions and `globalErrorInEffectFailure` warnings remain (non-fatal; the tsconfig plugin excludes them from the tsc exit diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index e9cc437eb..bb3119599 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/emulate": "^0.13.2", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/host-selfhost/scripts/mcp-apps-serve.ts b/apps/host-selfhost/scripts/mcp-apps-serve.ts new file mode 100644 index 000000000..f6344d4f8 --- /dev/null +++ b/apps/host-selfhost/scripts/mcp-apps-serve.ts @@ -0,0 +1,192 @@ +// Boot the REAL self-host app in-process, seed a published daily-brief app with +// scope-db rows, then serve its `/mcp` endpoint on a fixed loopback port for the +// sunpeak host simulation (e2e/mcp-apps) to connect to. +// +// The self-host MCP endpoint is Better-Auth-gated. Rather than teach sunpeak the +// auth dance, this wrapper owns auth: it signs up once, holds the bearer, and +// injects it on every forwarded request. sunpeak connects to `/mcp` with no +// credentials; the wrapper adds the Authorization header. +// +// Lives here (under apps/host-selfhost) so its imports resolve through this +// package's node_modules under Bun. Launched by e2e/mcp-apps/playwright.config.ts. +// +// Env in: PORT (wrapper port, default 8791). Health: GET /health -> 200 "ok". + +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createEmulator } from "@executor-js/emulate"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing"; + +import { mintInviteCode } from "../src/testing/mint-invite"; + +const PORT = Number(process.env.PORT ?? "8791"); +const origin = "http://mcp-apps.internal"; +const log = (...args: unknown[]) => console.log("[mcp-apps-server]", ...args); + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-mcpapps-")); +process.env.BETTER_AUTH_SECRET = "mcp-apps-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@mcp-apps.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; +process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; + +// --- real-shaped GitHub emulator + a seeded repo with issues ---------------- +const github = await createEmulator({ service: "github" }); +const cred = (await github.credentials.mint({ + type: "api-key", +})) as unknown as { token: string }; +const ghToken = cred.token; +const ghHeaders = { + authorization: `Bearer ${ghToken}`, + accept: "application/vnd.github+json", + "content-type": "application/json", +}; +const repoRes = await fetch(`${github.url}/user/repos`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ name: "app" }), +}); +const owner = ((await repoRes.json()) as { owner: { login: string } }).owner.login; +for (const title of ["Fresh bug", "Second bug"]) { + await fetch(`${github.url}/repos/${owner}/app/issues`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ title, labels: ["bug"] }), + }); +} +log("emulator ready", github.url, "owner", owner); + +// --- boot the real self-host handler --------------------------------------- +const { makeSelfHostApiHandler } = await import("../src/app"); +const app = await makeSelfHostApiHandler(); +const handler = app.handler; + +// --- sign up -> bearer token ------------------------------------------------ +const inviteCode = await mintInviteCode(handler); +const su = await handler( + new Request(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@mcp-apps.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), +); +const token = su.headers.get("set-auth-token"); +if (!token) throw new Error("sign-up produced no token"); + +const api = (path: string, init: RequestInit = {}) => + handler( + new Request(`${origin}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }), + ); + +// --- register the emulator as `github`, plus a connection to it ------------- +const spec = JSON.stringify({ + openapi: "3.0.0", + info: { title: "GitHub (emulated)", version: "1.0.0" }, + servers: [{ url: github.url }], + paths: { + "/user/repos": { + get: { + operationId: "listRepos", + responses: { 200: { description: "ok" } }, + }, + }, + }, +}); +await api("/api/openapi/specs", { + method: "POST", + body: JSON.stringify({ + spec: { kind: "blob", value: spec }, + slug: "github", + baseUrl: github.url, + }), +}); +await api("/api/connections", { + method: "POST", + body: JSON.stringify({ + owner: "user", + name: "github-emu", + integration: "github", + template: "bearer", + value: ghToken, + }), +}); + +// --- connect a real MCP client to publish + populate rows ------------------- +const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + handler(input instanceof Request ? input : new Request(input, init)); +const client = new Client({ name: "mcp-apps-setup", version: "1.0.0" }); +await client.connect( + new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + fetch: wireFetch as unknown as typeof globalThis.fetch, + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }), +); +await client.callTool({ + name: "apps_publish", + arguments: { files: Object.fromEntries(dailyBriefFileSet()) }, +}); +await client.callTool({ + name: "execute", + arguments: { + code: "export default await tools.executor.apps.connect_catalog({});", + }, +}); +await client.callTool({ + name: "execute", + arguments: { + code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`, + }, +}); +await client.close(); +log("published daily-brief + populated issues"); + +// --- serve a wrapper that injects the bearer for sunpeak -------------------- +const server = Bun.serve({ + port: PORT, + hostname: "127.0.0.1", + idleTimeout: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${token}`); + return handler(new Request(request, { headers })); + }, +}); +log(`serving /mcp for sunpeak at http://127.0.0.1:${server.port}/mcp`); + +const shutdown = async () => { + try { + server.stop(true); + } catch { + /* ignore */ + } + try { + await app.dispose(); + } catch { + /* ignore */ + } + try { + await github.close(); + } catch { + /* ignore */ + } + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/apps/host-selfhost/src/apps-resolver.ts b/apps/host-selfhost/src/apps-resolver.ts index 1e0054839..42d068342 100644 --- a/apps/host-selfhost/src/apps-resolver.ts +++ b/apps/host-selfhost/src/apps-resolver.ts @@ -46,6 +46,22 @@ export interface AppsResolverCtx { readonly integration: string; }) => Effect.Effect; }; + /** Catalog integration lookup, used to resolve an integration's base URL from + * its registered record. An operator can register a `github` integration + * (OpenAPI-shaped) pointed at a loopback emulator; the base URL it stores in + * the integration's opaque config is what the resolver dispatches against. + * Kept structural so the host does not import the SDK's ctx type. */ + readonly core?: { + readonly integrations: { + readonly get: (slug: string) => Effect.Effect; + }; + }; +} + +interface AppsResolverIntegration { + /** The owning plugin's opaque config. OpenAPI-shaped integrations carry the + * spec server URL here as `baseUrl`. */ + readonly config?: { readonly baseUrl?: string } | null; } interface AppsResolverConnection { @@ -94,7 +110,11 @@ const methodPathToRequest = ( const { owner: _o, repo: _r, ...rest } = query as Record; void _o; void _r; - return { method: "GET", url: `/repos/${owner}/${repo}/issues`, query: rest }; + return { + method: "GET", + url: `/repos/${owner}/${repo}/issues`, + query: rest, + }; } default: return null; @@ -124,7 +144,11 @@ export const makeCtxResolver = ( ); } const token = yield* ctx.connections - .resolveValue({ owner: conn.owner, name: conn.name, integration: conn.integration }) + .resolveValue({ + owner: conn.owner, + name: conn.name, + integration: conn.integration, + }) .pipe(Effect.orElseSucceed(() => null)); const req = methodPathToRequest(path, (args[0] ?? {}) as Record); @@ -137,7 +161,22 @@ export const makeCtxResolver = ( }), ); } - const baseUrl = conn.config?.baseUrl ?? DEFAULT_BASE_URLS[integrationSlug]; + // Base URL precedence: the connection's own config override (rare), then + // the registered integration record's non-secret displayUrl (an operator + // registering `github` against a loopback emulator sets it there), then + // the built-in default. Reading it from the integration record is the + // right seam: the base URL is an integration property, not a credential. + const integrationBaseUrl = ctx.core + ? yield* ctx.core.integrations.get(integrationSlug).pipe( + Effect.map((rec) => { + const base = rec?.config?.baseUrl; + return typeof base === "string" && base.length > 0 ? base : undefined; + }), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const baseUrl = + conn.config?.baseUrl ?? integrationBaseUrl ?? DEFAULT_BASE_URLS[integrationSlug]; if (!baseUrl) { return yield* Effect.fail( new BindingError({ diff --git a/apps/host-selfhost/src/apps-wire.node.test.ts b/apps/host-selfhost/src/apps-wire.node.test.ts new file mode 100644 index 000000000..27bc0067f --- /dev/null +++ b/apps/host-selfhost/src/apps-wire.node.test.ts @@ -0,0 +1,375 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { createEmulator } from "@executor-js/emulate"; + +import { dailyBriefFileSet } from "@executor-js/plugin-apps/testing"; + +import { mintInviteCode } from "./testing/mint-invite"; + +// =========================================================================== +// THE BOOTED-HOST WIRE E2E (Fix 5): prove the apps subsystem over real HTTP + +// a real MCP client, against the ACTUAL self-host app (the same composition +// serve.ts uses), bound to an ephemeral socket. +// +// One runnable command: +// bun run --filter='@executor-js/host-selfhost' test -- src/apps-wire.node.test.ts +// +// The chain, all over the wire (nothing faked, no FakeMcpServer): +// 1. boot makeSelfHostApiHandler on an ephemeral Bun.serve port +// 2. stand up a real-shaped GitHub via @executor-js/emulate; seed a repo+issues +// 3. sign up (Better Auth) -> bearer token; register the emulator as the +// `github` integration and create a connection to it (its minted token) +// 4. connect a REAL MCP Client over StreamableHTTP to /mcp +// 5. publish daily-brief over the `apps_publish` MCP door +// 6. tools/list over MCP shows the published tool AS A CATALOG TOOL +// 7. invoke it through the catalog path; the emulator's ledger proves the +// upstream GitHub call landed +// 8. start the workflow (manual) over HTTP; see it complete with a journal +// 9. read the ui:// resource over MCP (resources/read) +// 10. make a scope-db write; observe the SSE invalidation frame over HTTP +// 11. list + read the published skill over MCP +// =========================================================================== + +// Better Auth needs a secret + a bootstrap admin before the app graph imports. +const dataDir = mkdtempSync(join(tmpdir(), "eh-apps-wire-")); +process.env.EXECUTOR_DATA_DIR = dataDir; +process.env.BETTER_AUTH_SECRET = "apps-wire-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-wire.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; +// The apps ClientResolver dials the emulator over loopback; allow it. +process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; + +const SCOPE = "default"; +const GITHUB_INTEGRATION = "github"; +const GITHUB_CONNECTION = "github-emu"; + +type LocalEmulator = Awaited>; + +describe("Executor apps wire e2e (booted self-host, real MCP client + GitHub emulator)", () => { + let handler!: (request: Request) => Promise; + let dispose: () => Promise = async () => {}; + let github: LocalEmulator; + // The booted app is an in-process web handler; the "wire" is a real MCP client + // + real JSON-RPC/SSE framing over a fetch bound to it (vitest runs under Node, + // so there is no ambient Bun.serve to bind an ephemeral socket to). `origin` is + // the URL the transport dials; the injected fetch routes it to the handler. + const origin = "http://apps-wire.internal"; + let token = ""; + let owner = ""; + + // A JSON fetch that carries the bearer, aimed at the booted handler. + const api = (path: string, init?: RequestInit) => + handler( + new Request(`${origin}${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }), + ); + + // Fetch shim the MCP StreamableHTTP transport uses: forward WHATWG fetch calls + // straight into the booted handler. + const wireFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = input instanceof Request ? input : new Request(input, init); + return handler(request); + }; + + let client: Client; + + beforeAll(async () => { + // --- real-shaped GitHub (emulate) + seed a repo with two issues --------- + github = await createEmulator({ service: "github" }); + const cred = (await github.credentials.mint({ + type: "api-key", + })) as unknown as { + token: string; + login: string; + }; + const ghToken = cred.token; + const ghHeaders = { + authorization: `Bearer ${ghToken}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + }; + const repoRes = await fetch(`${github.url}/user/repos`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ name: "app" }), + }); + const repo = (await repoRes.json()) as { owner: { login: string } }; + owner = repo.owner.login; + for (const title of ["Fresh bug", "Second bug"]) { + await fetch(`${github.url}/repos/${owner}/app/issues`, { + method: "POST", + headers: ghHeaders, + body: JSON.stringify({ title, labels: ["bug"] }), + }); + } + + // --- boot the REAL self-host app (the composition serve.ts uses) -------- + const { makeSelfHostApiHandler } = await import("./app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; + + // --- sign up -> bearer token -------------------------------------------- + const inviteCode = await mintInviteCode(handler); + const su = await handler( + new Request(`${origin}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@apps-wire.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), + ); + token = su.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); + + // --- register the emulator as the `github` integration + a connection ---- + // An OpenAPI-shaped integration whose baseUrl points at the loopback + // emulator: the apps ClientResolver reads that base URL from the integration + // record and dispatches the published tool's github.* calls there. A minimal + // spec is enough; the resolver builds REST paths itself (it doesn't drive the + // spec's operations), so the integration exists only to carry the base URL + + // hold the connection's credential. + const spec = JSON.stringify({ + openapi: "3.0.0", + info: { title: "GitHub (emulated)", version: "1.0.0" }, + servers: [{ url: github.url }], + paths: { + "/user/repos": { + get: { + operationId: "listRepos", + responses: { "200": { description: "ok" } }, + }, + }, + }, + }); + const addSpec = await api("/api/openapi/specs", { + method: "POST", + body: JSON.stringify({ + spec: { kind: "blob", value: spec }, + slug: GITHUB_INTEGRATION, + baseUrl: github.url, + }), + }); + expect(addSpec.status).toBe(200); + + const conn = await api("/api/connections", { + method: "POST", + body: JSON.stringify({ + owner: "user", + name: GITHUB_CONNECTION, + integration: GITHUB_INTEGRATION, + template: "bearer", + value: ghToken, + }), + }); + expect(conn.status).toBe(200); + + // --- connect a REAL MCP client over StreamableHTTP to /mcp -------------- + client = new Client({ name: "apps-wire-client", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`${origin}/mcp`), { + fetch: wireFetch as unknown as typeof globalThis.fetch, + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }); + await client.connect(transport); + }, 90_000); + + afterAll(async () => { + await client?.close().catch(() => {}); + await dispose(); + await github?.close(); + }); + + it("publishes the daily-brief set over the MCP publish door", async () => { + const result = (await client.callTool({ + name: "apps_publish", + arguments: { files: Object.fromEntries(dailyBriefFileSet()) }, + })) as { + structuredContent?: { + tools?: string[]; + workflows?: string[]; + ui?: string[]; + skills?: string[]; + }; + }; + const sc = result.structuredContent!; + expect((sc.tools ?? []).sort()).toEqual(["issues-sync", "search-all-mail"]); + expect(sc.workflows).toEqual(["morning-sync"]); + expect(sc.ui).toEqual(["dashboard"]); + expect(sc.skills).toEqual(["issues-brief"]); + }); + + it("wires the published app into the catalog; the tool becomes a catalog citizen", async () => { + // Wire the scope into the catalog through the REAL request path: the built-in + // `executor.apps.connect_catalog` tool registers the `apps` integration and + // creates the apps/ connection for the caller. Invoked over MCP via + // the `execute` sandbox, exactly as an agent would. + const connect = (await client.callTool({ + name: "execute", + arguments: { + code: "export default await tools.executor.apps.connect_catalog({});", + }, + })) as { isError?: boolean; structuredContent?: { status?: string } }; + expect(connect.isError ?? false).toBe(false); + expect(connect.structuredContent?.status).toBe("completed"); + + // The published tool is now discoverable as a catalog tool through the same + // `tools.search` an agent uses (executor exposes catalog tools inside the + // `execute` sandbox, not as flat MCP tools — `execute` IS the catalog door). + const search = (await client.callTool({ + name: "execute", + arguments: { + code: 'export default (await tools.search({ query: "sync github issues into the scope table", limit: 30 })).items.map((m) => m.path)', + }, + })) as { structuredContent?: { result?: string[] } }; + const paths = search.structuredContent?.result ?? []; + // Addressed as tools.apps... — a real catalog citizen. + expect(paths.some((p) => p.includes("apps.") && p.includes("issues-sync"))).toBe(true); + }); + + it("invokes the published tool through the catalog path; the ledger proves the upstream GitHub call landed", async () => { + const before = (await github.ledger.list()).length; + + // Invoke via the MCP `execute` sandbox, addressing the published tool through + // the catalog: tools.apps...issues-sync(...) routes through + // resolveTools/invokeTool -> the sandbox -> the per-request ClientResolver -> + // the GitHub emulator, then writes the scope db. + const call = (await client.callTool({ + name: "execute", + arguments: { + code: `export default await tools.apps.user.appsdefault['issues-sync']({ repos: ["${owner}/app"] });`, + }, + })) as { + isError?: boolean; + structuredContent?: { + status?: string; + result?: { ok?: boolean; data?: { synced?: number } }; + }; + }; + + expect(call.isError ?? false).toBe(false); + expect(call.structuredContent?.status).toBe("completed"); + // The execute sandbox wraps a tool result as { ok, data }. + expect(call.structuredContent?.result?.data?.synced).toBe(2); + + // The emulator's request ledger proves the tool really called GitHub. + const ledger = await github.ledger.list(); + expect(ledger.length).toBeGreaterThan(before); + expect( + ledger.some( + (e) => + String(e.operationId ?? "") + .toLowerCase() + .includes("issue") || + String((e as { path?: string }).path ?? "") + .toLowerCase() + .includes("issues"), + ), + ).toBe(true); + }); + + it("starts the workflow (manual) and sees it complete with a journal", async () => { + // Start the workflow through the REAL request path (the `start_workflow` + // built-in over the MCP execute sandbox), so its `step.tool` external call + // reaches the emulator through the caller's connection + per-request resolver. + const started = (await client.callTool({ + name: "execute", + arguments: { + code: + "export default await tools.executor.apps.start_workflow(" + + '{ workflow: "morning-sync", runId: "wire-morning" });', + }, + })) as { + isError?: boolean; + structuredContent?: { + status?: string; + result?: { data?: { status?: string } }; + }; + }; + expect(started.isError ?? false).toBe(false); + expect(started.structuredContent?.result?.data?.status).toBe("completed"); + + const hist = await api(`/api/apps/${SCOPE}/workflows/runs/wire-morning`, { + method: "GET", + }); + const steps = ((await hist.json()) as { steps: { name: string; status: string }[] }).steps; + expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe(true); + }); + + it("reads the ui:// resource over MCP (resources/read)", async () => { + const read = await client.readResource({ uri: `ui://${SCOPE}/dashboard` }); + const first = read.contents[0] as { + mimeType?: string; + text?: string; + _meta?: { ui?: { title?: string } }; + }; + expect(first.mimeType).toContain("mcp"); + expect(first.text).toContain("issues"); + expect(first._meta?.ui?.title).toBe("GitHub Issues"); + }); + + it("delivers an SSE invalidation frame over HTTP after a scope-db write", async () => { + const res = await api(`/api/apps/${SCOPE}/live`, { method: "GET" }); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + + const first = await reader.read(); + expect(decoder.decode(first.value)).toContain("event: ready"); + + // Re-invoke issues-sync via HTTP to write the scope db (a fresh write bumps + // the version and fires the invalidation). + await api(`/api/apps/${SCOPE}/tools/issues-sync`, { + method: "POST", + body: JSON.stringify({ + args: { repos: [`${owner}/app`] }, + bindings: { github: { kind: "single", connection: GITHUB_CONNECTION } }, + }), + }); + + const invalidation = await Promise.race([ + (async () => { + // Drain frames until we see an invalidate (there may be keepalives). + for (let i = 0; i < 10; i++) { + const chunk = await reader.read(); + if (chunk.done) break; + const text = decoder.decode(chunk.value); + if (text.includes("event: invalidate")) return text; + } + return ""; + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error("SSE timeout")), 8000)), + ]); + expect(invalidation).toContain("event: invalidate"); + expect(invalidation).toContain('"table":"issues"'); + await reader.cancel(); + }); + + it("lists and reads the published skill over MCP", async () => { + const listed = (await client.callTool({ + name: "apps_list_skills", + arguments: {}, + })) as { structuredContent?: { skills?: { name: string }[] } }; + expect((listed.structuredContent?.skills ?? []).map((s) => s.name)).toEqual(["issues-brief"]); + + const readSkill = (await client.callTool({ + name: "apps_read_skill", + arguments: { name: "issues-brief" }, + })) as { content?: { text: string }[] }; + expect(readSkill.content?.[0]?.text).toContain("GitHub issues brief"); + }); +}); diff --git a/bun.lock b/bun.lock index e9df62144..24e555359 100644 --- a/bun.lock +++ b/bun.lock @@ -247,6 +247,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@executor-js/emulate": "^0.13.2", "@executor-js/vite-plugin": "workspace:*", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/e2e/mcp-apps/.gitignore b/e2e/mcp-apps/.gitignore new file mode 100644 index 000000000..014c4cc36 --- /dev/null +++ b/e2e/mcp-apps/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +.server.json diff --git a/e2e/mcp-apps/README.md b/e2e/mcp-apps/README.md new file mode 100644 index 000000000..8ad89cc6e --- /dev/null +++ b/e2e/mcp-apps/README.md @@ -0,0 +1,66 @@ +# MCP Apps tests (sunpeak) — executor apps' published UI + +Mount and test **executor apps' published UI** (the daily-brief dashboard, served +as a `ui://` MCP-Apps resource) in a **real MCP-Apps host**, headlessly, in CI, +with **no VM and no Claude/ChatGPT account**. + +[sunpeak](https://github.com/Sunpeak-AI/sunpeak) locally replicates the Claude +and ChatGPT app runtimes: it connects to an MCP server, calls a UI tool, mounts +the returned `ui://` resource in a sandboxed iframe with the host bridge, and +hands the test a frame-scoped handle to the rendered component. Every test runs +against both host simulations. + +## Run + +```bash +cd e2e/mcp-apps +npm install +npm test # boots our self-host MCP server + sunpeak, runs the specs +``` + +`playwright.config.ts` starts two web servers: our self-host wrapper +(`scripts/start-server.mjs`, under Bun) and sunpeak's inspect backend. The +wrapper boots the REAL self-host app in-process, publishes the daily-brief app, +populates its scope-db `issues` table from a GitHub emulator, and serves `/mcp` +on a fixed loopback port with the Better-Auth bearer injected (so sunpeak needs +no credentials). + +A spec is tiny: + +```ts +import { test, expect } from "sunpeak/test"; +test("dashboard mounts", async ({ inspector }) => { + const result = await inspector.renderTool("apps_open_ui", { name: "dashboard" }); + await expect(result.app().getByText("Open issues")).toBeVisible(); + await expect(result.app().getByText(/\/app#\d+/)).toBeVisible(); +}); +``` + +## How our UI reaches a host + +Our published `ui:///` resource is a COMPLETE, self-booting HTML +document (`text/html;profile=mcp-app`): React, the `executor:ui` runtime +(`useQuery`/`useTool`/`config`), the compiled component, and the current +scope-db rows are all inlined, so it renders under a strict sandbox CSP with no +network. The `apps_open_ui` MCP tool declares `_meta.ui.resourceUri` pointing at +that resource, which is how a real MCP-Apps host (and sunpeak) knows to render +it when the tool runs. See `packages/plugins/apps/src/mcp/ui-shell.ts` (document +builder) and `.../mcp/register.ts` (tool + resource template). + +## Notes (vs the earlier render-ui harness) + +1. **Use the LATEST sunpeak and DO NOT patch it.** sunpeak now advertises the + MCP-Apps UI *client* capability upstream, so widgets mount inline without the + old `scripts/patch-sunpeak.mjs` (dropped here on purpose). +2. **Extra iframe descent.** Our shell mounts the component directly in the host + sandbox iframe, so `result.app()` reaches it. If the shell is ever changed to + nest a further `srcdoc` iframe, add one `.frameLocator("iframe")` descent in + the spec. + +## Scope + +sunpeak is a host *simulation*: it covers the protocol contract, the rendered UI, +and tool/theme/display-mode behavior. It does not catch real-host quirks (OAuth, +production CSP). Live SSE-driven refetch INTO the mounted widget is a documented +follow-up (see `APPS_DESIGN.md`); this harness proves mount + first-paint row +rendering. diff --git a/e2e/mcp-apps/package-lock.json b/e2e/mcp-apps/package-lock.json new file mode 100644 index 000000000..5162a9eab --- /dev/null +++ b/e2e/mcp-apps/package-lock.json @@ -0,0 +1,3010 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@executor-js/e2e-mcp-apps", + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.60" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.8.tgz", + "integrity": "sha512-ZqT9kLxS2Cbo/4juwtEGbYyP0jNQTnUUKy2nQ8Vn28xqNu50+TukO/oCEWCpm18YbQq7N+hr6ClJs/ATTFpBTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.12.tgz", + "integrity": "sha512-Y7Fy8xJwPz7ZC0DhSQG3HIVk+drup42hrIj6yqKlib3CxwiR0F7nYyUI8+kPrEtbZEoyKoRstvT4/o0HEyFBHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.7.tgz", + "integrity": "sha512-55K0j8RRjcKosUvIl8u/+SMaS1wedq77xN2iuhlOvB/USbIgSmjkWKV9lqGhhp+Qxhnm3qg5NzrqOI1jmra9fQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz", + "integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz", + "integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", + "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ai": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.15.tgz", + "integrity": "sha512-7406MUy9O5sIhwOgxEWuoj+td3XUGgG96SBt6pmBU4t4sQ2fpnDx5UnHaXT2HUTXl5GorbWz/MfCrSPRz0QJqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.12", + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/sunpeak": { + "version": "0.20.60", + "resolved": "https://registry.npmjs.org/sunpeak/-/sunpeak-0.20.60.tgz", + "integrity": "sha512-mHpnB/GnQEzmET6vHDKFc5bTar5Iff4p7VEg3A/W218FbhUNSmyXFeNjdtZ10QTChFPgDguVAr7Y41KnKWikvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/openai": "^4.0.0", + "@clack/prompts": "^1.6.0", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/sdk": "^1.29.0", + "@vitejs/plugin-react": "^6.0.3", + "ai": "^7.0.2", + "clsx": "^2.1.1", + "esbuild": "^0.28.1", + "tailwind-merge": "^3.6.0", + "vite": "8.1.0", + "zod": "^4.4.3" + }, + "bin": { + "sunpeak": "bin/sunpeak.js" + }, + "peerDependencies": { + "@ai-sdk/google": "^4.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/google": { + "optional": true + } + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/e2e/mcp-apps/package.json b/e2e/mcp-apps/package.json new file mode 100644 index 000000000..1512f9632 --- /dev/null +++ b/e2e/mcp-apps/package.json @@ -0,0 +1,15 @@ +{ + "name": "@executor-js/e2e-mcp-apps", + "private": true, + "description": "Mount + test executor apps' published UI in a real MCP-Apps host (sunpeak), headless, no VM.", + "type": "module", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "sunpeak": "^0.20.60" + } +} diff --git a/e2e/mcp-apps/playwright.config.ts b/e2e/mcp-apps/playwright.config.ts new file mode 100644 index 000000000..3caa10114 --- /dev/null +++ b/e2e/mcp-apps/playwright.config.ts @@ -0,0 +1,58 @@ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +import { defineConfig } from "sunpeak/test/config"; + +// sunpeak runs `sunpeak inspect` (a real MCP-Apps host simulation that mounts +// ui:// resources in a sandboxed iframe) as its Playwright web server, connects +// it to the MCP server below, and gives each spec a frame-scoped handle to the +// rendered app. Every spec runs against both the Claude and ChatGPT host +// simulations. +// +// We point sunpeak at OUR self-host over HTTP. `scripts/start-server.mjs` boots +// the real self-host app in-process, publishes the daily-brief app + populates +// its scope-db `issues` table, and serves `/mcp` on a fixed loopback port with +// the Better-Auth bearer injected (so sunpeak needs no credentials). We append +// that wrapper as a SECOND Playwright webServer (health-gated), alongside +// sunpeak's inspect backend. +// +// IMPORTANT (vs the older render-ui harness): use the LATEST sunpeak and DO NOT +// patch it. sunpeak now advertises the MCP-Apps UI *client* capability upstream, +// so widgets mount inline without the old scripts/patch-sunpeak.mjs. + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = resolve(here, "../.."); +const hostDir = resolve(repo, "apps/host-selfhost"); + +const PORT = process.env.MCP_APPS_PORT ?? "8791"; + +const base = defineConfig({ + testDir: "tests", + hosts: ["claude", "chatgpt"], + server: { url: `http://127.0.0.1:${PORT}/mcp` }, + timeout: 120_000, +}); + +// Our self-host wrapper server, started before the specs and torn down after. +const ourServer = { + // The seeding server lives under apps/host-selfhost so its imports (emulate, + // MCP SDK, workspace source) resolve through that package's node_modules. + command: `bun ${resolve(hostDir, "scripts/mcp-apps-serve.ts")}`, + cwd: hostDir, + url: `http://127.0.0.1:${PORT}/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { PORT }, +}; + +const baseWebServers = Array.isArray(base.webServer) + ? base.webServer + : base.webServer + ? [base.webServer] + : []; + +export default { + ...base, + // Start our MCP server first, then sunpeak's inspect backend. + webServer: [ourServer, ...baseWebServers], +}; diff --git a/e2e/mcp-apps/tests/dashboard.spec.ts b/e2e/mcp-apps/tests/dashboard.spec.ts new file mode 100644 index 000000000..dc9098487 --- /dev/null +++ b/e2e/mcp-apps/tests/dashboard.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "sunpeak/test"; + +// executor apps serve a published UI view (`ui:///dashboard`) as a +// complete, self-booting MCP-Apps HTML document (React + the executor:ui runtime +// + the compiled component + the current scope-db rows, all inline). The +// `apps_open_ui` tool declares `_meta.ui.resourceUri` linking to that resource, +// so a real MCP-Apps host renders the widget when the tool runs. +// +// This spec proves the widget actually MOUNTS and RENDERS rows from the scope db +// inside a simulated host. sunpeak runs it against BOTH the Claude and ChatGPT +// host simulations (Playwright projects). + +// Our published document mounts the component directly inside the host's sandbox +// iframe. sunpeak's `result.app()` already descends one nested iframe; if our +// shell ever nests a further srcdoc iframe, add one more `.frameLocator("iframe")` +// descent here (see README). +const appBody = (result: { app: () => ReturnType["app"]> } | any) => + result.app(); + +test("the published dashboard mounts and renders scope-db issue rows", async ({ inspector }) => { + // No input: `apps_open_ui` takes none. sunpeak renders the tool's declared + // ui resource (`_meta.ui.resourceUri` -> ui:///dashboard) in the host + // sandbox. + const result = await inspector.renderTool("apps_open_ui"); + const app = appBody(result); + + // The widget's chrome renders (proves React mounted inside the host sandbox). + await expect(app.getByText("Open issues", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + // The scope-db rows the server inlined render: the daily-brief `issues-sync` + // populated the `issues` table (2 issues) from the GitHub emulator, and the + // dashboard shows the live count + lists each `repo#number`. + await expect(app.getByText("2 open issues")).toBeVisible({ timeout: 30_000 }); + await expect(app.getByText(/\/app#\d+/).first()).toBeVisible({ + timeout: 30_000, + }); +}); diff --git a/packages/plugins/apps/src/mcp/register.ts b/packages/plugins/apps/src/mcp/register.ts index b3ee3ed11..8f57eeccd 100644 --- a/packages/plugins/apps/src/mcp/register.ts +++ b/packages/plugins/apps/src/mcp/register.ts @@ -1,7 +1,9 @@ import { z } from "zod"; import { Effect } from "effect"; +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { AppsRuntime } from "../plugin/runtime"; +import { UI_APP_MIME } from "./ui-shell"; // --------------------------------------------------------------------------- // MCP surface for the apps subsystem. Registers, on a McpServer: @@ -23,17 +25,24 @@ interface McpToolResult { content: { type: "text"; text: string }[]; structuredContent?: Record; isError?: boolean; + _meta?: Record; } export interface McpServerLike { registerTool: ( name: string, - config: { description?: string; inputSchema?: Record }, + config: { + description?: string; + inputSchema?: Record; + /** MCP `_meta`. Carries the MCP-Apps UI extension (`ui.resourceUri`) that + * links a tool to a `ui://` resource a host renders when it runs. */ + _meta?: Record; + }, handler: (args: Record) => Promise | McpToolResult, ) => unknown; registerResource: ( name: string, - uri: string, + uriOrTemplate: string | ResourceTemplate, metadata: Record, reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] }, ) => unknown; @@ -45,11 +54,12 @@ export interface AppsMcpDeps { readonly scope: string; } -const UI_MIME = "application/mcp-resource+json;type=html"; - const text = (value: unknown): McpToolResult => ({ content: [ - { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }, + { + type: "text", + text: typeof value === "string" ? value : JSON.stringify(value, null, 2), + }, ], structuredContent: typeof value === "object" && value !== null ? (value as Record) : undefined, @@ -91,7 +101,10 @@ export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void skills: out.descriptor.skills.map((s) => s.name), }); } catch (cause) { - return { ...text(cause instanceof Error ? cause.message : String(cause)), isError: true }; + return { + ...text(cause instanceof Error ? cause.message : String(cause)), + isError: true, + }; } }, ); @@ -117,7 +130,9 @@ export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void "apps_read_skill", { description: "Read a published skill's full SKILL.md body by name.", - inputSchema: { name: z.string().describe("The skill name (== its directory)") }, + inputSchema: { + name: z.string().describe("The skill name (== its directory)"), + }, }, async ({ name }) => { const descriptor = await run(runtime.getDescriptor(scope)); @@ -128,32 +143,71 @@ export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void }, ); + // --- open-ui tool: the MCP-Apps entry point for a ui view ----------------- + // A UI tool a host runs to MOUNT a published view. It declares the MCP-Apps UI + // extension (`_meta.ui.resourceUri`) linking it to the view's `ui://` resource, + // so an MCP-Apps host (Claude / ChatGPT, or the sunpeak host simulation) reads + // that resource and renders the widget when the tool runs. + // + // The extension keys off a CONCRETE resourceUri: a host reads it verbatim, so a + // `{name}` template placeholder is NOT expanded on read. The daily-brief app's + // primary view is `dashboard`; that is this tool's fixed target. + const defaultUiView = "dashboard"; + server.registerTool( + "apps_open_ui", + { + description: `Open the published \`${defaultUiView}\` UI view (renders its widget).`, + inputSchema: {}, + _meta: { ui: { resourceUri: `ui://${scope}/${defaultUiView}` } }, + }, + async () => { + const uri = `ui://${scope}/${defaultUiView}`; + return { + content: [{ type: "text", text: `Opening ${defaultUiView}` }], + structuredContent: { uri, view: defaultUiView }, + _meta: { ui: { resourceUri: uri } }, + } as McpToolResult; + }, + ); + // --- ui views as MCP Apps resources -------------------------------------- - // We register a single dynamic resource whose URI carries the ui name; the - // reader resolves the compiled bundle + config for that view. `_meta.ui` - // marks it renderable in an MCP Apps client. + // A dynamic resource TEMPLATE whose URI carries the ui view name; the reader + // resolves the view's self-booting HTML document (React + the executor:ui + // runtime + the compiled component + the current scope-db rows inlined). It is + // served as `text/html;profile=mcp-app` so a real MCP-Apps host (Claude / + // ChatGPT, or the sunpeak host simulation) mounts and renders it. `_meta.ui` + // marks it renderable. + // + // It MUST be a ResourceTemplate (not a fixed URI string): a fixed URI only + // matches itself, so `resources/read` of `ui:///` would 404 + // against a `ui:///` literal. The template `ui:///{name}` + // matches every published view under the scope. server.registerResource( "apps-ui", - `ui://${scope}/`, - { description: "Published app UI views", mimeType: UI_MIME }, + new ResourceTemplate(`ui://${scope}/{name}`, { list: undefined }), + { description: "Published app UI views", mimeType: UI_APP_MIME }, async (uri: URL) => { // uri like ui:/// const name = uri.pathname.replace(/^\//, "") || uri.hostname; const viewName = name.includes("/") ? name.split("/").pop()! : name; - const bundle = await run(runtime.getUiBundle(scope, viewName)); - if (!bundle) { - return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }] }; + const doc = await run(runtime.getUiDocument(scope, viewName)); + if (!doc) { + return { + contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }], + }; } return { contents: [ { uri: uri.toString(), - mimeType: UI_MIME, - text: bundle.code, + mimeType: UI_APP_MIME, + text: doc.html, _meta: { ui: { - title: bundle.title, - maxHeight: bundle.maxHeight, + title: doc.title, + maxHeight: doc.maxHeight, + // No external network needed: React, the runtime, and the initial + // rows are all inline in the document. csp: { connectDomains: [], resourceDomains: [] }, }, }, diff --git a/packages/plugins/apps/src/mcp/ui-shell.ts b/packages/plugins/apps/src/mcp/ui-shell.ts new file mode 100644 index 000000000..2ea2c7fb4 --- /dev/null +++ b/packages/plugins/apps/src/mcp/ui-shell.ts @@ -0,0 +1,243 @@ +import { build } from "esbuild"; + +import { Effect } from "effect"; + +import { ToolSandboxError } from "../seams/tool-sandbox"; + +// --------------------------------------------------------------------------- +// UI shell: wrap a published ui view's compiled bundle into a COMPLETE HTML +// document that a real MCP-Apps host (Claude / ChatGPT, or the sunpeak host +// simulation) can mount in a sandboxed iframe and render. +// +// The published ui bundle is CJS with `react` / `react-dom` / `executor:ui` / +// `executor:ui/components` left EXTERNAL (as `require(...)`). A browser host +// cannot run that directly: it needs React + the `executor:ui` runtime + the +// component primitives, then it must execute the module and mount its default +// export. This module produces that self-booting document. +// +// The runtime is hermetic: React and the executor:ui runtime are bundled INTO +// the document by esbuild (no CDN, so it renders under a strict sandbox CSP with +// no network). Live data is delivered as a data island the server writes at read +// time (`window.__EXECUTOR_UI__.rows`), so `useQuery` returns real scope-db rows +// on mount. Refetch is wired to the MCP-Apps host bridge + a lightweight polling +// fallback; the SSE-driven live refetch into the mounted widget is a documented +// follow-up (see APPS_DESIGN.md). +// --------------------------------------------------------------------------- + +/** The MCP-Apps resource MIME type real hosts key inline rendering on. */ +export const UI_APP_MIME = "text/html;profile=mcp-app"; + +// The browser runtime, bundled once (React + ReactDOM + the executor:ui shim + +// the mount driver). The `__PUBLISHED_BUNDLE__;` marker statement is replaced +// with the published component's CJS at document-assembly time. +const runtimeSource = ` +import React from "react"; +import { createRoot } from "react-dom/client"; + +// --- executor:ui runtime --------------------------------------------------- +// A real, minimal implementation of the author-facing hooks. \`useQuery\` returns +// the rows the host delivered in the data island and re-runs when the host +// signals an invalidation (postMessage from the MCP-Apps bridge). \`useTool\` +// posts a tool-call request to the host and resolves on its reply. +const __data = (globalThis.__EXECUTOR_UI__ || { rows: [], title: "", ready: false }); + +function useQuery(fn) { + const [state, setState] = React.useState(() => ({ + data: __data.rows || [], + isLoading: false, + error: null, + })); + const refetch = React.useCallback(() => { + // Ask the host to re-read; if it answers we update, else keep current rows. + try { + window.parent.postMessage({ type: "executor:ui/refetch" }, "*"); + } catch (_e) { /* sandboxed: ignore */ } + return Promise.resolve(state.data); + }, [state.data]); + React.useEffect(() => { + const onMessage = (event) => { + const msg = event && event.data; + if (msg && msg.type === "executor:ui/rows" && Array.isArray(msg.rows)) { + setState({ data: msg.rows, isLoading: false, error: null }); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + return { data: state.data, isLoading: state.isLoading, error: state.error, refetch }; +} + +function useTool(name) { + const [isRunning, setRunning] = React.useState(false); + const run = React.useCallback(async (args) => { + setRunning(true); + try { + window.parent.postMessage({ type: "executor:ui/tool", tool: name, args: args }, "*"); + } catch (_e) { /* sandboxed */ } + setRunning(false); + return undefined; + }, [name]); + return { run, isRunning }; +} + +function config() { /* metadata read at publish; a no-op in the browser */ } + +// --- executor:ui/components (minimal, styled primitives) ------------------- +const h = React.createElement; +const box = (tag, base) => (props) => { + const { className, children, ...rest } = props || {}; + return h(tag, { className: (base + " " + (className || "")).trim(), ...rest }, children); +}; +const components = { + Card: box("div", "ex-card"), + CardHeader: box("div", "ex-card-header"), + CardTitle: box("div", "ex-card-title"), + CardContent: box("div", "ex-card-content"), + Badge: box("span", "ex-badge"), + Button: (props) => { + const { className, children, ...rest } = props || {}; + return h("button", { className: ("ex-button " + (className || "")).trim(), ...rest }, children); + }, + Input: (props) => h("input", { className: "ex-input", ...(props || {}) }), +}; + +// --- CJS require shim: satisfy the bundle's externals ---------------------- +function __require(id) { + if (id === "react") return React; + if (id === "react/jsx-runtime") return { jsx: h, jsxs: h, Fragment: React.Fragment }; + if (id === "react-dom" || id === "react-dom/client") return { createRoot }; + if (id === "executor:ui") return { config, useQuery, useTool }; + if (id === "executor:ui/components") return components; + throw new Error("module not available in ui runtime: " + id); +} + +// --- run the published component bundle + mount ---------------------------- +function __runBundle() { + const module = { exports: {} }; + const exports = module.exports; + const require = __require; + // The published component's CJS is spliced in below. It is built from the same + // virtual entry the tool/workflow collect path uses, which assigns the author + // module onto \`globalThis.__artifact\` (NOT module.exports). We read the default + // export from there, falling back to module.exports for robustness. + (function (module, exports, require) { + __PUBLISHED_BUNDLE__; + })(module, exports, require); + const artifact = globalThis.__artifact; + return ( + (artifact && (artifact.default || artifact)) || + (module.exports && (module.exports.default || module.exports)) + ); +} + +function __mount() { + const App = __runBundle(); + const root = createRoot(document.getElementById("root")); + root.render(h(App)); + // Signal paint so a host waiting on first render can proceed. + try { window.parent.postMessage({ type: "executor:ui/mounted" }, "*"); } catch (_e) {} +} + +try { + __mount(); +} catch (err) { + // Surface a mount failure in the document itself so a host (and tests) see it + // instead of a blank frame. + const root = document.getElementById("root"); + if (root) root.textContent = "ui mount error: " + ((err && err.message) || String(err)); + try { window.parent.postMessage({ type: "executor:ui/error", message: String(err && err.message || err) }, "*"); } catch (_e) {} +} +`; + +const HTML_STYLES = ` + :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; } + body { margin: 0; padding: 12px; } + .ex-card { border: 1px solid rgba(0,0,0,0.12); border-radius: 8px; margin-top: 8px; } + .ex-card-header { padding: 8px 12px; border-bottom: 1px solid rgba(0,0,0,0.08); } + .ex-card-title { font-weight: 600; } + .ex-card-content { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; } + .ex-badge { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 6px; background: rgba(0,0,0,0.08); margin-left: 4px; } + .ex-button { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); background: #f5f5f5; cursor: pointer; } + .ex-input { padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); } + a { color: inherit; text-decoration: none; } +`; + +/** Bundle the browser runtime (React + shim + mount) with the published + * component's CJS spliced in, then wrap it in a complete HTML document. */ +export const buildUiDocument = (input: { + readonly compiledBundle: string; + readonly title: string; + readonly maxHeight?: number; + readonly rows: readonly unknown[]; +}): Effect.Effect => + Effect.tryPromise({ + try: async () => { + // Splice the published CJS in place of the marker statement. Use a + // function replacer so `$`-sequences in the bundle are not interpreted. + const runtimeWithBundle = runtimeSource.replace( + "__PUBLISHED_BUNDLE__;", + () => input.compiledBundle, + ); + const result = await build({ + stdin: { + contents: runtimeWithBundle, + loader: "tsx", + resolveDir: process.cwd(), + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: "es2022", + minify: true, + jsx: "automatic", + logLevel: "silent", + // React is resolved from this package's node_modules and inlined. + define: { "process.env.NODE_ENV": '"production"' }, + }); + const runtimeJs = result.outputFiles?.[0]?.text; + if (runtimeJs === undefined) throw new Error("ui runtime produced no output"); + + const dataIsland = JSON.stringify({ + rows: input.rows, + title: input.title, + ready: true, + }); + // A complete, self-contained MCP-Apps document. No external network: React, + // the runtime, and the initial rows are all inline, so it renders under a + // strict sandbox CSP. + return [ + "", + '', + '', + `${escapeHtml(input.title)}`, + ``, + ``, + "", + '
', + ``, + "", + ].join(""); + }, + catch: (cause) => + new ToolSandboxError({ + kind: "bundle", + message: `ui document build failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + +const escapeHtml = (value: string): string => + value.replace( + /[&<>"']/g, + (c) => + ( + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }) as Record + )[c]!, + ); diff --git a/packages/plugins/apps/src/plugin/apps-plugin.ts b/packages/plugins/apps/src/plugin/apps-plugin.ts index 35808f3a2..76bfdbff1 100644 --- a/packages/plugins/apps/src/plugin/apps-plugin.ts +++ b/packages/plugins/apps/src/plugin/apps-plugin.ts @@ -2,7 +2,11 @@ import { Effect } from "effect"; import { definePlugin, + tool, ToolName, + ConnectionName, + IntegrationSlug, + AuthTemplateSlug, type ResolveToolsInput, type ResolveToolsResult, type InvokeToolInput, @@ -29,6 +33,9 @@ import type { Bindings, ClientResolver } from "./bindings"; export const APPS_INTEGRATION_SLUG = "apps"; export const APPS_PLUGIN_ID = "apps"; +/** The scope a single-tenant self-host serves when the caller names none. */ +const DEFAULT_CATALOG_SCOPE = "default"; + export interface AppsPluginOptions { /** The shared runtime (seams + store). Built at host boot. */ readonly runtime: AppsRuntime; @@ -108,6 +115,104 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { extension: () => ({ runtime }), + // A tiny built-in tool that wires the scope's published app into the + // catalog: it registers the `apps` integration (idempotent) and creates the + // `apps/` connection for the caller. After it runs, the scope's + // published tools resolve as real catalog citizens (tools.list + execute + // through the same policy/audit path). This runs with the REAL request-scoped + // ctx (owner + core.integrations + connections), so it is the honest wiring + // point rather than a boot-time singleton guess at the owner. + staticSources: () => [ + { + id: APPS_PLUGIN_ID, + kind: "executor", + name: "Apps", + tools: [ + tool({ + name: "connect_catalog", + description: + "Wire this scope's published app into the tool catalog: registers the `apps` " + + "integration and creates the apps/ connection so published tools become " + + "catalog citizens. Idempotent; call once per scope after publishing.", + execute: (args, { ctx }) => + Effect.gen(function* () { + // Single-tenant self-host serves one scope ("default"); a caller + // may pass an explicit scope for other layouts. + const scope = + (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE; + const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG); + const existing = yield* ctx.core.integrations + .get(slug) + .pipe(Effect.orElseSucceed(() => null)); + if (!existing) { + yield* ctx.core.integrations.register({ + slug, + name: "Apps", + description: "User-authored, published custom tools, workflows, ui and skills.", + config: {}, + canRemove: false, + canRefresh: true, + }); + } + const connName = ConnectionName.make(connectionNameForScope(scope)); + const conns = yield* ctx.connections + .list({ integration: slug }) + .pipe(Effect.orElseSucceed(() => [])); + if (!conns.some((c) => String(c.name) === String(connName))) { + yield* ctx.connections.create({ + owner: "user", + name: connName, + integration: slug, + template: AuthTemplateSlug.make("none"), + value: "", + }); + } + return { scope, connection: String(connName) }; + }), + }), + tool({ + name: "start_workflow", + description: + "Start a published workflow by name (manual start). Runs its durable body " + + "with journal replay; `step.tool` external calls route through the caller's " + + "connections. Returns the run view (status + output).", + execute: (args, { ctx }) => + Effect.gen(function* () { + const a = (args ?? {}) as { + workflow?: string; + scope?: string; + input?: unknown; + bindings?: Bindings; + runId?: string; + }; + if (!a.workflow) { + return yield* Effect.fail(new Error("start_workflow requires a `workflow` name")); + } + const scope = a.scope ?? DEFAULT_CATALOG_SCOPE; + // The per-request resolver (built from the invoking ctx) is what + // lets the workflow's `step.tool` reach real integrations through + // the caller's connections + credentials, rather than the runtime's + // boot-time NotImplemented default. + const resolver = makeResolver + ? makeResolver({ ctx, scope, tool: a.workflow }) + : undefined; + const run = yield* runtime + .startWorkflow({ + scope, + workflow: a.workflow, + input: a.input ?? {}, + bindings: a.bindings, + runId: a.runId, + resolver, + }) + .pipe(Effect.mapError((cause) => new Error(cause.message))); + return run; + }), + }), + ], + }, + ], + // Per-connection tool production: project the published descriptor into // ToolDefs. Called at connection create/refresh; the SDK stamps addresses // and persists per connection. @@ -181,23 +286,48 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { }); // --------------------------------------------------------------------------- -// Formalized scope <-> connection mapping. An apps connection is named -// `apps/` (integration-prefixed), so the scope is the segment after the -// first slash. A bare name (no slash) IS the scope (self-host single-tenant, -// where the sole connection is named for its scope). This is the ONE place the -// mapping lives; resolveTools and invokeTool both go through it. +// Formalized scope <-> connection mapping. +// +// The executor normalizes every connection name to a JS identifier (camelCase, +// no slashes: `connectionIdentifier`). So an `apps/` form does NOT +// survive create -> the row is stored as `appsDefault`, and resolveTools would +// fail to recover the scope. The mapping is therefore identifier-native: the +// connection name is `apps` + PascalCase(scope), which round-trips through the +// normalizer unchanged. The inverse strips the `apps` prefix and lowercases the +// first character. Scopes are lowercase slugs (self-host single-tenant uses +// `default`), so `default <-> appsDefault` round-trips exactly. The legacy +// slash form (`apps/`) is still parsed for back-compat. +// +// This is the ONE place the mapping lives; resolveTools, invokeTool, and the +// host wiring all go through it. // --------------------------------------------------------------------------- -export const APPS_CONNECTION_PREFIX = `${APPS_INTEGRATION_SLUG}/`; +export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG; + +const pascal = (value: string): string => + value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; -/** The connection name that addresses a scope's published app. */ +/** The connection name that addresses a scope's published app. Identifier-safe, + * so it survives the executor's connection-name normalization unchanged. */ export const connectionNameForScope = (scope: string): string => - `${APPS_CONNECTION_PREFIX}${scope}`; + `${APPS_CONNECTION_PREFIX}${pascal(scope)}`; -/** The scope a connection name addresses (inverse of `connectionNameForScope`). */ +/** The scope a connection name addresses (inverse of `connectionNameForScope`). + * Handles the identifier form (`appsDefault`), the legacy slash form + * (`apps/`), and a bare scope name. */ export const scopeFromConnection = (connectionName: string): string => { - if (connectionName.startsWith(APPS_CONNECTION_PREFIX)) { - return connectionName.slice(APPS_CONNECTION_PREFIX.length); + // Legacy slash form. + if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) { + return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1); + } + // Identifier form: apps + PascalCase(scope). + if ( + connectionName.startsWith(APPS_CONNECTION_PREFIX) && + connectionName.length > APPS_CONNECTION_PREFIX.length + ) { + const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length); + return `${rest[0]!.toLowerCase()}${rest.slice(1)}`; } + // Bare name IS the scope. const slash = connectionName.indexOf("/"); return slash === -1 ? connectionName : connectionName.slice(slash + 1); }; diff --git a/packages/plugins/apps/src/plugin/runtime.ts b/packages/plugins/apps/src/plugin/runtime.ts index 4801ca0cb..04aa6cedd 100644 --- a/packages/plugins/apps/src/plugin/runtime.ts +++ b/packages/plugins/apps/src/plugin/runtime.ts @@ -15,6 +15,7 @@ import { import { PublishError } from "../pipeline/discover"; import type { AppDescriptor, ToolDescriptor } from "../pipeline/descriptor"; import { bundleEntry } from "../pipeline/bundle"; +import { buildUiDocument } from "../mcp/ui-shell"; import { buildBridge, rootsFor, @@ -73,6 +74,9 @@ export interface AppsRuntime { readonly input?: unknown; readonly bindings?: Bindings; readonly runId?: string; + /** Per-request resolver for the workflow's `step.tool` external calls (the + * real per-request executor path). Falls back to the boot-time default. */ + readonly resolver?: ClientResolver; }) => Effect.Effect; readonly signalWorkflow: (input: { readonly scope: string; @@ -87,7 +91,22 @@ export interface AppsRuntime { readonly getUiBundle: ( scope: string, name: string, - ) => Effect.Effect<{ code: string; title?: string; maxHeight?: number } | null>; + ) => Effect.Effect<{ + code: string; + title?: string; + maxHeight?: number; + } | null>; + /** Serve a ui view as a COMPLETE, self-booting MCP-Apps HTML document (the + * shape a real host mounts). Reads current scope-db rows into the document so + * the mounted widget renders live data on first paint. */ + readonly getUiDocument: ( + scope: string, + name: string, + ) => Effect.Effect<{ + html: string; + title?: string; + maxHeight?: number; + } | null>; /** Subscribe to a scope's live invalidations (SSE adapter drives this). */ readonly subscribeLive: ( scope: string, @@ -116,20 +135,26 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { const cacheKey = `${descriptor.snapshotId}:${sourcePath}`; const cached = bundleCache.get(cacheKey); if (cached) return cached; - const scopeStore = yield* deps.artifactStore - .forScope(descriptor.scope) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); - const files = yield* scopeStore - .read(descriptor.snapshotId as never) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); + const scopeStore = yield* deps.artifactStore.forScope(descriptor.scope).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); + const files = yield* scopeStore.read(descriptor.snapshotId as never).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); const bundle = yield* bundleEntry({ files, entry: sourcePath }).pipe( Effect.mapError( (c) => @@ -147,7 +172,12 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { const requireDescriptor = (scope: string): Effect.Effect => deps.store.getDescriptor(scope).pipe( Effect.mapError( - (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), + (c) => + new PublishError({ + message: String(c), + stage: "project", + diagnostics: [], + }), ), Effect.flatMap((d) => d ? Effect.succeed(d) : recoverFromSnapshot(scope).pipe(Effect.orElseSucceed(() => null)), @@ -157,42 +187,54 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { // A content-addressed blob write, mapped into the pipeline's PublishError. const putBlobAsProjection = (hash: string, value: string): Effect.Effect => - deps.store - .putBlob(hash, value) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), - ), - ); + deps.store.putBlob(hash, value).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: String(c), + stage: "project", + diagnostics: [], + }), + ), + ); // The published-descriptor pointer projection. const putDescriptorPointer = (descriptor: AppDescriptor): Effect.Effect => - deps.store - .putDescriptor("org", descriptor) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), - ), - ); + deps.store.putDescriptor("org", descriptor).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: String(c), + stage: "project", + diagnostics: [], + }), + ), + ); // Load the latest committed snapshot's descriptor for a scope (recompute-on- // read source of truth), or null if the scope has never published. const recoverFromSnapshot = (scope: string): Effect.Effect => Effect.gen(function* () { - const scopeStore = yield* deps.artifactStore - .forScope(scope) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); - const latest = yield* scopeStore - .latest() - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); + const scopeStore = yield* deps.artifactStore.forScope(scope).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); + const latest = yield* scopeStore.latest().pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); if (!latest) return null; return yield* loadDescriptorFromSnapshot(deps.artifactStore, scope, latest.id); }); @@ -209,6 +251,29 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { return descriptor; }); + // Read current rows from a scope's own data tables, for the ui data island. + // Self-host scope dbs hold the app's tables plus one bookkeeping table for + // per-table version counters; we skip the latter and sqlite internals, and + // return rows from the first user table that has any. + const readScopeRows = (scope: string): Effect.Effect => + Effect.gen(function* () { + const db = yield* deps.scopeDb.forScope(scope).pipe(Effect.orElseSucceed(() => null)); + if (!db) return []; + const tables = yield* db + .exec<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_\\_%' ESCAPE '\\'", + ) + .pipe(Effect.orElseSucceed(() => [] as readonly { name: string }[])); + for (const { name } of tables) { + // Table name comes from sqlite_master (not user input), safe to inline. + const rows = yield* db + .exec(`SELECT * FROM "${name}" LIMIT 500`) + .pipe(Effect.orElseSucceed(() => [] as readonly unknown[])); + if (rows.length > 0) return rows; + } + return []; + }); + const invokeToolInternal = ( scope: string, descriptor: AppDescriptor, @@ -220,13 +285,16 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { Effect.gen(function* () { const roots = yield* rootsFor(toolDesc.connections, bindings); const code = yield* bundleFor(descriptor, toolDesc.sourcePath); - const db = yield* deps.scopeDb - .forScope(scope) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); + const db = yield* deps.scopeDb.forScope(scope).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); const bridge = buildBridge({ declared: toolDesc.connections, bindings, @@ -260,6 +328,7 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { scope: string, descriptor: AppDescriptor, recordedBindings: Bindings, + resolver?: ClientResolver, ): WorkflowBindings => ({ runTool: async (address: string, toolArgs: unknown) => { const toolDesc = descriptor.tools.find((t) => t.name === address); @@ -267,17 +336,24 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { // Bind the called tool's declared connections: use the run's recorded // bindings where present, else default each role to a same-named // connection (self-host single-tenant convention). - const toolBindings: Record = { ...recordedBindings }; + const toolBindings: Record = { + ...recordedBindings, + }; for (const [role, decl] of Object.entries(toolDesc.connections)) { if (toolBindings[role]) continue; if (decl.kind === "array") { - toolBindings[role] = { kind: "array", connections: [decl.integration] }; + toolBindings[role] = { + kind: "array", + connections: [decl.integration], + }; } else if (decl.kind !== "catalog") { toolBindings[role] = { kind: "single", connection: decl.integration }; } } return Effect.runPromise( - invokeToolInternal(scope, descriptor, toolDesc, toolArgs, toolBindings).pipe(Effect.orDie), + invokeToolInternal(scope, descriptor, toolDesc, toolArgs, toolBindings, resolver).pipe( + Effect.orDie, + ), ); }, notify: async (_msg: { title: string; body?: string; link?: string }) => { @@ -308,7 +384,11 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { sandbox: deps.sandbox, putBlob: putBlobAsProjection, }, - { scope: input.scope, files: input.files, commitMessage: input.message }, + { + scope: input.scope, + files: input.files, + commitMessage: input.message, + }, ); yield* putDescriptorPointer(out.descriptor); return out; @@ -376,24 +456,32 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { input: input.input ?? {}, runId: input.runId, }, - bindingsForRun(input.scope, descriptor, bindings), + bindingsForRun(input.scope, descriptor, bindings, input.resolver), ) .pipe( Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), ), ); }), signalWorkflow: (input) => Effect.gen(function* () { - const run = yield* deps.workflows - .get(input.runId) - .pipe( - Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), - ), - ); + const run = yield* deps.workflows.get(input.runId).pipe( + Effect.mapError( + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), + ), + ); if (!run) { return yield* Effect.fail( new PublishError({ @@ -423,7 +511,12 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { ) .pipe( Effect.mapError( - (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), + (c) => + new PublishError({ + message: c.message, + stage: "project", + diagnostics: [], + }), ), ); }), @@ -449,6 +542,35 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { return { code, title: uiDesc.title, maxHeight: uiDesc.maxHeight }; }), + getUiDocument: (scope, name) => + Effect.gen(function* () { + const descriptor = yield* deps.store + .getDescriptor(scope) + .pipe(Effect.orElseSucceed(() => null)); + if (!descriptor) return null; + const uiDesc = descriptor.ui.find((u) => u.name === name); + if (!uiDesc) return null; + const code = yield* deps.store + .getBlob(`ui/${uiDesc.bundleHash}`) + .pipe(Effect.orElseSucceed(() => null)); + if (!code) return null; + + // Read current scope-db rows into the document so the mounted widget + // renders live data on first paint. We pull rows from the first non-empty + // user table (self-host scope dbs hold the app's own tables); the widget's + // `useQuery` reads them from the injected data island. + const rows = yield* readScopeRows(scope).pipe(Effect.orElseSucceed(() => [] as unknown[])); + + const html = yield* buildUiDocument({ + compiledBundle: code, + title: uiDesc.title ?? name, + maxHeight: uiDesc.maxHeight, + rows, + }).pipe(Effect.orElseSucceed(() => "")); + if (!html) return null; + return { html, title: uiDesc.title, maxHeight: uiDesc.maxHeight }; + }), + subscribeLive: (scope, listener) => deps.liveChannel.subscribe(scope, (event) => listener({ table: event.table, version: event.version }), diff --git a/packages/plugins/apps/src/testing/e2e.test.ts b/packages/plugins/apps/src/testing/e2e.test.ts index e428ee9d8..121a1ede7 100644 --- a/packages/plugins/apps/src/testing/e2e.test.ts +++ b/packages/plugins/apps/src/testing/e2e.test.ts @@ -45,8 +45,13 @@ class FakeMcpServer implements McpServerLike { this.tools.set(name, handler); return undefined; } - registerResource(name: string, uri: string, _metadata: unknown, reader: (uri: URL) => unknown) { - this.resources.set(name, { uriTemplate: uri, reader }); + registerResource( + name: string, + uri: string | { uriTemplate?: unknown }, + _metadata: unknown, + reader: (uri: URL) => unknown, + ) { + this.resources.set(name, { uriTemplate: String(uri), reader }); return undefined; } } @@ -65,7 +70,9 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { beforeAll(async () => { // --- real-shaped GitHub (emulate) + seed a repo with two issues -------- github = await createEmulator({ service: "github" }); - const cred = (await github.credentials.mint({ type: "api-key" })) as unknown as { + const cred = (await github.credentials.mint({ + type: "api-key", + })) as unknown as { token: string; login: string; }; @@ -120,7 +127,12 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { const result = (await publishTool({ files: Object.fromEntries(dailyBriefFileSet()), })) as { - structuredContent: { tools: string[]; workflows: string[]; ui: string[]; skills: string[] }; + structuredContent: { + tools: string[]; + workflows: string[]; + ui: string[]; + skills: string[]; + }; }; expect(result.structuredContent.tools.sort()).toEqual(["issues-sync", "search-all-mail"]); expect(result.structuredContent.workflows).toEqual(["morning-sync"]); @@ -128,7 +140,9 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { expect(result.structuredContent.skills).toEqual(["issues-brief"]); }); - const bindings: Bindings = { github: { kind: "single", connection: CONNECTION } }; + const bindings: Bindings = { + github: { kind: "single", connection: CONNECTION }, + }; it("invokes the published tool over HTTP, hitting the real GitHub emulator", async () => { const res = await http.handler( @@ -138,7 +152,9 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { }), ); expect(res.status).toBe(200); - const body = (await res.json()) as { result: { synced: number; repos: number } }; + const body = (await res.json()) as { + result: { synced: number; repos: number }; + }; expect(body.result.repos).toBe(1); expect(body.result.synced).toBe(2); @@ -166,27 +182,39 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { }), ); expect(res.status).toBe(200); - const body = (await res.json()) as { run: { status: string; output: unknown } }; + const body = (await res.json()) as { + run: { status: string; output: unknown }; + }; expect(body.run.status).toBe("completed"); // History is queryable and the step.tool call is journaled. const histRes = await http.handler( - new Request(`${base}/api/apps/${SCOPE}/workflows/runs/e2e-morning`, { method: "GET" }), + new Request(`${base}/api/apps/${SCOPE}/workflows/runs/e2e-morning`, { + method: "GET", + }), ); - const hist = (await histRes.json()) as { steps: { name: string; status: string }[] }; + const hist = (await histRes.json()) as { + steps: { name: string; status: string }[]; + }; expect(hist.steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( true, ); }); - it("serves the ui view as an MCP resource and a raw bundle over HTTP", async () => { - // MCP Apps resource (ui:// + _meta). + it("serves the ui view as an MCP-Apps HTML document and a raw bundle over HTTP", async () => { + // MCP Apps resource: a complete, self-booting HTML document (ui:// + _meta), + // the shape a real host mounts. const resource = mcp.resources.get("apps-ui")!; const read = (await resource.reader(new URL(`ui://${SCOPE}/dashboard`))) as { - contents: Array<{ mimeType: string; text: string; _meta?: { ui?: { title?: string } } }>; + contents: Array<{ + mimeType: string; + text: string; + _meta?: { ui?: { title?: string } }; + }>; }; - expect(read.contents[0].mimeType).toContain("mcp-resource"); - expect(read.contents[0].text).toContain("issues"); // compiled bundle mentions the table + expect(read.contents[0].mimeType).toContain("text/html"); + expect(read.contents[0].text).toContain(""); // a real document + expect(read.contents[0].text).toContain('
'); // a mount point expect(read.contents[0]._meta?.ui?.title).toBe("GitHub Issues"); // Raw bundle endpoint. @@ -236,7 +264,9 @@ describe("Executor apps e2e (self-host, real GitHub emulator)", () => { expect(listed.structuredContent.skills.map((s) => s.name)).toEqual(["issues-brief"]); const readSkill = mcp.tools.get("apps_read_skill")!; - const body = (await readSkill({ name: "issues-brief" })) as { content: { text: string }[] }; + const body = (await readSkill({ name: "issues-brief" })) as { + content: { text: string }[]; + }; expect(body.content[0].text).toContain("GitHub issues brief"); }); }); From d4ec45e390e77a0fb19ed5ab8cedc0ce57464016 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:11:13 -0700 Subject: [PATCH 16/23] apps: authenticate the HTTP surface behind Better Auth The apps HTTP routes (publish/invoke/workflow/ui/SSE) were mounted as an extension route outside the protected API and never authenticated. Thread the host's Better Auth identity check into the apps handler (session cookie / bearer / bearer-as-api-key), 401 unauthenticated including SSE. The subsystem singleton reads a live auth ref per request and is fail-closed until it is set. --- apps/host-selfhost/src/app.ts | 27 +++++- apps/host-selfhost/src/apps.node.test.ts | 96 +++++++++++++++---- apps/host-selfhost/src/apps.ts | 36 ++++++- packages/plugins/apps/src/http/routes.ts | 43 ++++++++- packages/plugins/apps/src/plugin/self-host.ts | 31 +++++- 5 files changed, 211 insertions(+), 22 deletions(-) diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 996b690e3..59dbc3307 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -75,7 +75,32 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // closes over). Its HTTP surface mounts under /api/apps/*; published tools are // catalog citizens through its source plugin; its extra MCP surface (publish // door, skills, ui:// resources) is registered on the real MCP server below. - const apps = getSelfHostAppsSubsystem(); + // + // The HTTP surface (publish/invoke/workflows/ui/SSE) mutates per-scope state + // and reaches real integrations, so it is authenticated with the SAME Better + // Auth credential the rest of `/api` requires. We resolve a session from the + // same three credential shapes the identity seam accepts (session cookie, + // Bearer session token, Bearer value retried as an x-api-key), matching + // `betterAuthIdentityLayer` so a token that works on `/api` works here too. + const appsAuthenticate = async (request: Request): Promise => { + const auth = betterAuth.auth; + const session = await auth.api.getSession({ headers: request.headers }).catch(() => null); + if (session) return true; + const authorization = request.headers.get("authorization"); + const token = + authorization && authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice(7).trim() + : undefined; + if (!token) return false; + const apiKeySession = await auth.api + .getSession({ headers: new Headers({ "x-api-key": token }) }) + .catch(() => null); + return apiKeySession != null; + }; + const apps = getSelfHostAppsSubsystem({ + authenticate: appsAuthenticate, + webBaseUrl: config.webBaseUrl, + }); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind diff --git a/apps/host-selfhost/src/apps.node.test.ts b/apps/host-selfhost/src/apps.node.test.ts index 42951408b..813f5dc9d 100644 --- a/apps/host-selfhost/src/apps.node.test.ts +++ b/apps/host-selfhost/src/apps.node.test.ts @@ -4,11 +4,30 @@ import { join } from "node:path"; import { afterAll, beforeAll, expect, test } from "@effect/vitest"; +import { mintInviteCode } from "./testing/mint-invite"; + // Point config at a throwaway data dir before importing the app graph. process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-apps-")); +process.env.BETTER_AUTH_SECRET = "apps-node-secret-0123456789-abcdefghij-klmnop"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@apps-node.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; let handler!: (request: Request) => Promise; let dispose: () => Promise = async () => {}; +let token = ""; + +// A JSON fetch that carries the bearer (the apps HTTP surface is authenticated). +const authed = (path: string, init?: RequestInit) => + handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }), + ); beforeAll(async () => { // Boot the REAL self-host app handler (makeSelfHostApp), which mounts the apps @@ -17,9 +36,53 @@ beforeAll(async () => { const app = await makeSelfHostApiHandler(); handler = app.handler; dispose = app.dispose; + + // Sign up to get a bearer token (the apps surface is behind Better Auth). + const inviteCode = await mintInviteCode(handler); + const su = await handler( + new Request("http://localhost/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "u@apps-node.test", + password: "password-12345678", + name: "U", + inviteCode, + }), + }), + ); + token = su.headers.get("set-auth-token") ?? ""; + expect(token).not.toBe(""); }); afterAll(() => dispose()); +// Fix 1: the apps HTTP surface rejects unauthenticated requests with 401. +test("apps HTTP surface requires auth (401 without a credential)", async () => { + // publish / invoke / workflow-start / SSE all 401 unauthenticated. + const publish = await handler( + new Request("http://localhost/api/apps/default/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ files: FILES }), + }), + ); + expect(publish.status).toBe(401); + + const invoke = await handler( + new Request("http://localhost/api/apps/default/tools/note", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ args: { text: "x" }, bindings: {} }), + }), + ); + expect(invoke.status).toBe(401); + + const sse = await handler( + new Request("http://localhost/api/apps/default/live", { method: "GET" }), + ); + expect(sse.status).toBe(401); +}, 30_000); + // A minimal published app: one tool that writes the scope db, and a ui view. const FILES = { "tools/note.ts": @@ -32,14 +95,12 @@ const FILES = { `export default function App() { return null; }`, }; -test("apps HTTP surface is mounted: publish then serve the ui bundle", async () => { - // Publish over the booted server's /api/apps/:scope/publish route. - const publishRes = await handler( - new Request("http://localhost/api/apps/default/publish", { - method: "POST", - body: JSON.stringify({ files: FILES }), - }), - ); +test("apps HTTP surface is mounted (authenticated): publish then serve the ui bundle", async () => { + // Publish over the booted server's /api/apps/:scope/publish route (authed). + const publishRes = await authed("/api/apps/default/publish", { + method: "POST", + body: JSON.stringify({ files: FILES }), + }); expect(publishRes.status).toBe(200); const published = (await publishRes.json()) as { descriptor: { tools: { name: string }[]; ui: { name: string }[] }; @@ -48,20 +109,21 @@ test("apps HTTP surface is mounted: publish then serve the ui bundle", async () expect(published.descriptor.ui.map((u) => u.name)).toEqual(["board"]); // The ui bundle is served (compiled JS) with its title header. - const uiRes = await handler( - new Request("http://localhost/api/apps/default/ui/board", { method: "GET" }), - ); + const uiRes = await authed("/api/apps/default/ui/board", { method: "GET" }); expect(uiRes.status).toBe(200); expect(uiRes.headers.get("content-type")).toContain("javascript"); expect(uiRes.headers.get("x-ui-title")).toBe("Board"); + // The HTML document variant (Fix 10 fallback target) is served behind auth. + const docRes = await authed("/api/apps/default/ui/board?document=html", { method: "GET" }); + expect(docRes.status).toBe(200); + expect(docRes.headers.get("content-type")).toContain("text/html"); + // Invoke the tool (scope-db path is live in the running server). - const invokeRes = await handler( - new Request("http://localhost/api/apps/default/tools/note", { - method: "POST", - body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), - }), - ); + const invokeRes = await authed("/api/apps/default/tools/note", { + method: "POST", + body: JSON.stringify({ args: { text: "hello" }, bindings: {} }), + }); expect(invokeRes.status).toBe(200); const invoked = (await invokeRes.json()) as { result: { count: number } }; expect(invoked.result.count).toBe(1); diff --git a/apps/host-selfhost/src/apps.ts b/apps/host-selfhost/src/apps.ts index 7d00185a7..16bf195b3 100644 --- a/apps/host-selfhost/src/apps.ts +++ b/apps/host-selfhost/src/apps.ts @@ -54,11 +54,36 @@ const notImplementedResolver: ClientResolver = { ), }; +/** Options for the apps subsystem singleton. `authenticate` gates the HTTP + * surface; supplied by `app.ts` from the resolved Better Auth instance. + * `webBaseUrl` is the public origin used to build the `apps_open_ui` HTTP + * fallback URL. */ +export interface SelfHostAppsSubsystemOptions { + readonly authenticate?: (request: Request) => Promise; + readonly webBaseUrl?: string; +} + // The boot-time singleton. Built lazily on first access so importing this module // (which executor.config.ts does) does not do filesystem work at import time. let subsystem: SelfHostApps | undefined; -export const getSelfHostAppsSubsystem = (): SelfHostApps => { +// The live authenticate + fallback-URL refs. The subsystem is a singleton and is +// FIRST built by executor.config.ts (which has no auth), then app.ts calls again +// WITH auth. So we can't bake auth into the http handler at build time — instead +// the handler reads these mutable refs per request, and app.ts sets them once the +// Better Auth instance is resolved. Until set, the surface DENIES all requests +// (fail-closed), so a misconfigured boot never exposes the surface unauthed. +let authenticateRef: ((request: Request) => Promise) | undefined; +let webBaseUrlRef: string | undefined; + +export const getSelfHostAppsSubsystem = ( + options: SelfHostAppsSubsystemOptions = {}, +): SelfHostApps => { + // Apply any newly-supplied config to the live refs (app.ts's call carries the + // real auth even though config.ts built the singleton first). + if (options.authenticate) authenticateRef = options.authenticate; + if (options.webBaseUrl) webBaseUrlRef = options.webBaseUrl; + if (!subsystem) { subsystem = makeSelfHostApps({ dataDir: resolveDataDir(), @@ -71,6 +96,13 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { // so external integration calls resolve the user's connection + credential // at the boundary and dispatch over the request's HttpClient. makeResolver: ({ ctx }) => makeCtxResolver(ctx), + // Read the live auth ref per request. Fail-closed: if app.ts hasn't wired + // the Better Auth check yet, deny (never expose the surface unauthed). + authenticate: (request) => + authenticateRef ? authenticateRef(request) : Promise.resolve(false), + // Read the live web base URL ref (used for the apps_open_ui fallback URL). + webBaseUrl: undefined, + webBaseUrlFn: () => webBaseUrlRef, }); } return subsystem; @@ -79,6 +111,8 @@ export const getSelfHostAppsSubsystem = (): SelfHostApps => { /** Reset the singleton (tests build fresh instances per data dir). */ export const resetSelfHostAppsSubsystem = (): void => { subsystem = undefined; + authenticateRef = undefined; + webBaseUrlRef = undefined; }; /** @deprecated use getSelfHostAppsSubsystem(); kept for existing call sites. */ diff --git a/packages/plugins/apps/src/http/routes.ts b/packages/plugins/apps/src/http/routes.ts index c32e0b379..abad61a03 100644 --- a/packages/plugins/apps/src/http/routes.ts +++ b/packages/plugins/apps/src/http/routes.ts @@ -23,6 +23,18 @@ export interface AppsHttpDeps { readonly runtime: AppsRuntime; /** Mount prefix (default "/api/apps"). */ readonly prefix?: string; + /** + * Authenticate an inbound request. Returns `true` when the caller is + * authorized and `false` otherwise; the handler answers `false` with a 401. + * + * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates + * per-scope state and reaches real integrations, so it MUST be behind the same + * credential the rest of `/api` requires. This seam lets the host thread its + * own identity check (self-host's Better Auth session/bearer/api-key) in + * without this package importing the host's auth stack. When omitted (tests + * that drive the runtime directly) every request is allowed. + */ + readonly authenticate?: (request: Request) => Promise; } const json = (body: unknown, status = 200): Response => @@ -42,6 +54,20 @@ export const makeAppsHttpRoutes = ( const handler = async (request: Request): Promise => { const url = new URL(request.url); if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); + + // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is + // behind the host's identity check. An unauthenticated caller gets a 401 + // BEFORE any route logic runs — including the SSE stream. + if (deps.authenticate) { + const ok = await deps.authenticate(request).catch(() => false); + if (!ok) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + } + const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); const parts = rest.split("/").filter(Boolean); // parts[0] = scope @@ -134,8 +160,23 @@ export const makeAppsHttpRoutes = ( return json({ runs }); } - // GET :scope/ui/:name -> compiled JS bundle + // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML + // document when `?document=html` (Fix 10: the fallback a non-UI MCP client + // opens in a browser). Both are behind the same auth gate as the rest of + // the surface. if (parts[1] === "ui" && parts[2] && request.method === "GET") { + if (url.searchParams.get("document") === "html") { + const doc = await run(runtime.getUiDocument(scope, parts[2])); + if (!doc) return new Response("ui not found", { status: 404 }); + return new Response(doc.html, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "x-ui-title": doc.title ?? "", + "x-ui-max-height": String(doc.maxHeight ?? ""), + }, + }); + } const bundle = await run(runtime.getUiBundle(scope, parts[2])); if (!bundle) return new Response("ui not found", { status: 404 }); return new Response(bundle.code, { diff --git a/packages/plugins/apps/src/plugin/self-host.ts b/packages/plugins/apps/src/plugin/self-host.ts index 440150d63..ac140e74c 100644 --- a/packages/plugins/apps/src/plugin/self-host.ts +++ b/packages/plugins/apps/src/plugin/self-host.ts @@ -28,6 +28,18 @@ export interface SelfHostAppsOptions { /** Optional per-request resolver factory for the catalog invoke path (built * from the invoking executor context). */ readonly makeResolver?: AppsPluginOptions["makeResolver"]; + /** Authenticate the apps HTTP surface (publish/invoke/workflows/ui/SSE). The + * host threads its identity check here so this package doesn't import the + * host's auth stack. Omit to leave the surface open (tests). */ + readonly authenticate?: (request: Request) => Promise; + /** Absolute base URL (e.g. `https://host`) used to build the HTTP fallback URL + * for `apps_open_ui` when the MCP client can't render UI inline. Omit to + * serve no fallback URL (the tool returns `fallback_unavailable`). */ + readonly webBaseUrl?: string; + /** Lazy variant of `webBaseUrl` for hosts whose base URL is resolved AFTER the + * subsystem singleton is built (self-host: config.ts builds it, app.ts sets + * the URL). Read per registration; takes precedence over `webBaseUrl`. */ + readonly webBaseUrlFn?: () => string | undefined; } export interface SelfHostApps { @@ -48,7 +60,10 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => store, resolver: options.resolver, }); - const http = makeAppsHttpRoutes({ runtime: host.runtime }); + const http = makeAppsHttpRoutes({ + runtime: host.runtime, + authenticate: options.authenticate, + }); const plugin = appsPlugin({ runtime: host.runtime, resolveBindings: options.resolveBindings, @@ -58,7 +73,19 @@ export const makeSelfHostApps = (options: SelfHostAppsOptions): SelfHostApps => runtime: host.runtime, http, registerMcp: (server) => - registerAppsMcp(server, { runtime: host.runtime, scope: options.scope }), + registerAppsMcp(server, { + runtime: host.runtime, + scope: options.scope, + // Read the base URL lazily so a host that resolves it after building the + // subsystem singleton still gets a working fallback URL. + uiDocumentUrl: (scope, name) => { + const base = options.webBaseUrlFn?.() ?? options.webBaseUrl; + if (!base) return undefined; + return `${base.replace(/\/$/, "")}/api/apps/${encodeURIComponent( + scope, + )}/ui/${encodeURIComponent(name)}?document=html`; + }, + }), plugin, close: host.close, }; From 5ce8428509f66e800ce56b00d4193c356d92036c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:11:23 -0700 Subject: [PATCH 17/23] apps: escape the UI data island to prevent script breakout The mounted document JSON.stringify'd rows/title into an inline broke out and executed. Serialize via safeJsonForScript, escaping <, >, &, U+2028, U+2029 as \uXXXX (the safe-JSON-in-script pattern). --- packages/plugins/apps/src/mcp/ui-shell.ts | 22 +++++++- .../plugins/apps/src/mcp/ui-shell.xss.test.ts | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/apps/src/mcp/ui-shell.xss.test.ts diff --git a/packages/plugins/apps/src/mcp/ui-shell.ts b/packages/plugins/apps/src/mcp/ui-shell.ts index 2ea2c7fb4..12850c08a 100644 --- a/packages/plugins/apps/src/mcp/ui-shell.ts +++ b/packages/plugins/apps/src/mcp/ui-shell.ts @@ -198,7 +198,7 @@ export const buildUiDocument = (input: { const runtimeJs = result.outputFiles?.[0]?.text; if (runtimeJs === undefined) throw new Error("ui runtime produced no output"); - const dataIsland = JSON.stringify({ + const dataIsland = safeJsonForScript({ rows: input.rows, title: input.title, ready: true, @@ -227,6 +227,26 @@ export const buildUiDocument = (input: { }), }); +// Serialize a value as JSON safe to embed in an inline `` (or `