Unified · Split
| @@ -1,10 +1,7 @@ | ||
| 1 | 1 | import { join } from "node:path"; |
| 2 | 2 | |
| 3 | −import { Effect } from "effect"; | |
| 4 | − | |
| 5 | 3 | import { |
| 6 | 4 | makeGitArtifactStore, |
| 7 | − makeLibsqlScopeDb, | |
| 8 | 5 | makeQuickjsToolSandbox, |
| 9 | 6 | makeSqliteAppsStore, |
| 10 | 7 | type AppsBackings, |
| @@ -19,19 +16,13 @@ interface SelfHostAppsBackings { · 8 unmodified lines | ||
| 19 | 16 | |
| 20 | 17 | const createBackings = (dataDir: string): SelfHostAppsBackings => { |
| 21 | 18 | const appsDir = join(dataDir, "apps"); |
| 22 | − const scopeDb = makeLibsqlScopeDb({ | |
| 23 | − root: join(appsDir, "scope-db"), | |
| 24 | − }); | |
| 25 | 19 | return { |
| 26 | 20 | backings: { |
| 27 | 21 | artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }), |
| 28 | − scopeDb, | |
| 29 | 22 | sandbox: makeQuickjsToolSandbox(), |
| 30 | 23 | store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }), |
| 31 | 24 | }, |
| 32 | − close: async () => { | |
| 33 | − await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | |
| 34 | − }, | |
| 25 | + close: async () => {}, | |
| 35 | 26 | }; |
| 36 | 27 | }; |
| 37 | 28 | |
| @@ -28,7 +28,6 @@ export { · 27 unmodified lines | ||
| 28 | 28 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; |
| 29 | 29 | export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings"; |
| 30 | 30 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; |
| 31 | −export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | |
| 32 | 31 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; |
| 33 | 32 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 34 | 33 | export { |
| @@ -38,6 +37,7 @@ export { · 3 unmodified lines | ||
| 38 | 37 | type RoleBindings, |
| 39 | 38 | } from "./plugin/bindings"; |
| 40 | 39 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; |
| 40 | +export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin"; | |
| 41 | 41 | export { |
| 42 | 42 | fetchGitHubSource, |
| 43 | 43 | parseGitHubSourceUrl, |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import type { ScopeDbHandle } from "./seams/scope-db"; | |
| 2 | 1 | import type { StandardSchemaV1 } from "./standard-schema"; |
| 3 | 2 | |
| 4 | 3 | export type JsonPrimitive = string | number | boolean | null; |
| @@ -16,8 +15,6 @@ export interface IntegrationDeclaration<Slug extends string = string> { · 11 unmodified lines | ||
| 16 | 15 | export type ToolHandlerContext< |
| 17 | 16 | TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined, |
| 18 | 17 | > = { |
| 19 | − readonly db: ScopeDbHandle; | |
| 20 | −} & { | |
| 21 | 18 | readonly [K in keyof NonNullable<TIntegrations>]: unknown; |
| 22 | 19 | }; |
| 23 | 20 | |
| @@ -1,4 +0,0 @@ | ||
| 1 | −import { scopeDbConformance } from "../seams/scope-db.conformance"; | |
| 2 | −import { makeLibsqlScopeDb } from "./libsql-scope-db"; | |
| 3 | − | |
| 4 | −scopeDbConformance("libsql (in-memory)", () => makeLibsqlScopeDb({ root: ":memory:" })); | |
| @@ -1,187 +0,0 @@ | ||
| 1 | −import { mkdirSync, rmSync } from "node:fs"; | |
| 2 | −import { join, resolve } from "node:path"; | |
| 3 | − | |
| 4 | −import { createClient, type Client } from "@libsql/client"; | |
| 5 | −import { Effect } from "effect"; | |
| 6 | − | |
| 7 | −import { ScopeDbError, type ScopeDb, type ScopeDbHandle } from "../seams/scope-db"; | |
| 8 | −import { scopeAddressStorageKey, type ScopeAddress } from "../seams/scope-address"; | |
| 9 | − | |
| 10 | −// --------------------------------------------------------------------------- | |
| 11 | −// libSQL-backed ScopeDb (self-hosted). One SQLite file per tenant/scope under | |
| 12 | −// `<root>`. A control table `__versions` holds a monotonic counter per user | |
| 13 | −// table; every write statement bumps the counters for the tables it touched. | |
| 14 | −// Scope isolation is a separate file per tenant/scope, there is no cross-scope | |
| 15 | −// query path. | |
| 16 | −// --------------------------------------------------------------------------- | |
| 17 | − | |
| 18 | −const VERSION_TABLE = "__scope_versions"; | |
| 19 | − | |
| 20 | −// Statements that mutate data. Determines whether a `sql` call bumps versions. | |
| 21 | −const WRITE_RE = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i; | |
| 22 | − | |
| 23 | −// Extract the table names a write statement targets (best-effort: covers the | |
| 24 | −// shapes authored tools produce — INSERT INTO x, UPDATE x, DELETE FROM x, | |
| 25 | −// CREATE TABLE x, REPLACE INTO x). | |
| 26 | −const targetsOf = (sql: string): string[] => { | |
| 27 | − const out = new Set<string>(); | |
| 28 | − const patterns = [ | |
| 29 | − /\binsert\s+(?:or\s+\w+\s+)?into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 30 | − /\breplace\s+into\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 31 | − /\bupdate\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 32 | − /\bdelete\s+from\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 33 | − /\bcreate\s+table\s+(?:if\s+not\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 34 | − /\bdrop\s+table\s+(?:if\s+exists\s+)?["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 35 | − /\balter\s+table\s+["'`]?([a-zA-Z_][a-zA-Z0-9_]*)["'`]?/gi, | |
| 36 | − ]; | |
| 37 | − for (const re of patterns) { | |
| 38 | − let m: RegExpExecArray | null; | |
| 39 | − while ((m = re.exec(sql)) !== null) { | |
| 40 | − const name = m[1]; | |
| 41 | − if (name && name !== VERSION_TABLE) out.add(name); | |
| 42 | − } | |
| 43 | − } | |
| 44 | − return [...out]; | |
| 45 | −}; | |
| 46 | − | |
| 47 | −const toArgs = (values: readonly unknown[]): unknown[] => | |
| 48 | − values.map((v) => (v === undefined ? null : v)); | |
| 49 | − | |
| 50 | −const makeHandle = (address: ScopeAddress, client: Client): ScopeDbHandle => { | |
| 51 | − const label = `${address.tenant}/${address.scope}`; | |
| 52 | − const ensureVersionTable = async () => { | |
| 53 | − await client.execute( | |
| 54 | − `CREATE TABLE IF NOT EXISTS ${VERSION_TABLE} (name TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0)`, | |
| 55 | − ); | |
| 56 | − }; | |
| 57 | − | |
| 58 | − const bump = async (tables: readonly string[]): Promise<{ table: string; version: number }[]> => { | |
| 59 | − const bumped: { table: string; version: number }[] = []; | |
| 60 | − for (const table of tables) { | |
| 61 | − await client.execute({ | |
| 62 | − sql: `INSERT INTO ${VERSION_TABLE} (name, version) VALUES (?, 1) | |
| 63 | − ON CONFLICT(name) DO UPDATE SET version = version + 1`, | |
| 64 | − args: [table], | |
| 65 | − }); | |
| 66 | − const row = await client.execute({ | |
| 67 | − sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, | |
| 68 | − args: [table], | |
| 69 | − }); | |
| 70 | − bumped.push({ table, version: Number(row.rows[0]?.version ?? 0) }); | |
| 71 | − } | |
| 72 | − return bumped; | |
| 73 | − }; | |
| 74 | − | |
| 75 | − const runStatement = <Row>( | |
| 76 | − sql: string, | |
| 77 | − args: unknown[], | |
| 78 | − ): Effect.Effect<readonly Row[], ScopeDbError> => | |
| 79 | − Effect.tryPromise({ | |
| 80 | − try: async () => { | |
| 81 | − await ensureVersionTable(); | |
| 82 | − const result = await client.execute({ sql, args: args as never }); | |
| 83 | − if (WRITE_RE.test(sql)) { | |
| 84 | − const targets = targetsOf(sql); | |
| 85 | − if (targets.length > 0) { | |
| 86 | − await bump(targets); | |
| 87 | − } | |
| 88 | − } | |
| 89 | − return result.rows.map((row) => row as Row); | |
| 90 | − }, | |
| 91 | − catch: (cause) => | |
| 92 | − new ScopeDbError({ message: `scope-db statement failed for scope ${label}`, cause }), | |
| 93 | − }); | |
| 94 | − | |
| 95 | − return { | |
| 96 | − sql: <Row = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]) => { | |
| 97 | − const sql = strings.reduce((acc, part, i) => acc + part + (i < values.length ? "?" : ""), ""); | |
| 98 | − return runStatement<Row>(sql, toArgs(values)); | |
| 99 | − }, | |
| 100 | − exec: <Row = Record<string, unknown>>(sql: string, params: readonly unknown[] = []) => | |
| 101 | − runStatement<Row>(sql, toArgs(params)), | |
| 102 | − tableVersion: (table: string) => | |
| 103 | − Effect.tryPromise({ | |
| 104 | − try: async () => { | |
| 105 | − await ensureVersionTable(); | |
| 106 | − const row = await client.execute({ | |
| 107 | − sql: `SELECT version FROM ${VERSION_TABLE} WHERE name = ?`, | |
| 108 | − args: [table], | |
| 109 | − }); | |
| 110 | − return Number(row.rows[0]?.version ?? 0); | |
| 111 | − }, | |
| 112 | − catch: (cause) => new ScopeDbError({ message: "tableVersion failed", cause }), | |
| 113 | − }), | |
| 114 | − versions: () => | |
| 115 | − Effect.tryPromise({ | |
| 116 | − try: async () => { | |
| 117 | − await ensureVersionTable(); | |
| 118 | − const rows = await client.execute(`SELECT name, version FROM ${VERSION_TABLE}`); | |
| 119 | − const out = new Map<string, number>(); | |
| 120 | − for (const r of rows.rows) out.set(String(r.name), Number(r.version)); | |
| 121 | − return out as ReadonlyMap<string, number>; | |
| 122 | − }, | |
| 123 | − catch: (cause) => new ScopeDbError({ message: "versions failed", cause }), | |
| 124 | − }), | |
| 125 | − }; | |
| 126 | −}; | |
| 127 | − | |
| 128 | −export interface LibsqlScopeDbOptions { | |
| 129 | − /** Directory holding one SQLite file per tenant/scope, or ":memory:" for tests. */ | |
| 130 | − readonly root: string; | |
| 131 | −} | |
| 132 | − | |
| 133 | −const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); | |
| 134 | − | |
| 135 | −export const makeLibsqlScopeDb = (options: LibsqlScopeDbOptions): ScopeDb => { | |
| 136 | − const clients = new Map<string, Client>(); | |
| 137 | − | |
| 138 | − const clientFor = (address: ScopeAddress): Client => { | |
| 139 | − const key = scopeAddressStorageKey(address); | |
| 140 | − let client = clients.get(key); | |
| 141 | − if (!client) { | |
| 142 | − if (options.root === ":memory:") { | |
| 143 | − client = createClient({ url: ":memory:" }); | |
| 144 | − } else { | |
| 145 | − mkdirSync(options.root, { recursive: true }); | |
| 146 | − client = createClient({ url: toUrl(join(options.root, `${key}.db`)) }); | |
| 147 | − } | |
| 148 | − clients.set(key, client); | |
| 149 | − } | |
| 150 | − return client; | |
| 151 | − }; | |
| 152 | − | |
| 153 | − return { | |
| 154 | − forScope: (address) => | |
| 155 | − Effect.try({ | |
| 156 | − try: () => makeHandle(address, clientFor(address)), | |
| 157 | − catch: (cause) => | |
| 158 | − new ScopeDbError({ | |
| 159 | − message: `failed to open scope db ${address.tenant}/${address.scope}`, | |
| 160 | − cause, | |
| 161 | − }), | |
| 162 | − }), | |
| 163 | − removeScope: (address) => | |
| 164 | − Effect.try({ | |
| 165 | − try: () => { | |
| 166 | − const key = scopeAddressStorageKey(address); | |
| 167 | − clients.get(key)?.close(); | |
| 168 | − clients.delete(key); | |
| 169 | − if (options.root === ":memory:") return; | |
| 170 | − const dbPath = join(options.root, `${key}.db`); | |
| 171 | − for (const path of [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]) { | |
| 172 | − rmSync(path, { force: true }); | |
| 173 | − } | |
| 174 | − }, | |
| 175 | − catch: (cause) => | |
| 176 | − new ScopeDbError({ | |
| 177 | − message: `failed to remove scope db ${address.tenant}/${address.scope}`, | |
| 178 | − cause, | |
| 179 | − }), | |
| 180 | − }), | |
| 181 | − close: () => | |
| 182 | − Effect.sync(() => { | |
| 183 | − for (const client of clients.values()) client.close(); | |
| 184 | − clients.clear(); | |
| 185 | − }), | |
| 186 | − }; | |
| 187 | −}; | |
| @@ -31,7 +31,7 @@ describe("QuickJS ToolSandbox", () => { · 30 unmodified lines | ||
| 31 | 31 | expect(descriptor.inputJsonSchema.properties).toHaveProperty("since"); |
| 32 | 32 | }); |
| 33 | 33 | |
| 34 | − it("invokes a tool, routing injected-client + db calls through the bridge", async () => { | |
| 34 | + it("invokes a tool, routing injected-client calls through the bridge", async () => { | |
| 35 | 35 | const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); |
| 36 | 36 | const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); |
| 37 | 37 | |
| @@ -56,7 +56,6 @@ describe("QuickJS ToolSandbox", () => { · 18 unmodified lines | ||
| 56 | 56 | }, |
| 57 | 57 | ]; |
| 58 | 58 | } |
| 59 | − // db.sql calls return empty rows | |
| 60 | 59 | return []; |
| 61 | 60 | }), |
| 62 | 61 | }; |
| @@ -68,18 +67,43 @@ describe("QuickJS ToolSandbox", () => { · 5 unmodified lines | ||
| 68 | 67 | artifact: "issues-sync", |
| 69 | 68 | kind: "tool", |
| 70 | 69 | input: {}, |
| 71 | − roots: { github: { kind: "single" }, db: { kind: "single" } }, | |
| 70 | + roots: { github: { kind: "single" } }, | |
| 72 | 71 | }, |
| 73 | 72 | bridge, |
| 74 | 73 | ), |
| 75 | 74 | ); |
| 76 | 75 | |
| 77 | − expect(result.output).toEqual({ synced: 1, repos: 1 }); | |
| 78 | − // The db write went through the bridge (root "db"). | |
| 79 | − expect(calls.some((c) => c.root === "db")).toBe(true); | |
| 76 | + expect(result.output).toEqual({ | |
| 77 | + synced: 1, | |
| 78 | + repos: 1, | |
| 79 | + issues: [{ repo: "acme/app", number: 1, title: "Bug" }], | |
| 80 | + }); | |
| 80 | 81 | expect(calls.some((c) => c.root === "github")).toBe(true); |
| 81 | 82 | }); |
| 82 | 83 | |
| 84 | + it("reports storage as unavailable when an old handler calls db.sql", async () => { | |
| 85 | + const source = `import { defineTool } from "executor:app"; | |
| 86 | +import { z } from "zod"; | |
| 87 | +export default defineTool({ | |
| 88 | + description: "legacy storage", | |
| 89 | + input: z.object({}), | |
| 90 | + async handler(_input, { db }) { | |
| 91 | + await db.sql\`SELECT 1\`; | |
| 92 | + return { ok: true }; | |
| 93 | + }, | |
| 94 | +});`; | |
| 95 | + const files = new Map([["tools/storage.ts", source]]); | |
| 96 | + const { code } = await run(bundleEntry({ files, entry: "tools/storage.ts" })); | |
| 97 | + const bridge: HandleBridge = { call: () => Effect.succeed(null) }; | |
| 98 | + | |
| 99 | + const exit = await Effect.runPromiseExit( | |
| 100 | + sandbox.invoke(code, { artifact: "storage", kind: "tool", input: {}, roots: {} }, bridge), | |
| 101 | + ); | |
| 102 | + | |
| 103 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 104 | + expect(JSON.stringify(exit)).toContain("storage is not available yet"); | |
| 105 | + }); | |
| 106 | + | |
| 83 | 107 | it("surfaces Standard Schema validation issues before the handler runs", async () => { |
| 84 | 108 | const source = `import { defineTool } from "executor:app"; |
| 85 | 109 | import { z } from "zod"; |
| @@ -29,6 +29,8 @@ import { stableStringify } from "../pipeline/descriptor"; · 28 unmodified lines | ||
| 29 | 29 | // `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the |
| 30 | 30 | // QuickJS runtime already provides (`tools`/`__invokeTool`). Our |
| 31 | 31 | // `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`. |
| 32 | +// The old `db` handle is intentionally unavailable in v1; handlers that still | |
| 33 | +// try it get a clear error before any host storage call exists. | |
| 32 | 34 | // Everything crossing is JSON (the cloud version is RPC), so the interface |
| 33 | 35 | // stays honest. |
| 34 | 36 | // |
| @@ -63,6 +65,16 @@ function __mkHandle(root, prefix) { · 28 unmodified lines | ||
| 63 | 65 | } |
| 64 | 66 | }); |
| 65 | 67 | } |
| 68 | +function __unavailableStorage() { | |
| 69 | + return new Proxy({}, { | |
| 70 | + get: function() { | |
| 71 | + return function() { throw new Error('storage is not available yet'); }; | |
| 72 | + }, | |
| 73 | + apply: function() { | |
| 74 | + throw new Error('storage is not available yet'); | |
| 75 | + } | |
| 76 | + }); | |
| 77 | +} | |
| 66 | 78 | var __executorApp = { |
| 67 | 79 | integration: function(integration) { return { integration: integration }; }, |
| 68 | 80 | defineTool: function(def) { __defs.tool = def; return def; }, |
| @@ -167,6 +179,16 @@ return await (async () => { · 98 unmodified lines | ||
| 167 | 179 | }); |
| 168 | 180 | }; |
| 169 | 181 | var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); |
| 182 | + if (def && typeof def.handler === 'function') { | |
| 183 | + var handlerSource = Function.prototype.toString.call(def.handler); | |
| 184 | + if (/\\bdb\\b/.test(handlerSource)) { | |
| 185 | + fail('StorageUnavailableError', { | |
| 186 | + tool: artifactName, | |
| 187 | + field: 'handler', | |
| 188 | + message: 'tool "' + artifactName + '" uses storage, but storage is not available yet', | |
| 189 | + }); | |
| 190 | + } | |
| 191 | + } | |
| 170 | 192 | var integrations = {}; |
| 171 | 193 | if (def && def.integrations) { |
| 172 | 194 | for (var k in def.integrations) { |
| @@ -226,6 +248,7 @@ return await (async () => { · 53 unmodified lines | ||
| 226 | 248 | if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind); |
| 227 | 249 | injected[name] = __mkHandle(name, []); |
| 228 | 250 | } |
| 251 | + injected.db = __unavailableStorage(); | |
| 229 | 252 | var input = await validateWithStandardSchema('input', def.input, ${inputLiteral}); |
| 230 | 253 | var out = await def.handler(input, injected); |
| 231 | 254 | out = await validateWithStandardSchema('output', def.output, out); |
| @@ -267,7 +290,10 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 35 unmodified lines | ||
| 267 | 290 | Effect.flatMap((result) => { |
| 268 | 291 | if (result.error) { |
| 269 | 292 | const marker = parseMarker(result.error); |
| 270 | − if (isMarker(marker, "SchemaExportError")) { | |
| 293 | + if ( | |
| 294 | + isMarker(marker, "SchemaExportError") || | |
| 295 | + isMarker(marker, "StorageUnavailableError") | |
| 296 | + ) { | |
| 271 | 297 | return Effect.fail( |
| 272 | 298 | new ToolSandboxError({ |
| 273 | 299 | kind: "collect", |
| @@ -1,52 +1,18 @@ | ||
| 1 | −import { mkdtempSync, readdirSync } from "node:fs"; | |
| 2 | −import { tmpdir } from "node:os"; | |
| 3 | −import { join } from "node:path"; | |
| 4 | − | |
| 5 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 2 | import { Effect } from "effect"; |
| 7 | 3 | |
| 8 | −import { makeLibsqlScopeDb } from "./libsql-scope-db"; | |
| 9 | 4 | import { makeSqliteAppsStore } from "./sqlite-apps-store"; |
| 10 | 5 | import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor"; |
| 11 | −import { scopeAddress } from "../seams/scope-address"; | |
| 12 | 6 | |
| 13 | 7 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| 14 | 8 | |
| 15 | 9 | // --------------------------------------------------------------------------- |
| 16 | −// Finding 9 regression: scopes that differ only by a character the old naming | |
| 17 | −// collapsed ("my-scope" vs "my_scope") must NOT share a database, and the | |
| 18 | −// connection->scope mapping must route each to its own scope. Before the fix the | |
| 19 | −// scope-db filename collapsed `[^A-Za-z0-9._-]` to `_` (colliding two scopes onto | |
| 20 | −// one file) and the connection name was reverse-parsed (colliding two scopes onto | |
| 21 | −// one normalized identifier). | |
| 10 | +// Finding 9 regression: scopes that differ only by a character an old naming | |
| 11 | +// scheme collapsed ("my-scope" vs "my_scope") must not share descriptor | |
| 12 | +// storage. | |
| 22 | 13 | // --------------------------------------------------------------------------- |
| 23 | 14 | |
| 24 | 15 | describe("scope collision (Fix 9)", () => { |
| 25 | − it("gives my-scope and my_scope DISTINCT scope-db files with isolated data", async () => { | |
| 26 | − const root = mkdtempSync(join(tmpdir(), "apps-scopecol-")); | |
| 27 | − const db = makeLibsqlScopeDb({ root }); | |
| 28 | − | |
| 29 | − const a = await run(db.forScope(scopeAddress("org", "my-scope"))); | |
| 30 | − await run(a.exec("CREATE TABLE t (v TEXT)")); | |
| 31 | − await run(a.exec("INSERT INTO t (v) VALUES ('a')")); | |
| 32 | − | |
| 33 | − const b = await run(db.forScope(scopeAddress("org", "my_scope"))); | |
| 34 | − await run(b.exec("CREATE TABLE t (v TEXT)")); | |
| 35 | − await run(b.exec("INSERT INTO t (v) VALUES ('b')")); | |
| 36 | − | |
| 37 | − // Each scope sees ONLY its own row (no shared file). | |
| 38 | − const aRows = await run(a.exec<{ v: string }>("SELECT v FROM t")); | |
| 39 | − const bRows = await run(b.exec<{ v: string }>("SELECT v FROM t")); | |
| 40 | − expect(aRows.map((r) => r.v)).toEqual(["a"]); | |
| 41 | − expect(bRows.map((r) => r.v)).toEqual(["b"]); | |
| 42 | − | |
| 43 | − // Two distinct .db files exist on disk. | |
| 44 | − const dbFiles = readdirSync(root).filter((f) => f.endsWith(".db")); | |
| 45 | − expect(dbFiles.length).toBe(2); | |
| 46 | − | |
| 47 | − await run(db.close()); | |
| 48 | − }); | |
| 49 | − | |
| 50 | 16 | it("stores distinct descriptor scopes independently", async () => { |
| 51 | 17 | const store = makeSqliteAppsStore({ path: ":memory:" }); |
| 52 | 18 | const descriptor = (scope: string): AppDescriptor => ({ |
| @@ -52,6 +52,7 @@ export { · 51 unmodified lines | ||
| 52 | 52 | type BindingContext, |
| 53 | 53 | } from "./plugin/bindings"; |
| 54 | 54 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; |
| 55 | +export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin"; | |
| 55 | 56 | export { |
| 56 | 57 | makeSelfHostAppsRuntime, |
| 57 | 58 | type SelfHostAppsRuntime, |
| @@ -60,6 +61,5 @@ export { · 2 unmodified lines | ||
| 60 | 61 | |
| 61 | 62 | // Self-host seam backings. |
| 62 | 63 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; |
| 63 | −export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | |
| 64 | 64 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; |
| 65 | 65 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| @@ -94,6 +94,34 @@ export default defineTool({ · 93 unmodified lines | ||
| 94 | 94 | expect(JSON.stringify(exit)).toContain("crm"); |
| 95 | 95 | }); |
| 96 | 96 | |
| 97 | + it("rejects tools that reference storage", async () => { | |
| 98 | + const deps = makeDeps(); | |
| 99 | + const files = new Map<string, string>([ | |
| 100 | + [ | |
| 101 | + "tools/storage.ts", | |
| 102 | + `import { defineTool } from "executor:app"; | |
| 103 | +import { z } from "zod"; | |
| 104 | +export default defineTool({ | |
| 105 | + description: "storage", | |
| 106 | + input: z.object({}), | |
| 107 | + async handler(_input, { db }) { | |
| 108 | + await db.sql\`SELECT 1\`; | |
| 109 | + return { ok: true }; | |
| 110 | + }, | |
| 111 | +});`, | |
| 112 | + ], | |
| 113 | + ]); | |
| 114 | + | |
| 115 | + const exit = await Effect.runPromiseExit(publish(deps, { scope: "s", files })); | |
| 116 | + | |
| 117 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 118 | + expect(JSON.stringify(exit)).toContain("storage is not available yet"); | |
| 119 | + const latest = await run( | |
| 120 | + Effect.flatMap(deps.artifactStore.forScope(scopeAddress("org", "s")), (s) => s.latest()), | |
| 121 | + ); | |
| 122 | + expect(latest).toBeNull(); | |
| 123 | + }); | |
| 124 | + | |
| 97 | 125 | it("rejects a Standard Schema vendor without JSON Schema export", async () => { |
| 98 | 126 | const deps = makeDeps(); |
| 99 | 127 | const files = new Map<string, string>([ |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { mkdtempSync, readFileSync } from "node:fs"; | |
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | 2 | import { tmpdir } from "node:os"; |
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| @@ -7,27 +7,13 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 7 | 7 | import { IntegrationSlug } from "@executor-js/sdk"; |
| 8 | 8 | import { makeTestExecutor } from "@executor-js/sdk/testing"; |
| 9 | 9 | |
| 10 | −import { appsPlugin } from "./apps-plugin"; | |
| 10 | +import { SourceOriginError, appsPlugin, assertSourceOrigin } from "./apps-plugin"; | |
| 11 | 11 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 12 | 12 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; |
| 13 | +import { dailyBriefFileSet } from "../testing/daily-brief"; | |
| 13 | 14 | |
| 14 | 15 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| 15 | 16 | |
| 16 | −const PROTOTYPE_ROOT = "/Users/rhyssullivan/agent-workspace/prototypes/custom-tools"; | |
| 17 | − | |
| 18 | −const prototypeFileSet = (): Map<string, string> => | |
| 19 | − new Map<string, string>([ | |
| 20 | − ["executor.json", readFileSync(join(PROTOTYPE_ROOT, "executor.json"), "utf8")], | |
| 21 | − [ | |
| 22 | − "tools/deal-pipeline-sync.ts", | |
| 23 | − readFileSync(join(PROTOTYPE_ROOT, "tools/deal-pipeline-sync.ts"), "utf8"), | |
| 24 | − ], | |
| 25 | − [ | |
| 26 | − "tools/find-deal-docs.ts", | |
| 27 | − readFileSync(join(PROTOTYPE_ROOT, "tools/find-deal-docs.ts"), "utf8"), | |
| 28 | − ], | |
| 29 | − ]); | |
| 30 | − | |
| 31 | 17 | describe("appsPlugin custom-tools contract", () => { |
| 32 | 18 | it.effect("detects GitHub repo URLs for console auto-detect", () => |
| 33 | 19 | Effect.gen(function* () { |
| @@ -77,30 +63,41 @@ describe("appsPlugin custom-tools contract", () => { · 43 unmodified lines | ||
| 77 | 63 | }), |
| 78 | 64 | ); |
| 79 | 65 | |
| 80 | − it("round-trips prototype files through publish, resolveTools, and invokeTool", async () => { | |
| 81 | − let dealListArgs: unknown; | |
| 66 | + it.effect("rejects publishing through a different source door for a GitHub-managed app", () => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + const failure = yield* Effect.flip(assertSourceOrigin("github", "mcp")); | |
| 69 | + | |
| 70 | + expect(failure).toBeInstanceOf(SourceOriginError); | |
| 71 | + expect(failure.message).toBe("this app is managed by its GitHub repo"); | |
| 72 | + expect(failure.existingOrigin).toBe("github"); | |
| 73 | + expect(failure.requestedOrigin).toBe("mcp"); | |
| 74 | + }), | |
| 75 | + ); | |
| 76 | + | |
| 77 | + it("round-trips custom tool files through publish, resolveTools, and invokeTool", async () => { | |
| 78 | + let issueListArgs: unknown; | |
| 82 | 79 | const resolver = makeTestResolver( |
| 83 | 80 | { |
| 84 | − dealcloud: { | |
| 85 | − "deals.list": (args) => { | |
| 86 | − dealListArgs = args[0]; | |
| 87 | − return []; | |
| 81 | + github: { | |
| 82 | + "issues.listForRepo": (args) => { | |
| 83 | + issueListArgs = args[0]; | |
| 84 | + return [{ number: 7, title: "Renewal diligence" }]; | |
| 88 | 85 | }, |
| 89 | 86 | }, |
| 90 | − "microsoft-sharepoint": { | |
| 91 | − "search.query": () => [], | |
| 87 | + gmail: { | |
| 88 | + "messages.search": () => ({ messages: [] }), | |
| 92 | 89 | }, |
| 93 | 90 | }, |
| 94 | 91 | [ |
| 95 | 92 | { |
| 96 | − address: "tools.dealcloud.user.crm-main", | |
| 97 | − integration: "dealcloud", | |
| 98 | − name: "crm-main", | |
| 93 | + address: "tools.github.user.github-main", | |
| 94 | + integration: "github", | |
| 95 | + name: "github-main", | |
| 99 | 96 | }, |
| 100 | 97 | { |
| 101 | − address: "tools.microsoft-sharepoint.user.sharepoint-main", | |
| 102 | − integration: "microsoft-sharepoint", | |
| 103 | − name: "sharepoint-main", | |
| 98 | + address: "tools.gmail.user.inbox-main", | |
| 99 | + integration: "gmail", | |
| 100 | + name: "inbox-main", | |
| 104 | 101 | }, |
| 105 | 102 | ], |
| 106 | 103 | ); |
| @@ -114,6 +111,7 @@ describe("appsPlugin custom-tools contract", () => { · 7 unmodified lines | ||
| 114 | 111 | const plugin = appsPlugin({ backings: host.backings }); |
| 115 | 112 | const appIntegration = IntegrationSlug.make("rhys-tools"); |
| 116 | 113 | const appConfig = { |
| 114 | + origin: "github", | |
| 117 | 115 | kind: "github", |
| 118 | 116 | repoUrl: "https://github.com/rhys/tools", |
| 119 | 117 | repo: "rhys/tools", |
| @@ -142,7 +140,7 @@ describe("appsPlugin custom-tools contract", () => { · 22 unmodified lines | ||
| 142 | 140 | }, |
| 143 | 141 | }; |
| 144 | 142 | |
| 145 | − await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() })); | |
| 143 | + await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() })); | |
| 146 | 144 | |
| 147 | 145 | const resolved = await run( |
| 148 | 146 | plugin.resolveTools!({ |
| @@ -151,18 +149,18 @@ describe("appsPlugin custom-tools contract", () => { · 2 unmodified lines | ||
| 151 | 149 | connection: { name: "main" }, |
| 152 | 150 | } as never), |
| 153 | 151 | ); |
| 154 | − const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync"); | |
| 152 | + const syncTool = resolved.tools.find((tool) => String(tool.name) === "issues-sync"); | |
| 155 | 153 | expect(syncTool).toBeTruthy(); |
| 156 | 154 | const persistedInputSchema = syncTool!.inputSchema as { |
| 157 | 155 | properties: Record<string, unknown>; |
| 158 | 156 | }; |
| 159 | − expect(persistedInputSchema.properties.crm).toBeUndefined(); | |
| 157 | + expect(persistedInputSchema.properties.github).toBeUndefined(); | |
| 160 | 158 | |
| 161 | 159 | const projected = await run( |
| 162 | 160 | plugin.projectToolSchema!({ |
| 163 | 161 | ctx, |
| 164 | 162 | toolRow: { |
| 165 | − name: "deal-pipeline-sync", | |
| 163 | + name: "issues-sync", | |
| 166 | 164 | integration: appIntegration, |
| 167 | 165 | connection: "main", |
| 168 | 166 | }, |
| @@ -174,31 +172,38 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines | ||
| 174 | 172 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; |
| 175 | 173 | required?: string[]; |
| 176 | 174 | }; |
| 177 | − expect(inputSchema.properties.crm.enum).toEqual(["tools.dealcloud.user.crm-main"]); | |
| 178 | − expect(inputSchema.properties.crm.default).toBe("tools.dealcloud.user.crm-main"); | |
| 179 | − expect(inputSchema.properties.crm.description).toBe("Connection to use for crm (dealcloud)"); | |
| 180 | − expect(inputSchema.required ?? []).not.toContain("crm"); | |
| 175 | + expect(inputSchema.properties.github.enum).toEqual(["tools.github.user.github-main"]); | |
| 176 | + expect(inputSchema.properties.github.default).toBe("tools.github.user.github-main"); | |
| 177 | + expect(inputSchema.properties.github.description).toBe("Connection to use for github (github)"); | |
| 178 | + expect(inputSchema.required ?? []).not.toContain("github"); | |
| 181 | 179 | |
| 182 | 180 | const output = await run( |
| 183 | 181 | plugin.invokeTool!({ |
| 184 | 182 | ctx, |
| 185 | 183 | toolRow: { |
| 186 | − name: "deal-pipeline-sync", | |
| 184 | + name: "issues-sync", | |
| 187 | 185 | integration: appIntegration, |
| 188 | 186 | connection: "main", |
| 189 | 187 | }, |
| 190 | 188 | args: { |
| 191 | − crm: "tools.dealcloud.user.crm-main", | |
| 192 | − updatedSince: "2026-01-01T00:00:00Z", | |
| 189 | + github: "tools.github.user.github-main", | |
| 190 | + repos: ["acme/tools"], | |
| 191 | + since: "2026-01-01T00:00:00Z", | |
| 193 | 192 | }, |
| 194 | 193 | } as never), |
| 195 | 194 | ); |
| 196 | 195 | |
| 197 | − expect(output).toEqual({ synced: 0 }); | |
| 198 | − expect(dealListArgs).toEqual({ | |
| 199 | − status: "active", | |
| 200 | − updatedSince: "2026-01-01T00:00:00Z", | |
| 201 | − pageSize: 200, | |
| 196 | + expect(output).toEqual({ | |
| 197 | + synced: 1, | |
| 198 | + repos: 1, | |
| 199 | + issues: [{ repo: "acme/tools", number: 7, title: "Renewal diligence" }], | |
| 200 | + }); | |
| 201 | + expect(issueListArgs).toEqual({ | |
| 202 | + owner: "acme", | |
| 203 | + repo: "tools", | |
| 204 | + state: "open", | |
| 205 | + since: "2026-01-01T00:00:00Z", | |
| 206 | + per_page: 100, | |
| 202 | 207 | }); |
| 203 | 208 | await host.close(); |
| 204 | 209 | }); |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Effect, Result } from "effect"; | |
| 1 | +import { Data, Effect, Result } from "effect"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | AuthTemplateSlug, |
| @@ -24,7 +24,6 @@ import { · 19 unmodified lines | ||
| 24 | 24 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 25 | 25 | import { PublishError } from "../pipeline/discover"; |
| 26 | 26 | import { ArtifactStoreError } from "../seams/artifact-store"; |
| 27 | −import { ScopeDbError } from "../seams/scope-db"; | |
| 28 | 27 | import { ToolSandboxError } from "../seams/tool-sandbox"; |
| 29 | 28 | import { |
| 30 | 29 | parseGitHubSourceUrl, |
| @@ -45,6 +44,7 @@ export const APPS_PLUGIN_ID = "apps"; · 14 unmodified lines | ||
| 45 | 44 | const APP_CONNECTION_NAME = connectionIdentifier("main"); |
| 46 | 45 | |
| 47 | 46 | interface AppsGitHubSourceConfig { |
| 47 | + readonly origin: "github"; | |
| 48 | 48 | readonly kind: "github"; |
| 49 | 49 | readonly repoUrl: string; |
| 50 | 50 | readonly repo: string; |
| @@ -59,6 +59,29 @@ type ResolverFactory = (input: { · 8 unmodified lines | ||
| 59 | 59 | readonly tool: string; |
| 60 | 60 | }) => ClientResolver; |
| 61 | 61 | |
| 62 | +export class SourceOriginError extends Data.TaggedError("SourceOriginError")<{ | |
| 63 | + readonly message: string; | |
| 64 | + readonly existingOrigin: string; | |
| 65 | + readonly requestedOrigin: string; | |
| 66 | +}> {} | |
| 67 | + | |
| 68 | +export const assertSourceOrigin = ( | |
| 69 | + existingOrigin: string, | |
| 70 | + requestedOrigin: string, | |
| 71 | +): Effect.Effect<void, SourceOriginError> => | |
| 72 | + existingOrigin === requestedOrigin | |
| 73 | + ? Effect.void | |
| 74 | + : Effect.fail( | |
| 75 | + new SourceOriginError({ | |
| 76 | + existingOrigin, | |
| 77 | + requestedOrigin, | |
| 78 | + message: | |
| 79 | + existingOrigin === "github" | |
| 80 | + ? "this app is managed by its GitHub repo" | |
| 81 | + : `this app is managed by its ${existingOrigin} source`, | |
| 82 | + }), | |
| 83 | + ); | |
| 84 | + | |
| 62 | 85 | export type AppsPluginOptions = |
| 63 | 86 | | { |
| 64 | 87 | readonly backings: AppsBackings; |
| @@ -171,21 +194,6 @@ const missingRuntime = (): AppsRuntime => ({ · 106 unmodified lines | ||
| 171 | 194 | }), |
| 172 | 195 | ), |
| 173 | 196 | }, |
| 174 | − scopeDb: { | |
| 175 | − forScope: () => | |
| 176 | − Effect.fail( | |
| 177 | − new ScopeDbError({ | |
| 178 | − message: missingBackingsMessage, | |
| 179 | − }), | |
| 180 | − ), | |
| 181 | − removeScope: () => | |
| 182 | − Effect.fail( | |
| 183 | − new ScopeDbError({ | |
| 184 | − message: missingBackingsMessage, | |
| 185 | − }), | |
| 186 | − ), | |
| 187 | − close: () => Effect.void, | |
| 188 | − }, | |
| 189 | 197 | sandbox: { |
| 190 | 198 | collect: () => |
| 191 | 199 | Effect.fail( |
| @@ -221,7 +229,7 @@ const configBaseUrl = (config: unknown): string | undefined => · 29 unmodified lines | ||
| 221 | 229 | : undefined; |
| 222 | 230 | |
| 223 | 231 | const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { |
| 224 | − if (!isRecord(config) || config.kind !== "github") return null; | |
| 232 | + if (!isRecord(config) || config.origin !== "github" || config.kind !== "github") return null; | |
| 225 | 233 | const repoUrl = asString(config.repoUrl); |
| 226 | 234 | const repo = asString(config.repo); |
| 227 | 235 | const scope = asString(config.scope); |
| @@ -235,6 +243,7 @@ const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { · 7 unmodified lines | ||
| 235 | 243 | } |
| 236 | 244 | : undefined; |
| 237 | 245 | return { |
| 246 | + origin: "github", | |
| 238 | 247 | kind: "github", |
| 239 | 248 | repoUrl, |
| 240 | 249 | repo, |
| @@ -440,10 +449,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 199 unmodified lines | ||
| 440 | 449 | Effect.gen(function* () { |
| 441 | 450 | const slug = IntegrationSlug.make(slugValue); |
| 442 | 451 | const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null)); |
| 443 | − const config = record?.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null; | |
| 444 | − if (!record || !config) { | |
| 452 | + if (!record) { | |
| 445 | 453 | return syncFailure(`Custom tools source "${slugValue}" does not exist.`); |
| 446 | 454 | } |
| 455 | + if (record.kind === APPS_PLUGIN_ID && isRecord(record.config)) { | |
| 456 | + const origin = asString(record.config.origin); | |
| 457 | + if (origin) { | |
| 458 | + const originCheck = yield* assertSourceOrigin(origin, "github").pipe(Effect.result); | |
| 459 | + if (Result.isFailure(originCheck)) return syncFailure(originCheck.failure.message); | |
| 460 | + } | |
| 461 | + } | |
| 462 | + const config = record.kind === APPS_PLUGIN_ID ? decodeSourceConfig(record.config) : null; | |
| 463 | + if (!config) return syncFailure(`Custom tools source "${slugValue}" does not exist.`); | |
| 447 | 464 | const github = yield* ctx.core.integrations |
| 448 | 465 | .get(IntegrationSlug.make("github")) |
| 449 | 466 | .pipe(Effect.orElseSucceed(() => null)); |
| @@ -542,6 +559,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 92 unmodified lines | ||
| 542 | 559 | const descriptor = yield* runtime.getDescriptor(tenant, scope); |
| 543 | 560 | const source = descriptor?.source?.kind === "github" ? descriptor.source : null; |
| 544 | 561 | const config: AppsGitHubSourceConfig = { |
| 562 | + origin: "github", | |
| 545 | 563 | kind: "github", |
| 546 | 564 | repoUrl: source?.url ?? input.url.trim(), |
| 547 | 565 | repo: source?.repo ?? parsed.value.repo, |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import type { ArtifactStore } from "../seams/artifact-store"; |
| 2 | −import type { ScopeDb } from "../seams/scope-db"; | |
| 3 | 2 | import type { ToolSandbox } from "../seams/tool-sandbox"; |
| 4 | 3 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; |
| 5 | 4 | import type { AppsStore } from "./store"; |
| @@ -7,7 +6,6 @@ import type { ClientResolver } from "./bindings"; · 1 unmodified line | ||
| 7 | 6 | |
| 8 | 7 | export interface AppsBackings { |
| 9 | 8 | readonly artifactStore: ArtifactStore; |
| 10 | − readonly scopeDb: ScopeDb; | |
| 11 | 9 | readonly sandbox: ToolSandbox; |
| 12 | 10 | readonly store: AppsStore; |
| 13 | 11 | readonly defaultTenant?: string; |
| @@ -20,7 +18,6 @@ export const makeAppsRuntimeFromBackings = ( · 6 unmodified lines | ||
| 20 | 18 | ): AppsRuntime => |
| 21 | 19 | makeAppsRuntime({ |
| 22 | 20 | artifactStore: backings.artifactStore, |
| 23 | − scopeDb: backings.scopeDb, | |
| 24 | 21 | sandbox: backings.sandbox, |
| 25 | 22 | store: backings.store, |
| 26 | 23 | resolver: backings.resolver ?? fallbackResolver, |
| @@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; |
| 5 | 5 | import type { IntegrationDecl } from "../pipeline/descriptor"; |
| 6 | −import type { ScopeDbHandle } from "../seams/scope-db"; | |
| 7 | 6 | |
| 8 | 7 | // --------------------------------------------------------------------------- |
| 9 | 8 | // Strict HandleBridge dispatch (Fix 4, grafted from build A). The bridge is the |
| @@ -11,13 +10,6 @@ import type { ScopeDbHandle } from "../seams/scope-db"; · 1 unmodified line | ||
| 11 | 10 | // malformed, reserved, undeclared, or out-of-range rather than resolve it. |
| 12 | 11 | // --------------------------------------------------------------------------- |
| 13 | 12 | |
| 14 | −const okDb: ScopeDbHandle = { | |
| 15 | − sql: () => Effect.succeed([]), | |
| 16 | − exec: () => Effect.succeed([]), | |
| 17 | − tableVersion: () => Effect.succeed(0), | |
| 18 | − versions: () => Effect.succeed(new Map()), | |
| 19 | −}; | |
| 20 | − | |
| 21 | 13 | const okResolver: ClientResolver = { |
| 22 | 14 | listConnections: ({ integration }) => |
| 23 | 15 | Effect.succeed([ |
| @@ -55,7 +47,7 @@ describe("HandleBridge strict dispatch", () => { · 31 unmodified lines | ||
| 55 | 47 | const bindings = { |
| 56 | 48 | gh: "tools.github.user.main", |
| 57 | 49 | }; |
| 58 | − const bridge = buildBridge({ declared, bindings, db: okDb, resolver: okResolver }); | |
| 50 | + const bridge = buildBridge({ declared, bindings, resolver: okResolver }); | |
| 59 | 51 | |
| 60 | 52 | it("rejects an empty method path", async () => { |
| 61 | 53 | expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true); |
| @@ -75,10 +67,6 @@ describe("HandleBridge strict dispatch", () => { · 13 unmodified lines | ||
| 75 | 67 | ); |
| 76 | 68 | }); |
| 77 | 69 | |
| 78 | − it("rejects an unsupported db call", async () => { | |
| 79 | − expect(await fails(bridge.call({ root: "db", path: ["drop"], args: [] }))).toBe(true); | |
| 80 | − }); | |
| 81 | − | |
| 82 | 70 | it("routes a valid single-connection call through the resolver", async () => { |
| 83 | 71 | const out = await Effect.runPromise( |
| 84 | 72 | bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }), |
| @@ -4,7 +4,6 @@ import type { InvokeOptions } from "@executor-js/sdk"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox"; |
| 6 | 6 | import { ToolSandboxError } from "../seams/tool-sandbox"; |
| 7 | −import type { ScopeDbHandle } from "../seams/scope-db"; | |
| 8 | 7 | import type { IntegrationDecl } from "../pipeline/descriptor"; |
| 9 | 8 | |
| 10 | 9 | // --------------------------------------------------------------------------- |
| @@ -16,7 +15,7 @@ import type { IntegrationDecl } from "../pipeline/descriptor"; · 5 unmodified lines | ||
| 16 | 15 | // has exactly one connection for a role's integration, that connection is used |
| 17 | 16 | // as the default. Missing, unknown, or wrong-integration choices are typed |
| 18 | 17 | // BindingError failures. The handler receives clients under the declared role |
| 19 | −// names and `db`. | |
| 18 | +// names. | |
| 20 | 19 | // --------------------------------------------------------------------------- |
| 21 | 20 | |
| 22 | 21 | export class BindingError extends Data.TaggedError("BindingError")<{ |
| @@ -60,8 +59,6 @@ export interface BindingContext { · 37 unmodified lines | ||
| 60 | 59 | readonly declared: Readonly<Record<string, IntegrationDecl>>; |
| 61 | 60 | /** Resolved role -> connection address choices for this invocation. */ |
| 62 | 61 | readonly bindings: RoleBindings; |
| 63 | − /** The invoking scope's app database (bound to the `db` root). */ | |
| 64 | − readonly db: ScopeDbHandle; | |
| 65 | 62 | /** Routes a bound method call to the real integration. */ |
| 66 | 63 | readonly resolver: ClientResolver; |
| 67 | 64 | /** Caller-supplied invoke options to preserve approval/elicitation context. */ |
| @@ -166,12 +163,12 @@ export const resolveIntegrationBindings = ( · 98 unmodified lines | ||
| 166 | 163 | return { input, bindings }; |
| 167 | 164 | }); |
| 168 | 165 | |
| 169 | −/** Compute the sandbox handle roots: `db` plus one single root per declared | |
| 170 | − * integration role. */ | |
| 166 | +/** Compute the sandbox handle roots: one single root per declared integration | |
| 167 | + * role. */ | |
| 171 | 168 | export const rootsFor = ( |
| 172 | 169 | declared: Readonly<Record<string, IntegrationDecl>>, |
| 173 | 170 | ): Readonly<Record<string, HandleRootSpec>> => { |
| 174 | − const roots: Record<string, HandleRootSpec> = { db: { kind: "single" } }; | |
| 171 | + const roots: Record<string, HandleRootSpec> = {}; | |
| 175 | 172 | for (const role of Object.keys(declared)) { |
| 176 | 173 | roots[role] = { kind: "single" }; |
| 177 | 174 | } |
| @@ -192,12 +189,11 @@ const invokeErr = (message: string): ToolSandboxError => · 14 unmodified lines | ||
| 192 | 189 | new ToolSandboxError({ kind: "invoke", message }); |
| 193 | 190 | |
| 194 | 191 | /** |
| 195 | − * Build the HandleBridge the sandbox calls out through. `db` routes to the | |
| 196 | − * scope database; a declared role routes to its resolved connection through the | |
| 197 | − * `ClientResolver`. The dispatch is STRICT: an empty/malformed path, a reserved | |
| 198 | − * root, an undeclared root, or an indexed root is an error, never a silent | |
| 199 | − * success. A `.account` read on a client returns bound-connection metadata | |
| 200 | − * without a round-trip. | |
| 192 | + * Build the HandleBridge the sandbox calls out through. A declared role routes | |
| 193 | + * to its resolved connection through the `ClientResolver`. The dispatch is | |
| 194 | + * STRICT: an empty/malformed path, a reserved root, an undeclared root, or an | |
| 195 | + * indexed root is an error, never a silent success. A `.account` read on a | |
| 196 | + * client returns bound-connection metadata without a round-trip. | |
| 201 | 197 | */ |
| 202 | 198 | export const buildBridge = (context: BindingContext): HandleBridge => ({ |
| 203 | 199 | call: ({ root, path, args }) => { |
| @@ -211,23 +207,6 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 7 unmodified lines | ||
| 211 | 207 | return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`)); |
| 212 | 208 | } |
| 213 | 209 | |
| 214 | − if (root === "db") { | |
| 215 | − if (path.length === 1 && path[0] === "sql") { | |
| 216 | − const [strings, ...values] = args as [TemplateStringsArray, ...unknown[]]; | |
| 217 | − return context.db.sql(strings, ...values).pipe( | |
| 218 | − Effect.mapError( | |
| 219 | − (cause) => | |
| 220 | − new ToolSandboxError({ | |
| 221 | − kind: "invoke", | |
| 222 | − message: "scope database call failed", | |
| 223 | − cause, | |
| 224 | − }), | |
| 225 | − ), | |
| 226 | − ); | |
| 227 | − } | |
| 228 | − return Effect.fail(invokeErr(`unsupported db call: ${path.join(".")}`)); | |
| 229 | − } | |
| 230 | − | |
| 231 | 210 | const { role, index } = parseRoot(root); |
| 232 | 211 | if (index !== undefined) { |
| 233 | 212 | return Effect.fail( |
| @@ -7,7 +7,6 @@ import { Effect, Exit } from "effect"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 9 | 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; |
| 10 | −import { scopeAddress } from "../seams/scope-address"; | |
| 11 | 10 | |
| 12 | 11 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); |
| 13 | 12 | |
| @@ -36,7 +35,7 @@ const githubHandlers = { · 22 unmodified lines | ||
| 36 | 35 | }; |
| 37 | 36 | |
| 38 | 37 | describe("AppsRuntime end-to-end (publish -> invoke)", () => { |
| 39 | − it("publishes daily-brief and invokes the tool into the scope db", async () => { | |
| 38 | + it("publishes daily-brief and invokes the tool through declared integrations", async () => { | |
| 40 | 39 | const store = makeInMemoryAppsStore(); |
| 41 | 40 | const resolver = makeTestResolver(githubHandlers, [ |
| 42 | 41 | { address: "tools.github.user.rhys-github", integration: "github", name: "rhys-github" }, |
| @@ -61,12 +60,19 @@ describe("AppsRuntime end-to-end (publish -> invoke)", () => { · 18 unmodified lines | ||
| 61 | 60 | tool: "issues-sync", |
| 62 | 61 | args: { github: "tools.github.user.rhys-github" }, |
| 63 | 62 | }), |
| 64 | − )) as { synced: number; repos: number }; | |
| 65 | − expect(syncResult).toEqual({ synced: 2, repos: 1 }); | |
| 66 | − | |
| 67 | − const db = await run(host.scopeDb.forScope(scopeAddress("org", "rhys"))); | |
| 68 | − const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); | |
| 69 | − expect(Number(rows[0].n)).toBe(2); | |
| 63 | + )) as { | |
| 64 | + synced: number; | |
| 65 | + repos: number; | |
| 66 | + issues: readonly { repo: string; number: number; title: string }[]; | |
| 67 | + }; | |
| 68 | + expect(syncResult).toEqual({ | |
| 69 | + synced: 2, | |
| 70 | + repos: 1, | |
| 71 | + issues: [ | |
| 72 | + { repo: "acme/app", number: 1, title: "Fresh bug" }, | |
| 73 | + { repo: "acme/app", number: 2, title: "Old bug" }, | |
| 74 | + ], | |
| 75 | + }); | |
| 70 | 76 | |
| 71 | 77 | await host.close(); |
| 72 | 78 | }); |
| @@ -2,7 +2,6 @@ import { Effect, Predicate } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import type { InvokeOptions } from "@executor-js/sdk"; |
| 3 | 3 | |
| 4 | 4 | import type { ArtifactStore } from "../seams/artifact-store"; |
| 5 | −import type { ScopeDb } from "../seams/scope-db"; | |
| 6 | 5 | import { |
| 7 | 6 | InputValidationError, |
| 8 | 7 | OutputValidationError, |
| @@ -33,7 +32,6 @@ import type { GitHubSkippedArtifact } from "../source/github-source"; · 24 unmodified lines | ||
| 33 | 32 | |
| 34 | 33 | export interface AppsRuntimeDeps { |
| 35 | 34 | readonly artifactStore: ArtifactStore; |
| 36 | − readonly scopeDb: ScopeDb; | |
| 37 | 35 | readonly sandbox: ToolSandbox; |
| 38 | 36 | readonly store: AppsStore; |
| 39 | 37 | /** Routes a bound integration method call to the real API (policy/audit). */ |
| @@ -289,20 +287,9 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 249 unmodified lines | ||
| 289 | 287 | ); |
| 290 | 288 | const roots = rootsFor(toolDesc.integrations); |
| 291 | 289 | const code = yield* bundleFor(descriptor, toolDesc.sourcePath); |
| 292 | − const db = yield* deps.scopeDb.forScope(scopeAddress(descriptor.tenant, scope)).pipe( | |
| 293 | − Effect.mapError( | |
| 294 | − (c) => | |
| 295 | − new PublishError({ | |
| 296 | − message: c.message, | |
| 297 | − stage: "project", | |
| 298 | − diagnostics: [], | |
| 299 | − }), | |
| 300 | − ), | |
| 301 | − ); | |
| 302 | 290 | const bridge = buildBridge({ |
| 303 | 291 | declared: toolDesc.integrations, |
| 304 | 292 | bindings: resolved.bindings, |
| 305 | − db, | |
| 306 | 293 | resolver: activeResolver, |
| 307 | 294 | invokeOptions, |
| 308 | 295 | }); |
| @@ -434,9 +421,6 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 125 unmodified lines | ||
| 434 | 421 | yield* deps.artifactStore |
| 435 | 422 | .removeScope(address) |
| 436 | 423 | .pipe(Effect.mapError(toPublishError("remove artifact scope failed"))); |
| 437 | − yield* deps.scopeDb | |
| 438 | − .removeScope(address) | |
| 439 | − .pipe(Effect.mapError(toPublishError("remove scope db failed"))); | |
| 440 | 424 | }); |
| 441 | 425 | }, |
| 442 | 426 | |
| @@ -1,18 +1,14 @@ | ||
| 1 | 1 | import { join } from "node:path"; |
| 2 | 2 | |
| 3 | −import { Effect } from "effect"; | |
| 4 | − | |
| 5 | 3 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; |
| 6 | −import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; | |
| 7 | 4 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; |
| 8 | −import type { ScopeDb } from "../seams/scope-db"; | |
| 9 | 5 | import { type AppsRuntime } from "./runtime"; |
| 10 | 6 | import type { AppsStore } from "./store"; |
| 11 | 7 | import type { ClientResolver } from "./bindings"; |
| 12 | 8 | import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings"; |
| 13 | 9 | |
| 14 | 10 | export interface SelfHostAppsRuntimeOptions { |
| 15 | − /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */ | |
| 11 | + /** Data dir root; `<root>/artifacts`. */ | |
| 16 | 12 | readonly dataDir: string; |
| 17 | 13 | /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */ |
| 18 | 14 | readonly tenant?: string; |
| @@ -26,7 +22,6 @@ export interface SelfHostAppsRuntimeOptions { · 7 unmodified lines | ||
| 26 | 22 | export interface SelfHostAppsRuntime { |
| 27 | 23 | readonly runtime: AppsRuntime; |
| 28 | 24 | readonly backings: AppsBackings; |
| 29 | − readonly scopeDb: ScopeDb; | |
| 30 | 25 | readonly close: () => Promise<void>; |
| 31 | 26 | } |
| 32 | 27 | |
| @@ -37,13 +32,9 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines | ||
| 37 | 32 | const artifactStore = makeGitArtifactStore({ |
| 38 | 33 | root: inMem ? options.dataDir : join(options.dataDir, "artifacts"), |
| 39 | 34 | }); |
| 40 | − const scopeDb = makeLibsqlScopeDb({ | |
| 41 | − root: inMem ? ":memory:" : join(options.dataDir, "scope-db"), | |
| 42 | − }); | |
| 43 | 35 | const sandbox = makeQuickjsToolSandbox(); |
| 44 | 36 | const backings: AppsBackings = { |
| 45 | 37 | artifactStore, |
| 46 | − scopeDb, | |
| 47 | 38 | sandbox, |
| 48 | 39 | store: options.store, |
| 49 | 40 | resolver: options.resolver, |
| @@ -54,9 +45,6 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines | ||
| 54 | 45 | return { |
| 55 | 46 | runtime, |
| 56 | 47 | backings, |
| 57 | − scopeDb, | |
| 58 | − close: async () => { | |
| 59 | − await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | |
| 60 | − }, | |
| 48 | + close: async () => {}, | |
| 61 | 49 | }; |
| 62 | 50 | }; |
| @@ -6,7 +6,6 @@ import { describe, expect, it } from "@effect/vitest"; · 5 unmodified lines | ||
| 6 | 6 | import { Effect } from "effect"; |
| 7 | 7 | |
| 8 | 8 | import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; |
| 9 | −import { scopeAddress } from "../seams/scope-address"; | |
| 10 | 9 | import { syncGitHubSource } from "../source/github-source"; |
| 11 | 10 | import { makeTestResolver } from "../testing"; |
| 12 | 11 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| @@ -18,11 +17,9 @@ import { z } from "zod"; · 5 unmodified lines | ||
| 18 | 17 | export default defineTool({ |
| 19 | 18 | description: "${name}", |
| 20 | 19 | input: z.object({}), |
| 21 | − output: z.object({ ok: z.boolean() }), | |
| 22 | − async handler(_input, { db }) { | |
| 23 | − await db.sql\`CREATE TABLE IF NOT EXISTS markers (name TEXT NOT NULL)\`; | |
| 24 | − await db.sql\`INSERT INTO markers (name) VALUES (${JSON.stringify(name)})\`; | |
| 25 | − return { ok: true }; | |
| 20 | + output: z.object({ ok: z.boolean(), name: z.string() }), | |
| 21 | + async handler() { | |
| 22 | + return { ok: true, name: ${JSON.stringify(name)} }; | |
| 26 | 23 | }, |
| 27 | 24 | });`; |
| 28 | 25 | |
| @@ -119,22 +116,6 @@ describe("tenant scope isolation", () => { · 90 unmodified lines | ||
| 119 | 116 | expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]); |
| 120 | 117 | expect(descriptorB?.tools.map((tool) => tool.name)).toEqual(["beta"]); |
| 121 | 118 | |
| 122 | − const dbA = await run(hostA.scopeDb.forScope(scopeAddress(tenantA, scope))); | |
| 123 | − const dbB = await run(hostB.scopeDb.forScope(scopeAddress(tenantB, scope))); | |
| 124 | − await run(dbA.exec("CREATE TABLE marker (value TEXT)")); | |
| 125 | − await run(dbA.exec("INSERT INTO marker (value) VALUES ('a')")); | |
| 126 | − await run(dbB.exec("CREATE TABLE marker (value TEXT)")); | |
| 127 | − await run(dbB.exec("INSERT INTO marker (value) VALUES ('b')")); | |
| 128 | − expect( | |
| 129 | − (await run(dbA.exec<{ value: string }>("SELECT value FROM marker"))).map((r) => r.value), | |
| 130 | − ).toEqual(["a"]); | |
| 131 | − expect( | |
| 132 | − (await run(dbB.exec<{ value: string }>("SELECT value FROM marker"))).map((r) => r.value), | |
| 133 | − ).toEqual(["b"]); | |
| 134 | − expect( | |
| 135 | − readdirSync(join(dataDir, "scope-db")).filter((name) => name.endsWith(".db")).length, | |
| 136 | − ).toBe(2); | |
| 137 | − | |
| 138 | 119 | await hostA.close(); |
| 139 | 120 | await hostB.close(); |
| 140 | 121 | }); |
| @@ -2,5 +2,4 @@ · 1 unmodified line | ||
| 2 | 2 | // `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a |
| 3 | 3 | // backing must pass, keeping a future Cloudflare backing honest. |
| 4 | 4 | export * from "./artifact-store"; |
| 5 | −export * from "./scope-db"; | |
| 6 | 5 | export * from "./tool-sandbox"; |
| @@ -1,70 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { Effect } from "effect"; | |
| 3 | − | |
| 4 | −import type { ScopeDb } from "./scope-db"; | |
| 5 | −import { scopeAddress } from "./scope-address"; | |
| 6 | − | |
| 7 | −// --------------------------------------------------------------------------- | |
| 8 | −// ScopeDb conformance suite. Runs against the interface. Covers: scope | |
| 9 | −// isolation (a write in scope A is invisible in scope B), per-table version | |
| 10 | −// bumps on write, and tagged-template parameters. | |
| 11 | −// --------------------------------------------------------------------------- | |
| 12 | − | |
| 13 | −const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | |
| 14 | − | |
| 15 | −export const scopeDbConformance = ( | |
| 16 | − name: string, | |
| 17 | − makeDb: () => Promise<ScopeDb> | ScopeDb, | |
| 18 | −): void => { | |
| 19 | − describe(`ScopeDb conformance: ${name}`, () => { | |
| 20 | − it("isolates data between scopes", async () => { | |
| 21 | − const db = await makeDb(); | |
| 22 | − const a = await run(db.forScope(scopeAddress("org", "scope-a"))); | |
| 23 | − const b = await run(db.forScope(scopeAddress("org", "scope-b"))); | |
| 24 | − await run(a.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")); | |
| 25 | − await run(a.exec("INSERT INTO items (name) VALUES ('a-only')")); | |
| 26 | − await run(b.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")); | |
| 27 | − | |
| 28 | − const aRows = await run(a.exec<{ name: string }>("SELECT name FROM items")); | |
| 29 | − const bRows = await run(b.exec<{ name: string }>("SELECT name FROM items")); | |
| 30 | − expect(aRows.map((r) => r.name)).toEqual(["a-only"]); | |
| 31 | − expect(bRows).toEqual([]); | |
| 32 | − await run(db.close()); | |
| 33 | − }); | |
| 34 | − | |
| 35 | − it("bumps a table's version on each write", async () => { | |
| 36 | − const db = await makeDb(); | |
| 37 | − const s = await run(db.forScope(scopeAddress("org", "ver"))); | |
| 38 | − await run(s.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)")); | |
| 39 | − const afterCreate = await run(s.tableVersion("t")); | |
| 40 | − expect(afterCreate).toBeGreaterThanOrEqual(1); | |
| 41 | − | |
| 42 | − await run(s.exec("INSERT INTO t (id) VALUES (1)")); | |
| 43 | − const afterInsert = await run(s.tableVersion("t")); | |
| 44 | − expect(afterInsert).toBe(afterCreate + 1); | |
| 45 | − | |
| 46 | − await run(s.exec("UPDATE t SET id = 2 WHERE id = 1")); | |
| 47 | − const afterUpdate = await run(s.tableVersion("t")); | |
| 48 | − expect(afterUpdate).toBe(afterInsert + 1); | |
| 49 | − | |
| 50 | − // Reads do not bump. | |
| 51 | − await run(s.exec("SELECT * FROM t")); | |
| 52 | − expect(await run(s.tableVersion("t"))).toBe(afterUpdate); | |
| 53 | − await run(db.close()); | |
| 54 | − }); | |
| 55 | − | |
| 56 | − it("supports the author-facing tagged-template sql with parameters", async () => { | |
| 57 | − const db = await makeDb(); | |
| 58 | − const s = await run(db.forScope(scopeAddress("org", "tpl"))); | |
| 59 | − await run(s.exec("CREATE TABLE issues (repo TEXT, title TEXT)")); | |
| 60 | − const repo = "acme/app"; | |
| 61 | − const title = "Bug"; | |
| 62 | − await run(s.sql`INSERT INTO issues (repo, title) VALUES (${repo}, ${title})`); | |
| 63 | − const rows = await run( | |
| 64 | − s.sql<{ repo: string; title: string }>`SELECT * FROM issues WHERE repo = ${repo}`, | |
| 65 | − ); | |
| 66 | − expect(rows).toEqual([{ repo: "acme/app", title: "Bug" }]); | |
| 67 | − await run(db.close()); | |
| 68 | − }); | |
| 69 | − }); | |
| 70 | −}; | |
| @@ -1,44 +0,0 @@ | ||
| 1 | −import type { Effect } from "effect"; | |
| 2 | −import { Data } from "effect"; | |
| 3 | −import type { ScopeAddress } from "./scope-address"; | |
| 4 | − | |
| 5 | −// --------------------------------------------------------------------------- | |
| 6 | −// ScopeDb — the per-scope app database (the shared primitive). | |
| 7 | −// | |
| 8 | −// One SQL database per scope: tools read and write it. The self-hosted backing | |
| 9 | −// is one libSQL (SQLite) file per scope; the cloud backing (future) is a | |
| 10 | −// Durable Object facet. The seam carries a template-tag `sql` (the author-facing | |
| 11 | −// shape) plus per-table version counters. | |
| 12 | −// | |
| 13 | −// Scope isolation is structural: `forScope(a)` and `forScope(b)` are distinct | |
| 14 | −// databases; there is no cross-scope query path. | |
| 15 | −// --------------------------------------------------------------------------- | |
| 16 | − | |
| 17 | −export class ScopeDbError extends Data.TaggedError("ScopeDbError")<{ | |
| 18 | − readonly message: string; | |
| 19 | − readonly cause?: unknown; | |
| 20 | −}> {} | |
| 21 | − | |
| 22 | −/** The author-facing db handle (matches `executor:app`'s `ScopeDb`): a tagged | |
| 23 | − * template returning rows. */ | |
| 24 | −export interface ScopeDbHandle { | |
| 25 | − readonly sql: <Row = Record<string, unknown>>( | |
| 26 | − strings: TemplateStringsArray, | |
| 27 | − ...values: unknown[] | |
| 28 | − ) => Effect.Effect<readonly Row[], ScopeDbError>; | |
| 29 | − /** Run a raw statement (used by the runtime for probes/migrations). */ | |
| 30 | − readonly exec: <Row = Record<string, unknown>>( | |
| 31 | − sql: string, | |
| 32 | − params?: readonly unknown[], | |
| 33 | − ) => Effect.Effect<readonly Row[], ScopeDbError>; | |
| 34 | − /** Current version counter for a table (0 if never written). */ | |
| 35 | − readonly tableVersion: (table: string) => Effect.Effect<number, ScopeDbError>; | |
| 36 | − /** Snapshot of every tracked table's version. */ | |
| 37 | − readonly versions: () => Effect.Effect<ReadonlyMap<string, number>, ScopeDbError>; | |
| 38 | −} | |
| 39 | − | |
| 40 | −export interface ScopeDb { | |
| 41 | − readonly forScope: (address: ScopeAddress) => Effect.Effect<ScopeDbHandle, ScopeDbError>; | |
| 42 | − readonly removeScope: (address: ScopeAddress) => Effect.Effect<void, ScopeDbError>; | |
| 43 | − readonly close: () => Effect.Effect<void, ScopeDbError>; | |
| 44 | −} | |
| @@ -106,60 +106,5 @@ export default defineTool({ · 105 unmodified lines | ||
| 106 | 106 | expect(seen[0].path).toEqual(["messages", "search"]); |
| 107 | 107 | expect(seen[0].args[0]).toMatchObject({ q: "invoice" }); |
| 108 | 108 | }); |
| 109 | − | |
| 110 | − // --- richer assertions grafted from build A --------------------------- | |
| 111 | − | |
| 112 | − it("round-trips a db.sql write with its parameters intact", async () => { | |
| 113 | − const sandbox = makeSandbox(); | |
| 114 | − // A tool with a github client AND a db.sql write, asserting BOTH the | |
| 115 | − // connection call and the parameterized db statement cross the bridge with | |
| 116 | − // their args intact (the db.sql param round-trip A's suite covers). | |
| 117 | − const src = `import { defineTool, integration } from "executor:app"; | |
| 118 | −import { z } from "zod"; | |
| 119 | −export default defineTool({ | |
| 120 | − description: "bridge", | |
| 121 | − integrations: { gh: integration("github") }, | |
| 122 | − input: z.object({}), | |
| 123 | − async handler(_i, { gh, db }) { | |
| 124 | − const repos = await gh.repos.listForAuthenticatedUser({ per_page: 5 }); | |
| 125 | − await db.sql\`INSERT INTO log (n) VALUES (\${repos.length})\`; | |
| 126 | − return { count: repos.length }; | |
| 127 | − }, | |
| 128 | −});`; | |
| 129 | − const b = await bundle("tools/bridge.ts", src); | |
| 130 | − const calls: { root: string; path: readonly string[]; args: readonly unknown[] }[] = []; | |
| 131 | − const bridge: HandleBridge = { | |
| 132 | − call: ({ root, path, args }) => | |
| 133 | − Effect.sync(() => { | |
| 134 | − calls.push({ root, path, args }); | |
| 135 | − if (root === "gh" && path.join(".") === "repos.listForAuthenticatedUser") { | |
| 136 | − return [{ full_name: "a/b" }, { full_name: "c/d" }]; | |
| 137 | − } | |
| 138 | − return []; | |
| 139 | − }), | |
| 140 | − }; | |
| 141 | − const result = await run( | |
| 142 | − sandbox.invoke( | |
| 143 | − b, | |
| 144 | − { | |
| 145 | − artifact: "bridge", | |
| 146 | − kind: "tool", | |
| 147 | − input: {}, | |
| 148 | − roots: { gh: { kind: "single" }, db: { kind: "single" } }, | |
| 149 | − }, | |
| 150 | − bridge, | |
| 151 | − ), | |
| 152 | − ); | |
| 153 | − expect(result.output).toEqual({ count: 2 }); | |
| 154 | − // The github call crossed the bridge. | |
| 155 | − expect(calls.some((c) => c.root === "gh")).toBe(true); | |
| 156 | − // The db.sql write crossed as ["sql"] with the template strings + the | |
| 157 | − // interpolated parameter (2) intact. | |
| 158 | − const dbCall = calls.find((c) => c.root === "db"); | |
| 159 | − expect(dbCall?.path).toEqual(["sql"]); | |
| 160 | − const [strings, ...params] = dbCall!.args as [readonly string[], ...unknown[]]; | |
| 161 | − expect(strings.join("?")).toContain("INSERT INTO log"); | |
| 162 | − expect(params).toEqual([2]); | |
| 163 | − }); | |
| 164 | 109 | }); |
| 165 | 110 | }; |
| @@ -49,7 +49,7 @@ export class OutputValidationError extends Data.TaggedError("OutputValidationErr · 48 unmodified lines | ||
| 49 | 49 | |
| 50 | 50 | /** |
| 51 | 51 | * The serializable bridge the sandbox calls out through. `root` names an |
| 52 | − * injected handle (a connection role, `db`, or one element of a fan-out set); | |
| 52 | + * injected handle (a connection role or one element of a fan-out set); | |
| 53 | 53 | * `path` is the method chain (`["events", "list"]`); `args` is the JSON call |
| 54 | 54 | * arguments. The return value is JSON. This is the ONE way sandboxed code |
| 55 | 55 | * reaches the host — nothing else is wired. |
| @@ -62,9 +62,9 @@ export interface HandleBridge { · 6 unmodified lines | ||
| 62 | 62 | }) => Effect.Effect<unknown, ToolSandboxError>; |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | −/** Which handle roots to inject. Each declared integration role and `db` are | |
| 66 | − * single roots. Everything the handler can see is enumerated here — | |
| 67 | − * undeclared roots are simply absent. */ | |
| 65 | +/** Which handle roots to inject. Each declared integration role is a single | |
| 66 | + * root. Everything the handler can see is enumerated here; undeclared roots | |
| 67 | + * are simply absent. */ | |
| 68 | 68 | export interface HandleRootSpec { |
| 69 | 69 | readonly kind: "single"; |
| 70 | 70 | } |
| @@ -77,7 +77,7 @@ export interface InvokeRequest { · 6 unmodified lines | ||
| 77 | 77 | /** JSON input passed to the handler. */ |
| 78 | 78 | readonly input: unknown; |
| 79 | 79 | /** The handle roots to inject, keyed by the name the handler destructures |
| 80 | − * (`github`, `db`, `inboxes`). */ | |
| 80 | + * (`github`, `inboxes`). */ | |
| 81 | 81 | readonly roots: Readonly<Record<string, HandleRootSpec>>; |
| 82 | 82 | } |
| 83 | 83 | |
| @@ -11,7 +11,7 @@ import { defineTool, integration } from "executor:app"; · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | export default defineTool({ |
| 13 | 13 | description: |
| 14 | − "Refresh the scope \\\`issues\\\` table from GitHub. Syncs open issues across the given repos (default: every repo the connection can see).", | |
| 14 | + "Summarize open GitHub issues across the given repos (default: every repo the connection can see).", | |
| 15 | 15 | integrations: { |
| 16 | 16 | github: integration("github"), |
| 17 | 17 | }, |
| @@ -19,38 +19,25 @@ export default defineTool({ · 1 unmodified line | ||
| 19 | 19 | repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"), |
| 20 | 20 | since: z.string().optional().describe("ISO timestamp, only issues updated after this"), |
| 21 | 21 | }), |
| 22 | − output: z.object({ synced: z.number(), repos: z.number() }), | |
| 22 | + output: z.object({ synced: z.number(), repos: z.number(), issues: z.array(z.object({ repo: z.string(), number: z.number(), title: z.string() })) }), | |
| 23 | 23 | annotations: { readOnly: false, destructive: false }, |
| 24 | − async handler({ repos, since }, { github, db }) { | |
| 24 | + async handler({ repos, since }, { github }) { | |
| 25 | 25 | const targets = |
| 26 | 26 | repos ?? |
| 27 | 27 | (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name); |
| 28 | 28 | |
| 29 | − await db.sql\` | |
| 30 | − CREATE TABLE IF NOT EXISTS issues ( | |
| 31 | − repo TEXT NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL, | |
| 32 | − labels TEXT NOT NULL DEFAULT '[]', assignee TEXT, updated_at TEXT NOT NULL, url TEXT NOT NULL, | |
| 33 | − PRIMARY KEY (repo, number) | |
| 34 | − )\`; | |
| 35 | − | |
| 36 | 29 | let synced = 0; |
| 30 | + const collected = []; | |
| 37 | 31 | for (const target of targets) { |
| 38 | 32 | const [owner, repo] = target.split("/"); |
| 39 | 33 | const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 }); |
| 40 | 34 | for (const issue of issues) { |
| 41 | 35 | if (issue.pull_request) continue; |
| 42 | − await db.sql\` | |
| 43 | − INSERT INTO issues (repo, number, title, labels, assignee, updated_at, url) | |
| 44 | − VALUES (\${target}, \${issue.number}, \${issue.title}, | |
| 45 | − \${JSON.stringify(issue.labels.map((l) => l.name))}, | |
| 46 | − \${issue.assignee?.login ?? null}, \${issue.updated_at}, \${issue.html_url}) | |
| 47 | − ON CONFLICT (repo, number) DO UPDATE SET | |
| 48 | − title = excluded.title, labels = excluded.labels, | |
| 49 | − assignee = excluded.assignee, updated_at = excluded.updated_at\`; | |
| 36 | + collected.push({ repo: target, number: issue.number, title: issue.title }); | |
| 50 | 37 | synced++; |
| 51 | 38 | } |
| 52 | 39 | } |
| 53 | − return { synced, repos: targets.length }; | |
| 40 | + return { synced, repos: targets.length, issues: collected }; | |
| 54 | 41 | }, |
| 55 | 42 | }); |
| 56 | 43 | `; |