Unified · Split
| @@ -0,0 +1,9 @@ | |||
| 1 | // HTTP + plugin surface for @executor-js/plugin-apps. | ||
| 2 | export { | ||
| 3 | appsPlugin, | ||
| 4 | APPS_INTEGRATION_SLUG, | ||
| 5 | APPS_PLUGIN_ID, | ||
| 6 | type AppsPluginOptions, | ||
| 7 | } from "./plugin/apps-plugin"; | ||
| 8 | export { makeAppsHttpRoutes, type AppsHttpDeps } from "./http/routes"; | ||
| 9 | export { registerAppsMcp, type AppsMcpDeps } from "./mcp/register"; | ||
| @@ -0,0 +1,46 @@ | |||
| 1 | // @executor-js/plugin-apps — the executor apps subsystem (self-hosted build). | ||
| 2 | // | ||
| 3 | // Custom tools, durable workflows, UI views, and skills: published into a | ||
| 4 | // per-scope store and served/executed by the platform. Publish is the compiler | ||
| 5 | // (FDI); every substrate-specific capability sits behind a seam. | ||
| 6 | |||
| 7 | export * from "./seams"; | ||
| 8 | export * from "./pipeline/descriptor"; | ||
| 9 | export { discover, PublishError } from "./pipeline/discover"; | ||
| 10 | export { bundleEntry, PLATFORM_MODULES, INLINABLE_MODULES } from "./pipeline/bundle"; | ||
| 11 | export { | ||
| 12 | publish, | ||
| 13 | type PublishInput, | ||
| 14 | type PublishOutput, | ||
| 15 | type PublishDeps, | ||
| 16 | type PutBlob, | ||
| 17 | } from "./pipeline/publish"; | ||
| 18 | |||
| 19 | export { makeAppsRuntime, type AppsRuntime, type AppsRuntimeDeps } from "./plugin/runtime"; | ||
| 20 | export { | ||
| 21 | makeAppsStore, | ||
| 22 | type AppsStore, | ||
| 23 | type AppsStoreDeps, | ||
| 24 | descriptorCollection, | ||
| 25 | } from "./plugin/store"; | ||
| 26 | export { | ||
| 27 | buildBridge, | ||
| 28 | rootsFor, | ||
| 29 | BindingError, | ||
| 30 | type Bindings, | ||
| 31 | type RoleBinding, | ||
| 32 | type ClientResolver, | ||
| 33 | type BindingContext, | ||
| 34 | } from "./plugin/bindings"; | ||
| 35 | export { | ||
| 36 | makeSelfHostAppsRuntime, | ||
| 37 | type SelfHostAppsRuntime, | ||
| 38 | type SelfHostAppsRuntimeOptions, | ||
| 39 | } from "./plugin/self-host-runtime"; | ||
| 40 | |||
| 41 | // Self-host seam backings. | ||
| 42 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; | ||
| 43 | export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | ||
| 44 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; | ||
| 45 | export { makeSqliteWorkflowRunner } from "./backing/sqlite-workflow-runner"; | ||
| 46 | export { makeInProcessLiveChannel } from "./backing/in-process-live-channel"; | ||
| @@ -0,0 +1,152 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | definePlugin, | ||
| 5 | ToolName, | ||
| 6 | type ResolveToolsInput, | ||
| 7 | type ResolveToolsResult, | ||
| 8 | type InvokeToolInput, | ||
| 9 | type ToolDef, | ||
| 10 | } from "@executor-js/sdk"; | ||
| 11 | |||
| 12 | import type { AppsRuntime } from "./runtime"; | ||
| 13 | import { makeAppsStore } from "./store"; | ||
| 14 | import type { Bindings } from "./bindings"; | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // The apps source plugin. Published custom tools become catalog citizens: the | ||
| 18 | // plugin registers one integration per scope (`apps`), and a connection to it | ||
| 19 | // makes the published tools resolvable + invocable like any catalog tool, so | ||
| 20 | // policy / approval / audit / toolkits / tools.list all apply unchanged. | ||
| 21 | // | ||
| 22 | // `resolveTools` projects the published descriptor into `ToolDef[]`. | ||
| 23 | // `invokeTool` bundles + runs the tool in the sandbox with connections bound. | ||
| 24 | // The plugin is thin: all logic lives in `AppsRuntime` (shared with the HTTP + | ||
| 25 | // MCP surfaces). The runtime is supplied via options because it owns the seam | ||
| 26 | // instances built at host boot. | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | |||
| 29 | export const APPS_INTEGRATION_SLUG = "apps"; | ||
| 30 | export const APPS_PLUGIN_ID = "apps"; | ||
| 31 | |||
| 32 | export interface AppsPluginOptions { | ||
| 33 | /** The shared runtime (seams + store). Built at host boot. */ | ||
| 34 | readonly runtime: AppsRuntime; | ||
| 35 | /** How a tool's declared connection roles are bound to the caller's | ||
| 36 | * connections at invoke time. Self-host resolves these from the scope's | ||
| 37 | * configured connections; a default binds each role to a connection of the | ||
| 38 | * same name as the integration. */ | ||
| 39 | readonly resolveBindings?: (input: { | ||
| 40 | readonly scope: string; | ||
| 41 | readonly tool: string; | ||
| 42 | readonly declared: Readonly<Record<string, { kind: string; integration?: string }>>; | ||
| 43 | }) => Bindings; | ||
| 44 | } | ||
| 45 | |||
| 46 | const defaultBindings = ( | ||
| 47 | declared: Readonly<Record<string, { kind: string; integration?: string }>>, | ||
| 48 | ): Bindings => { | ||
| 49 | const out: Record<string, Bindings[string]> = {}; | ||
| 50 | for (const [role, decl] of Object.entries(declared)) { | ||
| 51 | if (decl.kind === "array") { | ||
| 52 | out[role] = { kind: "array", connections: [decl.integration ?? role] }; | ||
| 53 | } else if (decl.kind === "catalog") { | ||
| 54 | // no binding | ||
| 55 | } else { | ||
| 56 | out[role] = { kind: "single", connection: decl.integration ?? role }; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | return out; | ||
| 60 | }; | ||
| 61 | |||
| 62 | interface AppsStoreShape { | ||
| 63 | readonly runtime: AppsRuntime; | ||
| 64 | } | ||
| 65 | |||
| 66 | export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { | ||
| 67 | if (!options?.runtime) { | ||
| 68 | throw new Error("appsPlugin requires a `runtime` (built from the five seams at host boot)"); | ||
| 69 | } | ||
| 70 | const runtime = options.runtime; | ||
| 71 | const resolveBindings = options.resolveBindings; | ||
| 72 | |||
| 73 | return { | ||
| 74 | id: APPS_PLUGIN_ID as "apps", | ||
| 75 | packageName: "@executor-js/plugin-apps", | ||
| 76 | |||
| 77 | // The plugin's store facade is host-owned plugin storage + blobs; the apps | ||
| 78 | // runtime already holds its own store, so this is a thin passthrough kept | ||
| 79 | // for the ctx shape (extension methods read the runtime). | ||
| 80 | storage: (deps): AppsStoreShape => { | ||
| 81 | void makeAppsStore({ | ||
| 82 | pluginStorage: deps.pluginStorage, | ||
| 83 | blobs: deps.blobs, | ||
| 84 | }); | ||
| 85 | return { runtime }; | ||
| 86 | }, | ||
| 87 | |||
| 88 | // Declare the plugin's storage collection so the host provisions it. | ||
| 89 | pluginStorage: { | ||
| 90 | published_descriptor: { | ||
| 91 | name: "published_descriptor", | ||
| 92 | schema: { Type: {} as Record<string, unknown> }, | ||
| 93 | indexes: [], | ||
| 94 | }, | ||
| 95 | }, | ||
| 96 | |||
| 97 | extension: () => ({ runtime }), | ||
| 98 | |||
| 99 | // Per-connection tool production: project the published descriptor into | ||
| 100 | // ToolDefs. Called at connection create/refresh; the SDK stamps addresses | ||
| 101 | // and persists per connection. | ||
| 102 | resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | // The scope is the connection owner's tenant; for self-host single | ||
| 105 | // tenant we key the descriptor by the connection's integration-scoped | ||
| 106 | // name. We read the published descriptor for the scope encoded in the | ||
| 107 | // connection name (`apps/<scope>`), falling back to the connection name. | ||
| 108 | const scope = scopeFromConnection(connection.name); | ||
| 109 | const descriptor = yield* runtime.getDescriptor(scope); | ||
| 110 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; | ||
| 111 | const tools: ToolDef[] = descriptor.tools.map((t) => ({ | ||
| 112 | name: ToolName.make(t.name), | ||
| 113 | description: t.description, | ||
| 114 | inputSchema: t.inputSchema, | ||
| 115 | outputSchema: t.outputSchema, | ||
| 116 | annotations: { | ||
| 117 | requiresApproval: t.annotations?.destructive === true, | ||
| 118 | }, | ||
| 119 | })); | ||
| 120 | return { tools } satisfies ResolveToolsResult; | ||
| 121 | }), | ||
| 122 | |||
| 123 | invokeTool: ({ toolRow, args }: InvokeToolInput<AppsStoreShape>) => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const scope = scopeFromConnection(toolRow.connection); | ||
| 126 | const descriptor = yield* runtime.getDescriptor(scope); | ||
| 127 | const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); | ||
| 128 | const declared = toolDesc?.connections ?? {}; | ||
| 129 | const bindings = resolveBindings | ||
| 130 | ? resolveBindings({ scope, tool: toolRow.name, declared }) | ||
| 131 | : defaultBindings(declared); | ||
| 132 | return yield* runtime | ||
| 133 | .invokeTool({ scope, tool: toolRow.name, args, bindings }) | ||
| 134 | .pipe( | ||
| 135 | Effect.mapError( | ||
| 136 | (cause) => | ||
| 137 | new Error( | ||
| 138 | "message" in cause && typeof cause.message === "string" | ||
| 139 | ? cause.message | ||
| 140 | : "apps tool invocation failed", | ||
| 141 | ), | ||
| 142 | ), | ||
| 143 | ); | ||
| 144 | }), | ||
| 145 | }; | ||
| 146 | }); | ||
| 147 | |||
| 148 | // Connection names encode the scope as `apps/<scope>` (or are the scope itself). | ||
| 149 | const scopeFromConnection = (connectionName: string): string => { | ||
| 150 | const slash = connectionName.indexOf("/"); | ||
| 151 | return slash === -1 ? connectionName : connectionName.slice(slash + 1); | ||
| 152 | }; | ||
| @@ -0,0 +1,193 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { Data } from "effect"; | ||
| 3 | |||
| 4 | import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox"; | ||
| 5 | import { ToolSandboxError } from "../seams/tool-sandbox"; | ||
| 6 | import type { ScopeDbHandle } from "../seams/scope-db"; | ||
| 7 | import type { ConnectionDecl } from "../pipeline/descriptor"; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // Connection DI: declare-then-bind. A tool's descriptor declares `connections` | ||
| 11 | // (role -> integration); before execution each role is bound to the user's | ||
| 12 | // connection(s) explicitly. A missing binding is a typed error naming role + | ||
| 13 | // surface (no auto-pick). The handler receives pre-bound clients whose method | ||
| 14 | // calls route through the platform invoke path. | ||
| 15 | // | ||
| 16 | // The routing is a seam: `ClientResolver` turns a (integration, connection, | ||
| 17 | // method path, args) into a JSON result. The self-hosted resolver calls the | ||
| 18 | // integration's real API through the connection credential (policy/audit | ||
| 19 | // applies there); a test resolver returns canned data. `db` is bound to the | ||
| 20 | // invoking scope's ScopeDb. Everything crossing is JSON (the cloud resolver is | ||
| 21 | // an RPC). | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | export class BindingError extends Data.TaggedError("BindingError")<{ | ||
| 25 | readonly message: string; | ||
| 26 | readonly role: string; | ||
| 27 | readonly surface: string; | ||
| 28 | }> {} | ||
| 29 | |||
| 30 | /** One bound connection for a role. Fan-out roles bind an ordered set. */ | ||
| 31 | export type RoleBinding = | ||
| 32 | | { readonly kind: "single"; readonly connection: string } | ||
| 33 | | { readonly kind: "array"; readonly connections: readonly string[] }; | ||
| 34 | |||
| 35 | /** The user's bindings for a tool invocation: role -> bound connection(s). */ | ||
| 36 | export type Bindings = Readonly<Record<string, RoleBinding>>; | ||
| 37 | |||
| 38 | /** Resolves a single method call against a bound connection to a JSON result. | ||
| 39 | * This is where the platform invoke path (credentials, policy, audit) lives. */ | ||
| 40 | export interface ClientResolver { | ||
| 41 | readonly call: (input: { | ||
| 42 | readonly integration: string; | ||
| 43 | readonly connection: string; | ||
| 44 | readonly path: readonly string[]; | ||
| 45 | readonly args: readonly unknown[]; | ||
| 46 | }) => Effect.Effect<unknown, BindingError>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export interface BindingContext { | ||
| 50 | /** Declared connection roles from the tool descriptor. */ | ||
| 51 | readonly declared: Readonly<Record<string, ConnectionDecl>>; | ||
| 52 | /** The user's bindings (role -> connection[s]). Missing => typed error. */ | ||
| 53 | readonly bindings: Bindings; | ||
| 54 | /** The invoking scope's app database (bound to the `db` root). */ | ||
| 55 | readonly db: ScopeDbHandle; | ||
| 56 | /** Routes a bound method call to the real integration. */ | ||
| 57 | readonly resolver: ClientResolver; | ||
| 58 | } | ||
| 59 | |||
| 60 | /** | ||
| 61 | * Validate bindings against declarations and compute the sandbox handle roots: | ||
| 62 | * `db` is always a single root; each declared role becomes a single or array | ||
| 63 | * root. Fails (typed) when a declared role has no binding, naming role + | ||
| 64 | * surface — the "missing binding is a typed error" rule. | ||
| 65 | */ | ||
| 66 | export const rootsFor = ( | ||
| 67 | declared: Readonly<Record<string, ConnectionDecl>>, | ||
| 68 | bindings: Bindings, | ||
| 69 | ): Effect.Effect<Readonly<Record<string, HandleRootSpec>>, BindingError> => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const roots: Record<string, HandleRootSpec> = { db: { kind: "single" } }; | ||
| 72 | for (const [role, decl] of Object.entries(declared)) { | ||
| 73 | if (decl.kind === "catalog") { | ||
| 74 | // Open-world proxy: parse + record, but execution is NotImplemented in | ||
| 75 | // this build. Bind a single root; the resolver throws if called. | ||
| 76 | roots[role] = { kind: "single" }; | ||
| 77 | continue; | ||
| 78 | } | ||
| 79 | const binding = bindings[role]; | ||
| 80 | if (!binding) { | ||
| 81 | return yield* Effect.fail( | ||
| 82 | new BindingError({ | ||
| 83 | message: `no connection bound for role "${role}" (surface "${decl.integration}")`, | ||
| 84 | role, | ||
| 85 | surface: decl.integration, | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | if (decl.kind === "array") { | ||
| 90 | if (binding.kind !== "array") { | ||
| 91 | return yield* Effect.fail( | ||
| 92 | new BindingError({ | ||
| 93 | message: `role "${role}" is a fan-out (connections("${decl.integration}")) and needs an array binding`, | ||
| 94 | role, | ||
| 95 | surface: decl.integration, | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | roots[role] = { kind: "array", count: binding.connections.length }; | ||
| 100 | } else { | ||
| 101 | if (binding.kind !== "single") { | ||
| 102 | return yield* Effect.fail( | ||
| 103 | new BindingError({ | ||
| 104 | message: `role "${role}" is a single connection and needs a single binding`, | ||
| 105 | role, | ||
| 106 | surface: decl.integration, | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | } | ||
| 110 | roots[role] = { kind: "single" }; | ||
| 111 | } | ||
| 112 | } | ||
| 113 | return roots; | ||
| 114 | }); | ||
| 115 | |||
| 116 | // Parse a fan-out root name back into (role, index): `inboxes#1` -> {inboxes,1}. | ||
| 117 | const parseRoot = (root: string): { role: string; index?: number } => { | ||
| 118 | const hash = root.indexOf("#"); | ||
| 119 | if (hash === -1) return { role: root }; | ||
| 120 | return { role: root.slice(0, hash), index: Number(root.slice(hash + 1)) }; | ||
| 121 | }; | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Build the HandleBridge the sandbox calls out through. `db` routes to the | ||
| 125 | * scope database; a declared role routes to its bound connection through the | ||
| 126 | * `ClientResolver`. Undeclared roots are unreachable (the sandbox never injects | ||
| 127 | * them). A `.account` read on a client returns bound-connection metadata | ||
| 128 | * without a round-trip. | ||
| 129 | */ | ||
| 130 | export const buildBridge = (context: BindingContext): HandleBridge => ({ | ||
| 131 | call: ({ root, path, args }) => { | ||
| 132 | if (root === "db") { | ||
| 133 | // The scope db handle exposes `sql` as a tagged template; the injected | ||
| 134 | // client calls `db.sql(templateStrings, ...values)`. When routed through | ||
| 135 | // the bridge, `path = ["sql"]` and args = [stringsArray, ...values]. | ||
| 136 | if (path.length === 1 && path[0] === "sql") { | ||
| 137 | const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]]; | ||
| 138 | return context.db | ||
| 139 | .sql(strings, ...values) | ||
| 140 | .pipe( | ||
| 141 | Effect.mapError( | ||
| 142 | (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | } | ||
| 146 | return Effect.fail( | ||
| 147 | new ToolSandboxError({ kind: "invoke", message: `unsupported db call: ${path.join(".")}` }), | ||
| 148 | ); | ||
| 149 | } | ||
| 150 | |||
| 151 | const { role, index } = parseRoot(root); | ||
| 152 | const decl = context.declared[role]; | ||
| 153 | if (!decl) { | ||
| 154 | return Effect.fail( | ||
| 155 | new ToolSandboxError({ kind: "invoke", message: `undeclared handle root: ${root}` }), | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | if (decl.kind === "catalog") { | ||
| 159 | return Effect.fail( | ||
| 160 | new ToolSandboxError({ | ||
| 161 | kind: "invoke", | ||
| 162 | message: "catalog() open-world proxy execution is not implemented in this build", | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | } | ||
| 166 | |||
| 167 | // A `.account` read returns bound-connection metadata (safe, no creds). | ||
| 168 | const binding = context.bindings[role]; | ||
| 169 | const connectionName = | ||
| 170 | binding?.kind === "array" | ||
| 171 | ? binding.connections[index ?? 0] | ||
| 172 | : binding?.kind === "single" | ||
| 173 | ? binding.connection | ||
| 174 | : undefined; | ||
| 175 | if (!connectionName) { | ||
| 176 | return Effect.fail( | ||
| 177 | new ToolSandboxError({ kind: "invoke", message: `no binding for role ${role}` }), | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | if (path.length === 1 && path[0] === "account") { | ||
| 181 | // Clients read `.account.email` / `.account.login`; expose both keys. | ||
| 182 | return Effect.succeed({ email: connectionName, login: connectionName, name: connectionName }); | ||
| 183 | } | ||
| 184 | |||
| 185 | return context.resolver | ||
| 186 | .call({ integration: decl.integration, connection: connectionName, path, args }) | ||
| 187 | .pipe( | ||
| 188 | Effect.mapError( | ||
| 189 | (cause) => new ToolSandboxError({ kind: "invoke", message: cause.message, cause }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | }, | ||
| 193 | }); | ||
| @@ -0,0 +1,98 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 5 | import { describe, expect, it } from "vitest"; | ||
| 6 | import { Effect } from "effect"; | ||
| 7 | |||
| 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | ||
| 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; | ||
| 10 | import type { Bindings } from "./bindings"; | ||
| 11 | |||
| 12 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | ||
| 13 | |||
| 14 | // A GitHub resolver returning two open issues, one stale. | ||
| 15 | const githubHandlers = { | ||
| 16 | github: { | ||
| 17 | "repos.listForAuthenticatedUser": () => [{ full_name: "acme/app" }], | ||
| 18 | "issues.listForRepo": () => [ | ||
| 19 | { | ||
| 20 | number: 1, | ||
| 21 | title: "Fresh bug", | ||
| 22 | labels: [{ name: "bug" }], | ||
| 23 | assignee: { login: "rhys" }, | ||
| 24 | updated_at: new Date().toISOString(), | ||
| 25 | html_url: "https://github.com/acme/app/issues/1", | ||
| 26 | }, | ||
| 27 | { | ||
| 28 | number: 2, | ||
| 29 | title: "Old bug", | ||
| 30 | labels: [], | ||
| 31 | assignee: null, | ||
| 32 | updated_at: "2020-01-01T00:00:00Z", | ||
| 33 | html_url: "https://github.com/acme/app/issues/2", | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | }, | ||
| 37 | }; | ||
| 38 | |||
| 39 | const githubBindings: Bindings = { github: { kind: "single", connection: "rhys-github" } }; | ||
| 40 | |||
| 41 | describe("AppsRuntime end-to-end (publish -> invoke -> workflow)", () => { | ||
| 42 | it("publishes daily-brief, invokes the tool into the scope db, runs the workflow", async () => { | ||
| 43 | const store = makeInMemoryAppsStore(); | ||
| 44 | const resolver = makeTestResolver(githubHandlers); | ||
| 45 | const host = makeSelfHostAppsRuntime({ | ||
| 46 | dataDir: mkdtempSync(join(tmpdir(), "apps-rt-")), | ||
| 47 | store, | ||
| 48 | resolver, | ||
| 49 | inMemory: true, | ||
| 50 | }); | ||
| 51 | const { runtime } = host; | ||
| 52 | |||
| 53 | // --- publish ---------------------------------------------------------- | ||
| 54 | const published = await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() })); | ||
| 55 | expect(published.descriptor.tools.map((t) => t.name).sort()).toEqual([ | ||
| 56 | "issues-sync", | ||
| 57 | "search-all-mail", | ||
| 58 | ]); | ||
| 59 | |||
| 60 | // --- invoke the tool: writes the scope `issues` table ----------------- | ||
| 61 | const syncResult = (await run( | ||
| 62 | runtime.invokeTool({ | ||
| 63 | scope: "rhys", | ||
| 64 | tool: "issues-sync", | ||
| 65 | args: {}, | ||
| 66 | bindings: githubBindings, | ||
| 67 | }), | ||
| 68 | )) as { synced: number; repos: number }; | ||
| 69 | expect(syncResult).toEqual({ synced: 2, repos: 1 }); | ||
| 70 | |||
| 71 | // The scope db now has the two issues. | ||
| 72 | const db = await run(host.scopeDb.forScope("rhys")); | ||
| 73 | const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); | ||
| 74 | expect(Number(rows[0].n)).toBe(2); | ||
| 75 | |||
| 76 | // --- run the workflow: syncs again + flags the stale issue ------------ | ||
| 77 | const runView = await run( | ||
| 78 | runtime.startWorkflow({ | ||
| 79 | scope: "rhys", | ||
| 80 | workflow: "morning-sync", | ||
| 81 | input: {}, | ||
| 82 | bindings: githubBindings, | ||
| 83 | runId: "morning-1", | ||
| 84 | }), | ||
| 85 | ); | ||
| 86 | expect(runView.status).toBe("completed"); | ||
| 87 | expect(runView.output).toEqual({ synced: 2, stale: 1 }); | ||
| 88 | |||
| 89 | // Journal has the step.tool call and the find-stale step. | ||
| 90 | const steps = await run(runtime.listSteps("morning-1")); | ||
| 91 | const names = steps.map((s) => s.name); | ||
| 92 | expect(names).toContain("tool:issues-sync"); | ||
| 93 | expect(names).toContain("find-stale"); | ||
| 94 | expect(steps.every((s) => s.status === "completed")).toBe(true); | ||
| 95 | |||
| 96 | await host.close(); | ||
| 97 | }); | ||
| 98 | }); | ||
| @@ -0,0 +1,433 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import type { ArtifactStore } from "../seams/artifact-store"; | ||
| 4 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 5 | import type { ToolSandbox } from "../seams/tool-sandbox"; | ||
| 6 | import type { LiveChannel } from "../seams/live-channel"; | ||
| 7 | import type { DurableSteps, WorkflowRunner, RunView, StepView } from "../seams/workflow-runner"; | ||
| 8 | import { publish as runPublish, type PublishOutput } from "../pipeline/publish"; | ||
| 9 | import { PublishError } from "../pipeline/discover"; | ||
| 10 | import type { AppDescriptor, ToolDescriptor, WorkflowDescriptor } from "../pipeline/descriptor"; | ||
| 11 | import { bundleEntry } from "../pipeline/bundle"; | ||
| 12 | import { | ||
| 13 | buildBridge, | ||
| 14 | rootsFor, | ||
| 15 | type Bindings, | ||
| 16 | type ClientResolver, | ||
| 17 | BindingError, | ||
| 18 | } from "./bindings"; | ||
| 19 | import type { AppsStore } from "./store"; | ||
| 20 | |||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | // AppsRuntime — the substrate-neutral core of the apps subsystem. It owns the | ||
| 23 | // five seams + the store, and exposes the operations every surface (HTTP, MCP, | ||
| 24 | // the plugin) drives: publish, resolveTools (descriptor projection), invokeTool | ||
| 25 | // (bundle -> sandbox invoke with bound clients), and the workflow lifecycle | ||
| 26 | // (start/signal/status/history) with real journal replay + scheduling. | ||
| 27 | // | ||
| 28 | // Nothing here knows about HTTP or MCP; those are thin adapters over this. | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | export interface AppsRuntimeDeps { | ||
| 32 | readonly artifactStore: ArtifactStore; | ||
| 33 | readonly scopeDb: ScopeDb; | ||
| 34 | readonly sandbox: ToolSandbox; | ||
| 35 | readonly workflows: WorkflowRunner; | ||
| 36 | readonly liveChannel: LiveChannel; | ||
| 37 | readonly store: AppsStore; | ||
| 38 | /** Routes a bound integration method call to the real API (policy/audit). */ | ||
| 39 | readonly resolver: ClientResolver; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface AppsRuntime { | ||
| 43 | readonly publish: (input: { | ||
| 44 | readonly scope: string; | ||
| 45 | readonly files: ReadonlyMap<string, string>; | ||
| 46 | readonly message?: string; | ||
| 47 | }) => Effect.Effect<PublishOutput, PublishError>; | ||
| 48 | readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null>; | ||
| 49 | readonly invokeTool: (input: { | ||
| 50 | readonly scope: string; | ||
| 51 | readonly tool: string; | ||
| 52 | readonly args: unknown; | ||
| 53 | readonly bindings: Bindings; | ||
| 54 | }) => Effect.Effect<unknown, PublishError | BindingError>; | ||
| 55 | readonly startWorkflow: (input: { | ||
| 56 | readonly scope: string; | ||
| 57 | readonly workflow: string; | ||
| 58 | readonly input?: unknown; | ||
| 59 | readonly bindings?: Bindings; | ||
| 60 | readonly runId?: string; | ||
| 61 | }) => Effect.Effect<RunView, PublishError | BindingError>; | ||
| 62 | readonly signalWorkflow: (input: { | ||
| 63 | readonly scope: string; | ||
| 64 | readonly runId: string; | ||
| 65 | readonly event: string; | ||
| 66 | readonly payload: unknown; | ||
| 67 | }) => Effect.Effect<RunView, PublishError | BindingError>; | ||
| 68 | readonly getRun: (runId: string) => Effect.Effect<RunView | null>; | ||
| 69 | readonly listRuns: (scope: string) => Effect.Effect<readonly RunView[]>; | ||
| 70 | readonly listSteps: (runId: string) => Effect.Effect<readonly StepView[]>; | ||
| 71 | /** Serve a ui bundle by name (raw endpoint / MCP resource). */ | ||
| 72 | readonly getUiBundle: ( | ||
| 73 | scope: string, | ||
| 74 | name: string, | ||
| 75 | ) => Effect.Effect<{ code: string; title?: string; maxHeight?: number } | null>; | ||
| 76 | /** Subscribe to a scope's live invalidations (SSE adapter drives this). */ | ||
| 77 | readonly subscribeLive: ( | ||
| 78 | scope: string, | ||
| 79 | listener: (event: { table: string; version: number }) => void, | ||
| 80 | ) => () => void; | ||
| 81 | readonly deps: AppsRuntimeDeps; | ||
| 82 | } | ||
| 83 | |||
| 84 | const failNoDescriptor = (scope: string): PublishError => | ||
| 85 | new PublishError({ | ||
| 86 | message: `scope "${scope}" has no published app`, | ||
| 87 | stage: "project", | ||
| 88 | diagnostics: [], | ||
| 89 | }); | ||
| 90 | |||
| 91 | export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { | ||
| 92 | // Bundle cache keyed by (snapshot, sourcePath): a published snapshot is | ||
| 93 | // immutable, so its bundles never change. | ||
| 94 | const bundleCache = new Map<string, string>(); | ||
| 95 | |||
| 96 | const bundleFor = ( | ||
| 97 | descriptor: AppDescriptor, | ||
| 98 | sourcePath: string, | ||
| 99 | ): Effect.Effect<string, PublishError> => | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const cacheKey = `${descriptor.snapshotId}:${sourcePath}`; | ||
| 102 | const cached = bundleCache.get(cacheKey); | ||
| 103 | if (cached) return cached; | ||
| 104 | const scopeStore = yield* deps.artifactStore | ||
| 105 | .forScope(descriptor.scope) | ||
| 106 | .pipe( | ||
| 107 | Effect.mapError( | ||
| 108 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 109 | ), | ||
| 110 | ); | ||
| 111 | const files = yield* scopeStore | ||
| 112 | .read(descriptor.snapshotId as never) | ||
| 113 | .pipe( | ||
| 114 | Effect.mapError( | ||
| 115 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | const bundle = yield* bundleEntry({ files, entry: sourcePath }).pipe( | ||
| 119 | Effect.mapError( | ||
| 120 | (c) => | ||
| 121 | new PublishError({ | ||
| 122 | message: c.message, | ||
| 123 | stage: "bundle", | ||
| 124 | diagnostics: [{ path: sourcePath, message: c.message }], | ||
| 125 | }), | ||
| 126 | ), | ||
| 127 | ); | ||
| 128 | bundleCache.set(cacheKey, bundle.code); | ||
| 129 | return bundle.code; | ||
| 130 | }); | ||
| 131 | |||
| 132 | const requireDescriptor = (scope: string): Effect.Effect<AppDescriptor, PublishError> => | ||
| 133 | deps.store.getDescriptor(scope).pipe( | ||
| 134 | Effect.mapError( | ||
| 135 | (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), | ||
| 136 | ), | ||
| 137 | Effect.flatMap((d) => (d ? Effect.succeed(d) : Effect.fail(failNoDescriptor(scope)))), | ||
| 138 | ); | ||
| 139 | |||
| 140 | const invokeToolInternal = ( | ||
| 141 | scope: string, | ||
| 142 | descriptor: AppDescriptor, | ||
| 143 | toolDesc: ToolDescriptor, | ||
| 144 | args: unknown, | ||
| 145 | bindings: Bindings, | ||
| 146 | ): Effect.Effect<unknown, PublishError | BindingError> => | ||
| 147 | Effect.gen(function* () { | ||
| 148 | const roots = yield* rootsFor(toolDesc.connections, bindings); | ||
| 149 | const code = yield* bundleFor(descriptor, toolDesc.sourcePath); | ||
| 150 | const db = yield* deps.scopeDb | ||
| 151 | .forScope(scope) | ||
| 152 | .pipe( | ||
| 153 | Effect.mapError( | ||
| 154 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | const bridge = buildBridge({ | ||
| 158 | declared: toolDesc.connections, | ||
| 159 | bindings, | ||
| 160 | db, | ||
| 161 | resolver: deps.resolver, | ||
| 162 | }); | ||
| 163 | const result = yield* deps.sandbox | ||
| 164 | .invoke(code, { artifact: toolDesc.name, kind: "tool", input: args, roots }, bridge) | ||
| 165 | .pipe( | ||
| 166 | Effect.mapError( | ||
| 167 | (c) => | ||
| 168 | new PublishError({ | ||
| 169 | message: c.message, | ||
| 170 | stage: "project", | ||
| 171 | diagnostics: [{ path: toolDesc.sourcePath, message: c.message }], | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | ); | ||
| 175 | return result.output; | ||
| 176 | }); | ||
| 177 | |||
| 178 | // Build the workflow body closure for one run: replay the workflow's compiled | ||
| 179 | // bundle over the DurableSteps. Our sandbox runs a tool handler, not a | ||
| 180 | // long-lived stepful body, so we drive the workflow with a thin interpreter: | ||
| 181 | // the workflow's `run(step, {db})` is executed in-process against the | ||
| 182 | // DurableSteps facade, with `step.tool` -> the runner bindings and | ||
| 183 | // `db.sql` -> the scope db. This keeps CF semantics while the durable journal | ||
| 184 | // lives in the WorkflowRunner seam. | ||
| 185 | const workflowBody = ( | ||
| 186 | scope: string, | ||
| 187 | descriptor: AppDescriptor, | ||
| 188 | wfDesc: WorkflowDescriptor, | ||
| 189 | ): ((steps: DurableSteps) => Promise<unknown>) => { | ||
| 190 | return async (steps: DurableSteps) => { | ||
| 191 | const code = await Effect.runPromise( | ||
| 192 | bundleFor(descriptor, wfDesc.sourcePath).pipe(Effect.orDie), | ||
| 193 | ); | ||
| 194 | const db = await Effect.runPromise(deps.scopeDb.forScope(scope).pipe(Effect.orDie)); | ||
| 195 | // Interpret the workflow: run its bundle in the sandbox is not how durable | ||
| 196 | // steps work (the body must call back into our journaled `steps`). Instead | ||
| 197 | // we evaluate the workflow module here and invoke its `run(step, {db})`. | ||
| 198 | // The compiled bundle sets globalThis.__artifact = the workflow def. | ||
| 199 | const def = extractWorkflowDef(code); | ||
| 200 | const scopeDbClient = { | ||
| 201 | sql: (strings: TemplateStringsArray, ...values: unknown[]) => | ||
| 202 | Effect.runPromise(db.sql(strings, ...values).pipe(Effect.orDie)), | ||
| 203 | }; | ||
| 204 | return def.run(steps, { db: scopeDbClient }); | ||
| 205 | }; | ||
| 206 | }; | ||
| 207 | |||
| 208 | const bindingsForRunTool = ( | ||
| 209 | scope: string, | ||
| 210 | descriptor: AppDescriptor, | ||
| 211 | recordedBindings: Bindings, | ||
| 212 | ) => ({ | ||
| 213 | runTool: async (address: string, toolArgs: unknown) => { | ||
| 214 | const toolDesc = descriptor.tools.find((t) => t.name === address); | ||
| 215 | if (!toolDesc) throw new Error(`workflow step.tool: unknown tool "${address}"`); | ||
| 216 | return Effect.runPromise( | ||
| 217 | invokeToolInternal(scope, descriptor, toolDesc, toolArgs, recordedBindings).pipe( | ||
| 218 | Effect.orDie, | ||
| 219 | ), | ||
| 220 | ); | ||
| 221 | }, | ||
| 222 | notify: async (_msg: { title: string; body?: string; link?: string }) => { | ||
| 223 | // Self-host delivery sink: recorded as a journaled step; a real host wires | ||
| 224 | // this to notifications. No-op body here keeps the workflow durable. | ||
| 225 | }, | ||
| 226 | }); | ||
| 227 | |||
| 228 | return { | ||
| 229 | deps, | ||
| 230 | publish: (input) => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const out = yield* runPublish( | ||
| 233 | { | ||
| 234 | artifactStore: deps.artifactStore, | ||
| 235 | sandbox: deps.sandbox, | ||
| 236 | putBlob: (hash, value) => | ||
| 237 | deps.store | ||
| 238 | .putBlob(hash, value) | ||
| 239 | .pipe( | ||
| 240 | Effect.mapError( | ||
| 241 | (c) => | ||
| 242 | new PublishError({ message: String(c), stage: "project", diagnostics: [] }), | ||
| 243 | ), | ||
| 244 | ), | ||
| 245 | }, | ||
| 246 | { scope: input.scope, files: input.files, commitMessage: input.message }, | ||
| 247 | ); | ||
| 248 | yield* deps.store | ||
| 249 | .putDescriptor("org", out.descriptor) | ||
| 250 | .pipe( | ||
| 251 | Effect.mapError( | ||
| 252 | (c) => new PublishError({ message: String(c), stage: "project", diagnostics: [] }), | ||
| 253 | ), | ||
| 254 | ); | ||
| 255 | return out; | ||
| 256 | }), | ||
| 257 | |||
| 258 | getDescriptor: (scope) => | ||
| 259 | deps.store.getDescriptor(scope).pipe(Effect.orElseSucceed(() => null)), | ||
| 260 | |||
| 261 | invokeTool: (input) => | ||
| 262 | Effect.gen(function* () { | ||
| 263 | const descriptor = yield* requireDescriptor(input.scope); | ||
| 264 | const toolDesc = descriptor.tools.find((t) => t.name === input.tool); | ||
| 265 | if (!toolDesc) { | ||
| 266 | return yield* Effect.fail( | ||
| 267 | new PublishError({ | ||
| 268 | message: `tool "${input.tool}" is not published in scope "${input.scope}"`, | ||
| 269 | stage: "project", | ||
| 270 | diagnostics: [], | ||
| 271 | }), | ||
| 272 | ); | ||
| 273 | } | ||
| 274 | return yield* invokeToolInternal( | ||
| 275 | input.scope, | ||
| 276 | descriptor, | ||
| 277 | toolDesc, | ||
| 278 | input.args, | ||
| 279 | input.bindings, | ||
| 280 | ); | ||
| 281 | }), | ||
| 282 | |||
| 283 | startWorkflow: (input) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const descriptor = yield* requireDescriptor(input.scope); | ||
| 286 | const wfDesc = descriptor.workflows.find((w) => w.name === input.workflow); | ||
| 287 | if (!wfDesc) { | ||
| 288 | return yield* Effect.fail( | ||
| 289 | new PublishError({ | ||
| 290 | message: `workflow "${input.workflow}" is not published in scope "${input.scope}"`, | ||
| 291 | stage: "project", | ||
| 292 | diagnostics: [], | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | const bindings = input.bindings ?? {}; | ||
| 297 | const body = workflowBody(input.scope, descriptor, wfDesc); | ||
| 298 | return yield* deps.workflows | ||
| 299 | .start( | ||
| 300 | { | ||
| 301 | scope: input.scope, | ||
| 302 | workflow: input.workflow, | ||
| 303 | snapshotId: descriptor.snapshotId, | ||
| 304 | input: input.input ?? {}, | ||
| 305 | runId: input.runId, | ||
| 306 | }, | ||
| 307 | body, | ||
| 308 | bindingsForRunTool(input.scope, descriptor, bindings), | ||
| 309 | ) | ||
| 310 | .pipe( | ||
| 311 | Effect.mapError( | ||
| 312 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 313 | ), | ||
| 314 | ); | ||
| 315 | }), | ||
| 316 | |||
| 317 | signalWorkflow: (input) => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const run = yield* deps.workflows | ||
| 320 | .get(input.runId) | ||
| 321 | .pipe( | ||
| 322 | Effect.mapError( | ||
| 323 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 324 | ), | ||
| 325 | ); | ||
| 326 | if (!run) { | ||
| 327 | return yield* Effect.fail( | ||
| 328 | new PublishError({ | ||
| 329 | message: `no run ${input.runId}`, | ||
| 330 | stage: "project", | ||
| 331 | diagnostics: [], | ||
| 332 | }), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | const descriptor = yield* requireDescriptor(run.scope); | ||
| 336 | const wfDesc = descriptor.workflows.find((w) => w.name === run.workflow); | ||
| 337 | if (!wfDesc) { | ||
| 338 | return yield* Effect.fail( | ||
| 339 | new PublishError({ | ||
| 340 | message: `workflow ${run.workflow} gone`, | ||
| 341 | stage: "project", | ||
| 342 | diagnostics: [], | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | } | ||
| 346 | const body = workflowBody(run.scope, descriptor, wfDesc); | ||
| 347 | return yield* deps.workflows | ||
| 348 | .signal( | ||
| 349 | input.runId, | ||
| 350 | input.event, | ||
| 351 | input.payload, | ||
| 352 | body, | ||
| 353 | bindingsForRunTool(run.scope, descriptor, {}), | ||
| 354 | ) | ||
| 355 | .pipe( | ||
| 356 | Effect.mapError( | ||
| 357 | (c) => new PublishError({ message: c.message, stage: "project", diagnostics: [] }), | ||
| 358 | ), | ||
| 359 | ); | ||
| 360 | }), | ||
| 361 | |||
| 362 | getRun: (runId) => deps.workflows.get(runId).pipe(Effect.orElseSucceed(() => null)), | ||
| 363 | listRuns: (scope) => | ||
| 364 | deps.workflows.list({ scope }).pipe(Effect.orElseSucceed(() => [] as readonly RunView[])), | ||
| 365 | listSteps: (runId) => | ||
| 366 | deps.workflows.listSteps(runId).pipe(Effect.orElseSucceed(() => [] as readonly StepView[])), | ||
| 367 | |||
| 368 | getUiBundle: (scope, name) => | ||
| 369 | Effect.gen(function* () { | ||
| 370 | const descriptor = yield* deps.store | ||
| 371 | .getDescriptor(scope) | ||
| 372 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 373 | if (!descriptor) return null; | ||
| 374 | const uiDesc = descriptor.ui.find((u) => u.name === name); | ||
| 375 | if (!uiDesc) return null; | ||
| 376 | const code = yield* deps.store | ||
| 377 | .getBlob(`ui/${uiDesc.bundleHash}`) | ||
| 378 | .pipe(Effect.orElseSucceed(() => null)); | ||
| 379 | if (!code) return null; | ||
| 380 | return { code, title: uiDesc.title, maxHeight: uiDesc.maxHeight }; | ||
| 381 | }), | ||
| 382 | |||
| 383 | subscribeLive: (scope, listener) => | ||
| 384 | deps.liveChannel.subscribe(scope, (event) => | ||
| 385 | listener({ table: event.table, version: event.version }), | ||
| 386 | ), | ||
| 387 | }; | ||
| 388 | }; | ||
| 389 | |||
| 390 | // Extract the workflow def from a compiled bundle by running it in a tiny | ||
| 391 | // module shim (Node-side, trusted: this is our own runtime, not user isolation | ||
| 392 | // for the durable interpreter — the tool HANDLERS still run in the sandbox). | ||
| 393 | // The bundle set globalThis.__artifact to the workflow def object. | ||
| 394 | const extractWorkflowDef = ( | ||
| 395 | code: string, | ||
| 396 | ): { run: (steps: DurableSteps, deps: { db: unknown }) => Promise<unknown> } => { | ||
| 397 | const g: Record<string, unknown> = {}; | ||
| 398 | const shim = { | ||
| 399 | connection: (integration: string, opts?: { description?: string }) => ({ | ||
| 400 | __decl: "single", | ||
| 401 | integration, | ||
| 402 | description: opts?.description, | ||
| 403 | }), | ||
| 404 | connections: (integration: string) => ({ __decl: "array", integration }), | ||
| 405 | catalog: () => ({ __decl: "catalog" }), | ||
| 406 | defineTool: (def: unknown) => { | ||
| 407 | g.__artifact = def; | ||
| 408 | return def; | ||
| 409 | }, | ||
| 410 | defineWorkflow: (def: unknown) => { | ||
| 411 | g.__artifact = def; | ||
| 412 | return def; | ||
| 413 | }, | ||
| 414 | }; | ||
| 415 | const req = (id: string) => { | ||
| 416 | if (id === "executor:app") return shim; | ||
| 417 | if (id === "executor:ui") return {}; | ||
| 418 | if (id === "executor:ui/components") return {}; | ||
| 419 | throw new Error(`module not available: ${id}`); | ||
| 420 | }; | ||
| 421 | const moduleObj = { exports: {} as Record<string, unknown> }; | ||
| 422 | const globalThisShim = g as unknown as typeof globalThis; | ||
| 423 | // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func | ||
| 424 | const factory = new Function("module", "exports", "require", "globalThis", code); | ||
| 425 | factory(moduleObj, moduleObj.exports, req, globalThisShim); | ||
| 426 | const def = (g.__artifact ?? moduleObj.exports.default ?? moduleObj.exports) as { | ||
| 427 | run: (steps: DurableSteps, deps: { db: unknown }) => Promise<unknown>; | ||
| 428 | }; | ||
| 429 | if (!def || typeof def.run !== "function") { | ||
| 430 | throw new Error("workflow bundle did not produce a run() function"); | ||
| 431 | } | ||
| 432 | return def; | ||
| 433 | }; | ||
| @@ -0,0 +1,86 @@ | |||
| 1 | import { join } from "node:path"; | ||
| 2 | |||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; | ||
| 6 | import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; | ||
| 7 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; | ||
| 8 | import { makeSqliteWorkflowRunner } from "../backing/sqlite-workflow-runner"; | ||
| 9 | import { makeInProcessLiveChannel } from "../backing/in-process-live-channel"; | ||
| 10 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 11 | import type { LiveChannel } from "../seams/live-channel"; | ||
| 12 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; | ||
| 13 | import type { AppsStore } from "./store"; | ||
| 14 | import type { ClientResolver } from "./bindings"; | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // Wire the five self-hosted seam backings into one AppsRuntime, rooted at a | ||
| 18 | // data directory. This is what the self-host app calls at boot. The ScopeDb's | ||
| 19 | // write events are wired into the LiveChannel here (invalidation delivery), and | ||
| 20 | // the store is provided by the caller (over the host's pluginStorage + blobs). | ||
| 21 | // --------------------------------------------------------------------------- | ||
| 22 | |||
| 23 | export interface SelfHostAppsRuntimeOptions { | ||
| 24 | /** Data dir root; `<root>/artifacts`, `<root>/scope-db`, `<root>/workflows`. */ | ||
| 25 | readonly dataDir: string; | ||
| 26 | /** Store over the host's pluginStorage + blobs. */ | ||
| 27 | readonly store: AppsStore; | ||
| 28 | /** Routes bound integration calls to real APIs (policy/audit). */ | ||
| 29 | readonly resolver: ClientResolver; | ||
| 30 | /** In-memory backings for tests. */ | ||
| 31 | readonly inMemory?: boolean; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface SelfHostAppsRuntime { | ||
| 35 | readonly runtime: AppsRuntime; | ||
| 36 | readonly scopeDb: ScopeDb; | ||
| 37 | readonly liveChannel: LiveChannel; | ||
| 38 | readonly close: () => Promise<void>; | ||
| 39 | } | ||
| 40 | |||
| 41 | export const makeSelfHostAppsRuntime = ( | ||
| 42 | options: SelfHostAppsRuntimeOptions, | ||
| 43 | ): SelfHostAppsRuntime => { | ||
| 44 | const inMem = options.inMemory === true; | ||
| 45 | const artifactStore = makeGitArtifactStore({ | ||
| 46 | root: inMem ? options.dataDir : join(options.dataDir, "artifacts"), | ||
| 47 | }); | ||
| 48 | const scopeDb = makeLibsqlScopeDb({ | ||
| 49 | root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), | ||
| 50 | }); | ||
| 51 | const sandbox = makeQuickjsToolSandbox(); | ||
| 52 | const workflows = makeSqliteWorkflowRunner({ | ||
| 53 | path: inMem ? ":memory:" : join(options.dataDir, "workflows", "journal.db"), | ||
| 54 | }); | ||
| 55 | const liveChannel = makeInProcessLiveChannel(); | ||
| 56 | |||
| 57 | // Wire scope-db writes -> live invalidations. | ||
| 58 | const unwire = scopeDb.onWrite((event) => { | ||
| 59 | for (const t of event.tables) { | ||
| 60 | void Effect.runPromise( | ||
| 61 | liveChannel.publish({ scope: event.scope, table: t.table, version: t.version }), | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | }); | ||
| 65 | |||
| 66 | const runtime = makeAppsRuntime({ | ||
| 67 | artifactStore, | ||
| 68 | scopeDb, | ||
| 69 | sandbox, | ||
| 70 | workflows, | ||
| 71 | liveChannel, | ||
| 72 | store: options.store, | ||
| 73 | resolver: options.resolver, | ||
| 74 | }); | ||
| 75 | |||
| 76 | return { | ||
| 77 | runtime, | ||
| 78 | scopeDb, | ||
| 79 | liveChannel, | ||
| 80 | close: async () => { | ||
| 81 | unwire(); | ||
| 82 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | ||
| 83 | await Effect.runPromise(workflows.close().pipe(Effect.orElseSucceed(() => undefined))); | ||
| 84 | }, | ||
| 85 | }; | ||
| 86 | }; | ||
| @@ -0,0 +1,79 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | definePluginStorageCollection, | ||
| 5 | type PluginStorageFacade, | ||
| 6 | type StorageFailure, | ||
| 7 | } from "@executor-js/sdk"; | ||
| 8 | |||
| 9 | import type { AppDescriptor } from "../pipeline/descriptor"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | // AppsStore — descriptor + snapshot pointer persistence for the apps plugin, | ||
| 13 | // over the host-owned `pluginStorage` (collections) + `blobs` (content-addressed | ||
| 14 | // bundles / skill bodies). Executor plugins do not contribute DB tables, so the | ||
| 15 | // versioned descriptor is a JSON document in a plugin-storage collection keyed | ||
| 16 | // by scope, and the compiled bundles + skill bodies are blobs. | ||
| 17 | // | ||
| 18 | // One published descriptor per scope (the current published pointer). History | ||
| 19 | // is the ArtifactStore's git log; the runtime only runs the published snapshot. | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /** The published descriptor document, keyed by scope. */ | ||
| 23 | export const descriptorCollection = definePluginStorageCollection("published_descriptor", { | ||
| 24 | Type: {} as { | ||
| 25 | readonly scope: string; | ||
| 26 | readonly snapshotId: string; | ||
| 27 | readonly descriptor: AppDescriptor; | ||
| 28 | readonly publishedAt: number; | ||
| 29 | }, | ||
| 30 | }); | ||
| 31 | |||
| 32 | export interface AppsStore { | ||
| 33 | /** Persist the published descriptor for a scope (the published pointer). */ | ||
| 34 | readonly putDescriptor: ( | ||
| 35 | owner: "org" | "user", | ||
| 36 | descriptor: AppDescriptor, | ||
| 37 | ) => Effect.Effect<void, StorageFailure>; | ||
| 38 | /** Read the current published descriptor for a scope, or null. */ | ||
| 39 | readonly getDescriptor: (scope: string) => Effect.Effect<AppDescriptor | null, StorageFailure>; | ||
| 40 | /** Store a compiled bundle / skill body blob (content-addressed). */ | ||
| 41 | readonly putBlob: (key: string, value: string) => Effect.Effect<void, StorageFailure>; | ||
| 42 | /** Read a blob by key. */ | ||
| 43 | readonly getBlob: (key: string) => Effect.Effect<string | null, StorageFailure>; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface AppsStoreDeps { | ||
| 47 | readonly pluginStorage: PluginStorageFacade; | ||
| 48 | readonly blobs: { | ||
| 49 | readonly get: (key: string) => Effect.Effect<string | null, StorageFailure>; | ||
| 50 | readonly put: ( | ||
| 51 | key: string, | ||
| 52 | value: string, | ||
| 53 | options: { readonly owner: "org" | "user" }, | ||
| 54 | ) => Effect.Effect<void, StorageFailure>; | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | |||
| 58 | export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { | ||
| 59 | const descriptors = deps.pluginStorage.collection(descriptorCollection); | ||
| 60 | return { | ||
| 61 | putDescriptor: (owner, descriptor) => | ||
| 62 | descriptors | ||
| 63 | .put({ | ||
| 64 | owner, | ||
| 65 | key: descriptor.scope, | ||
| 66 | data: { | ||
| 67 | scope: descriptor.scope, | ||
| 68 | snapshotId: descriptor.snapshotId, | ||
| 69 | descriptor, | ||
| 70 | publishedAt: Date.now(), | ||
| 71 | }, | ||
| 72 | }) | ||
| 73 | .pipe(Effect.asVoid), | ||
| 74 | getDescriptor: (scope) => | ||
| 75 | descriptors.get({ key: scope }).pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), | ||
| 76 | putBlob: (key, value) => deps.blobs.put(key, value, { owner: "org" }), | ||
| 77 | getBlob: (key) => deps.blobs.get(key), | ||
| 78 | }; | ||
| 79 | }; | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | |||
| 3 | import type { AppDescriptor } from "../pipeline/descriptor"; | ||
| 4 | import type { AppsStore } from "../plugin/store"; | ||
| 5 | import type { ClientResolver, BindingError } from "../plugin/bindings"; | ||
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // Test helpers: an in-memory AppsStore and a canned ClientResolver, plus the | ||
| 9 | // daily-brief fixture set. Used by the runtime integration test and the e2e. | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | export * from "./daily-brief"; | ||
| 13 | |||
| 14 | /** In-memory AppsStore (descriptors + blobs in Maps). */ | ||
| 15 | export const makeInMemoryAppsStore = (): AppsStore & { | ||
| 16 | readonly blobs: Map<string, string>; | ||
| 17 | readonly descriptors: Map<string, AppDescriptor>; | ||
| 18 | } => { | ||
| 19 | const descriptors = new Map<string, AppDescriptor>(); | ||
| 20 | const blobs = new Map<string, string>(); | ||
| 21 | return { | ||
| 22 | descriptors, | ||
| 23 | blobs, | ||
| 24 | putDescriptor: (_owner, descriptor) => | ||
| 25 | Effect.sync(() => void descriptors.set(descriptor.scope, descriptor)), | ||
| 26 | getDescriptor: (scope) => Effect.sync(() => descriptors.get(scope) ?? null), | ||
| 27 | putBlob: (key, value) => Effect.sync(() => void blobs.set(key, value)), | ||
| 28 | getBlob: (key) => Effect.sync(() => blobs.get(key) ?? null), | ||
| 29 | }; | ||
| 30 | }; | ||
| 31 | |||
| 32 | /** A resolver that dispatches integration method calls to supplied handlers. | ||
| 33 | * `handlers[integration][path.join(".")]` returns the JSON result. */ | ||
| 34 | export const makeTestResolver = ( | ||
| 35 | handlers: Record<string, Record<string, (args: readonly unknown[]) => unknown>>, | ||
| 36 | ): ClientResolver & { | ||
| 37 | readonly calls: { integration: string; connection: string; method: string }[]; | ||
| 38 | } => { | ||
| 39 | const calls: { integration: string; connection: string; method: string }[] = []; | ||
| 40 | return { | ||
| 41 | calls, | ||
| 42 | call: ({ integration, connection, path, args }) => { | ||
| 43 | const method = path.join("."); | ||
| 44 | calls.push({ integration, connection, method }); | ||
| 45 | const handler = handlers[integration]?.[method]; | ||
| 46 | if (!handler) { | ||
| 47 | return Effect.fail({ | ||
| 48 | _tag: "BindingError", | ||
| 49 | message: `no test handler for ${integration}.${method}`, | ||
| 50 | role: integration, | ||
| 51 | surface: integration, | ||
| 52 | } as unknown as BindingError); | ||
| 53 | } | ||
| 54 | return Effect.sync(() => handler(args)); | ||
| 55 | }, | ||
| 56 | }; | ||
| 57 | }; | ||