Unified · Split
| @@ -152,7 +152,7 @@ const makeScopeStore = (repoDir: string): ScopeArtifactStore => { · 151 unmodified lines | |||
| 152 | GIT_COMMITTER_EMAIL: "apps@executor.local", | 152 | GIT_COMMITTER_EMAIL: "apps@executor.local", |
| 153 | }; | 153 | }; |
| 154 | const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => { | 154 | const commitHash = (yield* Effect.callback<string, ArtifactStoreError>((resume) => { |
| 155 | const child = execFile( | 155 | execFile( |
| 156 | "git", | 156 | "git", |
| 157 | commitArgs, | 157 | commitArgs, |
| 158 | { cwd: repoDir, encoding: "buffer", env: commitEnvVars }, | 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 | }; | ||