Unified · Split
| @@ -152,7 +152,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 151 unmodified lines | ||
| 152 | 152 | GIT_COMMITTER_EMAIL: "apps@executor.local", |
| 153 | 153 | }; |
| 154 | 154 | const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => { |
| 155 | − const child = execFile( | |
| 155 | + execFile( | |
| 156 | 156 | "git", |
| 157 | 157 | commitArgs, |
| 158 | 158 | { cwd: repoDir, encoding: "buffer", env: commitEnvVars }, |
| @@ -0,0 +1,208 @@ | ||
| 1 | +import { Effect } from "effect"; | |
| 2 | + | |
| 3 | +import type { AppsRuntime } from "../plugin/runtime"; | |
| 4 | +import type { Bindings } from "../plugin/bindings"; | |
| 5 | + | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | +// Apps HTTP surface — a plain web handler (Request -> Response) the self-host | |
| 8 | +// app mounts as an extension route under `/api/apps/*`. Covers: | |
| 9 | +// POST /api/apps/:scope/publish { files } -> descriptor | |
| 10 | +// GET /api/apps/:scope/descriptor -> descriptor | |
| 11 | +// POST /api/apps/:scope/tools/:tool { args, bindings } -> result | |
| 12 | +// POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run | |
| 13 | +// POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run | |
| 14 | +// GET /api/apps/:scope/workflows/runs/:runId -> run + steps | |
| 15 | +// GET /api/apps/:scope/workflows/runs -> runs | |
| 16 | +// GET /api/apps/:scope/ui/:name -> compiled bundle (JS) | |
| 17 | +// GET /api/apps/:scope/live (SSE) -> invalidations | |
| 18 | +// | |
| 19 | +// It's deliberately transport-thin: all logic is in AppsRuntime. | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +export interface AppsHttpDeps { | |
| 23 | + readonly runtime: AppsRuntime; | |
| 24 | + /** Mount prefix (default "/api/apps"). */ | |
| 25 | + readonly prefix?: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +const json = (body: unknown, status = 200): Response => | |
| 29 | + new Response(JSON.stringify(body), { | |
| 30 | + status, | |
| 31 | + headers: { "content-type": "application/json" }, | |
| 32 | + }); | |
| 33 | + | |
| 34 | +const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never); | |
| 35 | + | |
| 36 | +export const makeAppsHttpRoutes = ( | |
| 37 | + deps: AppsHttpDeps, | |
| 38 | +): { readonly path: string; readonly handler: (request: Request) => Promise<Response> } => { | |
| 39 | + const prefix = deps.prefix ?? "/api/apps"; | |
| 40 | + const runtime = deps.runtime; | |
| 41 | + | |
| 42 | + const handler = async (request: Request): Promise<Response> => { | |
| 43 | + const url = new URL(request.url); | |
| 44 | + if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); | |
| 45 | + const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); | |
| 46 | + const parts = rest.split("/").filter(Boolean); | |
| 47 | + // parts[0] = scope | |
| 48 | + const scope = parts[0]; | |
| 49 | + if (!scope) return json({ error: "scope required" }, 400); | |
| 50 | + | |
| 51 | + try { | |
| 52 | + // POST :scope/publish | |
| 53 | + if (parts[1] === "publish" && request.method === "POST") { | |
| 54 | + const body = (await request.json()) as { files: Record<string, string>; message?: string }; | |
| 55 | + const files = new Map(Object.entries(body.files ?? {})); | |
| 56 | + const out = await run(runtime.publish({ scope, files, message: body.message })); | |
| 57 | + return json({ snapshotId: out.snapshotId, descriptor: out.descriptor }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + // GET :scope/descriptor | |
| 61 | + if (parts[1] === "descriptor" && request.method === "GET") { | |
| 62 | + const descriptor = await run(runtime.getDescriptor(scope)); | |
| 63 | + return json({ descriptor }); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // POST :scope/tools/:tool | |
| 67 | + if (parts[1] === "tools" && parts[2] && request.method === "POST") { | |
| 68 | + const body = (await request.json()) as { args?: unknown; bindings?: Bindings }; | |
| 69 | + const result = await run( | |
| 70 | + runtime.invokeTool({ | |
| 71 | + scope, | |
| 72 | + tool: parts[2], | |
| 73 | + args: body.args ?? {}, | |
| 74 | + bindings: body.bindings ?? {}, | |
| 75 | + }), | |
| 76 | + ); | |
| 77 | + return json({ result }); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // POST :scope/workflows/:wf/start | |
| 81 | + if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") { | |
| 82 | + const body = (await request.json()) as { | |
| 83 | + input?: unknown; | |
| 84 | + bindings?: Bindings; | |
| 85 | + runId?: string; | |
| 86 | + }; | |
| 87 | + const runView = await run( | |
| 88 | + runtime.startWorkflow({ | |
| 89 | + scope, | |
| 90 | + workflow: parts[2], | |
| 91 | + input: body.input, | |
| 92 | + bindings: body.bindings, | |
| 93 | + runId: body.runId, | |
| 94 | + }), | |
| 95 | + ); | |
| 96 | + return json({ run: runView }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + // POST :scope/workflows/runs/:runId/signal | |
| 100 | + if ( | |
| 101 | + parts[1] === "workflows" && | |
| 102 | + parts[2] === "runs" && | |
| 103 | + parts[3] && | |
| 104 | + parts[4] === "signal" && | |
| 105 | + request.method === "POST" | |
| 106 | + ) { | |
| 107 | + const body = (await request.json()) as { event: string; payload?: unknown }; | |
| 108 | + const runView = await run( | |
| 109 | + runtime.signalWorkflow({ | |
| 110 | + scope, | |
| 111 | + runId: parts[3], | |
| 112 | + event: body.event, | |
| 113 | + payload: body.payload, | |
| 114 | + }), | |
| 115 | + ); | |
| 116 | + return json({ run: runView }); | |
| 117 | + } | |
| 118 | + | |
| 119 | + // GET :scope/workflows/runs/:runId | |
| 120 | + if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") { | |
| 121 | + const runView = await run(runtime.getRun(parts[3])); | |
| 122 | + const steps = await run(runtime.listSteps(parts[3])); | |
| 123 | + return json({ run: runView, steps }); | |
| 124 | + } | |
| 125 | + | |
| 126 | + // GET :scope/workflows/runs | |
| 127 | + if ( | |
| 128 | + parts[1] === "workflows" && | |
| 129 | + parts[2] === "runs" && | |
| 130 | + !parts[3] && | |
| 131 | + request.method === "GET" | |
| 132 | + ) { | |
| 133 | + const runs = await run(runtime.listRuns(scope)); | |
| 134 | + return json({ runs }); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // GET :scope/ui/:name -> compiled JS bundle | |
| 138 | + if (parts[1] === "ui" && parts[2] && request.method === "GET") { | |
| 139 | + const bundle = await run(runtime.getUiBundle(scope, parts[2])); | |
| 140 | + if (!bundle) return new Response("ui not found", { status: 404 }); | |
| 141 | + return new Response(bundle.code, { | |
| 142 | + status: 200, | |
| 143 | + headers: { | |
| 144 | + "content-type": "application/javascript", | |
| 145 | + "x-ui-title": bundle.title ?? "", | |
| 146 | + "x-ui-max-height": String(bundle.maxHeight ?? ""), | |
| 147 | + }, | |
| 148 | + }); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // GET :scope/live -> SSE invalidations | |
| 152 | + if (parts[1] === "live" && request.method === "GET") { | |
| 153 | + return sseResponse(scope, runtime); | |
| 154 | + } | |
| 155 | + | |
| 156 | + return new Response("not found", { status: 404 }); | |
| 157 | + } catch (cause) { | |
| 158 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 159 | + return json({ error: message }, 400); | |
| 160 | + } | |
| 161 | + }; | |
| 162 | + | |
| 163 | + return { path: `${prefix}/*`, handler }; | |
| 164 | +}; | |
| 165 | + | |
| 166 | +// An SSE stream of `{table, version}` invalidations for a scope. Each scope-db | |
| 167 | +// write bumps a counter and publishes here; a `: keepalive` comment holds the | |
| 168 | +// connection open. | |
| 169 | +const sseResponse = (scope: string, runtime: AppsRuntime): Response => { | |
| 170 | + let unsubscribe: (() => void) | undefined; | |
| 171 | + let keepalive: ReturnType<typeof setInterval> | undefined; | |
| 172 | + const stream = new ReadableStream<Uint8Array>({ | |
| 173 | + start(controller) { | |
| 174 | + const enc = new TextEncoder(); | |
| 175 | + controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`)); | |
| 176 | + unsubscribe = runtime.subscribeLive(scope, (event) => { | |
| 177 | + try { | |
| 178 | + controller.enqueue( | |
| 179 | + enc.encode( | |
| 180 | + `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`, | |
| 181 | + ), | |
| 182 | + ); | |
| 183 | + } catch { | |
| 184 | + /* controller closed */ | |
| 185 | + } | |
| 186 | + }); | |
| 187 | + keepalive = setInterval(() => { | |
| 188 | + try { | |
| 189 | + controller.enqueue(enc.encode(`: keepalive\n\n`)); | |
| 190 | + } catch { | |
| 191 | + /* closed */ | |
| 192 | + } | |
| 193 | + }, 15_000); | |
| 194 | + }, | |
| 195 | + cancel() { | |
| 196 | + unsubscribe?.(); | |
| 197 | + if (keepalive) clearInterval(keepalive); | |
| 198 | + }, | |
| 199 | + }); | |
| 200 | + return new Response(stream, { | |
| 201 | + status: 200, | |
| 202 | + headers: { | |
| 203 | + "content-type": "text/event-stream", | |
| 204 | + "cache-control": "no-cache", | |
| 205 | + connection: "keep-alive", | |
| 206 | + }, | |
| 207 | + }); | |
| 208 | +}; | |
| @@ -0,0 +1,165 @@ | ||
| 1 | +import { z } from "zod"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | + | |
| 4 | +import type { AppsRuntime } from "../plugin/runtime"; | |
| 5 | + | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | +// MCP surface for the apps subsystem. Registers, on a McpServer: | |
| 8 | +// - `apps_publish` tool: the chat-authoring door (agent publishes a file set) | |
| 9 | +// - `apps_list_skills` / `apps_read_skill` tools: published skills over MCP | |
| 10 | +// - one `ui://<scope>/<name>` resource per published ui view (MCP Apps), | |
| 11 | +// carrying `_meta.ui` so a client renders it, + the raw bundle in the body | |
| 12 | +// | |
| 13 | +// Published TOOLS are already catalog citizens through the source plugin, so | |
| 14 | +// they surface over the host's normal `execute`/tools surface with no extra | |
| 15 | +// wiring here. This module adds the publish door, skills, and ui resources. | |
| 16 | +// | |
| 17 | +// `server` is a minimal structural view of the MCP SDK's McpServer (registerTool | |
| 18 | +// / registerResource), so this module has no hard dependency on a specific SDK | |
| 19 | +// version's class. | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +interface McpToolResult { | |
| 23 | + content: { type: "text"; text: string }[]; | |
| 24 | + structuredContent?: Record<string, unknown>; | |
| 25 | + isError?: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface McpServerLike { | |
| 29 | + registerTool: ( | |
| 30 | + name: string, | |
| 31 | + config: { description?: string; inputSchema?: Record<string, unknown> }, | |
| 32 | + handler: (args: Record<string, unknown>) => Promise<McpToolResult> | McpToolResult, | |
| 33 | + ) => unknown; | |
| 34 | + registerResource: ( | |
| 35 | + name: string, | |
| 36 | + uri: string, | |
| 37 | + metadata: Record<string, unknown>, | |
| 38 | + reader: (uri: URL) => Promise<{ contents: unknown[] }> | { contents: unknown[] }, | |
| 39 | + ) => unknown; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface AppsMcpDeps { | |
| 43 | + readonly runtime: AppsRuntime; | |
| 44 | + /** The scope this MCP session serves (self-host single-tenant). */ | |
| 45 | + readonly scope: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +const UI_MIME = "application/mcp-resource+json;type=html"; | |
| 49 | + | |
| 50 | +const text = (value: unknown): McpToolResult => ({ | |
| 51 | + content: [ | |
| 52 | + { type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }, | |
| 53 | + ], | |
| 54 | + structuredContent: | |
| 55 | + typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined, | |
| 56 | +}); | |
| 57 | + | |
| 58 | +const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect as never); | |
| 59 | + | |
| 60 | +export const registerAppsMcp = (server: McpServerLike, deps: AppsMcpDeps): void => { | |
| 61 | + const { runtime, scope } = deps; | |
| 62 | + | |
| 63 | + // --- the publish door ----------------------------------------------------- | |
| 64 | + server.registerTool( | |
| 65 | + "apps_publish", | |
| 66 | + { | |
| 67 | + description: | |
| 68 | + "Publish a set of app files (tools/, workflows/, ui/, skills/) into this scope. " + | |
| 69 | + "Returns the compiled descriptor: which tools, workflows, ui views and skills were published.", | |
| 70 | + inputSchema: { | |
| 71 | + files: z | |
| 72 | + .record(z.string(), z.string()) | |
| 73 | + .describe("Map of POSIX path -> file contents, e.g. { 'tools/x.ts': '...' }"), | |
| 74 | + message: z.string().optional().describe("Publish message"), | |
| 75 | + }, | |
| 76 | + }, | |
| 77 | + async ({ files, message }) => { | |
| 78 | + try { | |
| 79 | + const out = await run( | |
| 80 | + runtime.publish({ | |
| 81 | + scope, | |
| 82 | + files: new Map(Object.entries((files as Record<string, string>) ?? {})), | |
| 83 | + message: message as string | undefined, | |
| 84 | + }), | |
| 85 | + ); | |
| 86 | + return text({ | |
| 87 | + snapshotId: out.snapshotId, | |
| 88 | + tools: out.descriptor.tools.map((t) => t.name), | |
| 89 | + workflows: out.descriptor.workflows.map((w) => w.name), | |
| 90 | + ui: out.descriptor.ui.map((u) => u.name), | |
| 91 | + skills: out.descriptor.skills.map((s) => s.name), | |
| 92 | + }); | |
| 93 | + } catch (cause) { | |
| 94 | + return { ...text(cause instanceof Error ? cause.message : String(cause)), isError: true }; | |
| 95 | + } | |
| 96 | + }, | |
| 97 | + ); | |
| 98 | + | |
| 99 | + // --- skills over MCP ------------------------------------------------------ | |
| 100 | + server.registerTool( | |
| 101 | + "apps_list_skills", | |
| 102 | + { | |
| 103 | + description: "List the skills published in this scope (name + description).", | |
| 104 | + inputSchema: {}, | |
| 105 | + }, | |
| 106 | + async () => { | |
| 107 | + const descriptor = await run(runtime.getDescriptor(scope)); | |
| 108 | + const skills = (descriptor?.skills ?? []).map((s) => ({ | |
| 109 | + name: s.name, | |
| 110 | + description: s.description, | |
| 111 | + })); | |
| 112 | + return text({ skills }); | |
| 113 | + }, | |
| 114 | + ); | |
| 115 | + | |
| 116 | + server.registerTool( | |
| 117 | + "apps_read_skill", | |
| 118 | + { | |
| 119 | + description: "Read a published skill's full SKILL.md body by name.", | |
| 120 | + inputSchema: { name: z.string().describe("The skill name (== its directory)") }, | |
| 121 | + }, | |
| 122 | + async ({ name }) => { | |
| 123 | + const descriptor = await run(runtime.getDescriptor(scope)); | |
| 124 | + const skill = descriptor?.skills.find((s) => s.name === name); | |
| 125 | + if (!skill) return { ...text(`no skill named "${name}"`), isError: true }; | |
| 126 | + const body = await run(runtime.deps.store.getBlob(`skill/${skill.bodyHash}`)); | |
| 127 | + return text(body ?? ""); | |
| 128 | + }, | |
| 129 | + ); | |
| 130 | + | |
| 131 | + // --- ui views as MCP Apps resources -------------------------------------- | |
| 132 | + // We register a single dynamic resource whose URI carries the ui name; the | |
| 133 | + // reader resolves the compiled bundle + config for that view. `_meta.ui` | |
| 134 | + // marks it renderable in an MCP Apps client. | |
| 135 | + server.registerResource( | |
| 136 | + "apps-ui", | |
| 137 | + `ui://${scope}/`, | |
| 138 | + { description: "Published app UI views", mimeType: UI_MIME }, | |
| 139 | + async (uri: URL) => { | |
| 140 | + // uri like ui://<scope>/<name> | |
| 141 | + const name = uri.pathname.replace(/^\//, "") || uri.hostname; | |
| 142 | + const viewName = name.includes("/") ? name.split("/").pop()! : name; | |
| 143 | + const bundle = await run(runtime.getUiBundle(scope, viewName)); | |
| 144 | + if (!bundle) { | |
| 145 | + return { contents: [{ uri: uri.toString(), mimeType: "text/plain", text: "not found" }] }; | |
| 146 | + } | |
| 147 | + return { | |
| 148 | + contents: [ | |
| 149 | + { | |
| 150 | + uri: uri.toString(), | |
| 151 | + mimeType: UI_MIME, | |
| 152 | + text: bundle.code, | |
| 153 | + _meta: { | |
| 154 | + ui: { | |
| 155 | + title: bundle.title, | |
| 156 | + maxHeight: bundle.maxHeight, | |
| 157 | + csp: { connectDomains: [], resourceDomains: [] }, | |
| 158 | + }, | |
| 159 | + }, | |
| 160 | + }, | |
| 161 | + ], | |
| 162 | + }; | |
| 163 | + }, | |
| 164 | + ); | |
| 165 | +}; | |