Unified · Split
| @@ -1,10 +1,7 @@ | |||
| 1 | import { join } from "node:path"; | 1 | import { join } from "node:path"; |
| 2 | 2 | ||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { | 3 | import { |
| 6 | makeGitArtifactStore, | 4 | makeGitArtifactStore, |
| 7 | makeLibsqlScopeDb, | ||
| 8 | makeQuickjsToolSandbox, | 5 | makeQuickjsToolSandbox, |
| 9 | makeSqliteAppsStore, | 6 | makeSqliteAppsStore, |
| 10 | type AppsBackings, | 7 | type AppsBackings, |
| @@ -19,19 +16,13 @@ interface SelfHostAppsBackings { · 8 unmodified lines | |||
| 19 | 16 | ||
| 20 | const createBackings = (dataDir: string): SelfHostAppsBackings => { | 17 | const createBackings = (dataDir: string): SelfHostAppsBackings => { |
| 21 | const appsDir = join(dataDir, "apps"); | 18 | const appsDir = join(dataDir, "apps"); |
| 22 | const scopeDb = makeLibsqlScopeDb({ | ||
| 23 | root: join(appsDir, "scope-db"), | ||
| 24 | }); | ||
| 25 | return { | 19 | return { |
| 26 | backings: { | 20 | backings: { |
| 27 | artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }), | 21 | artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }), |
| 28 | scopeDb, | ||
| 29 | sandbox: makeQuickjsToolSandbox(), | 22 | sandbox: makeQuickjsToolSandbox(), |
| 30 | store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }), | 23 | store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }), |
| 31 | }, | 24 | }, |
| 32 | close: async () => { | 25 | close: async () => {}, |
| 33 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | ||
| 34 | }, | ||
| 35 | }; | 26 | }; |
| 36 | }; | 27 | }; |
| 37 | 28 | ||
| @@ -28,7 +28,6 @@ export { · 27 unmodified lines | |||
| 28 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; | 28 | export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime"; |
| 29 | export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings"; | 29 | export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings"; |
| 30 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; | 30 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; |
| 31 | export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | ||
| 32 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; | 31 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; |
| 33 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; | 32 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| 34 | export { | 33 | export { |
| @@ -38,6 +37,7 @@ export { · 3 unmodified lines | |||
| 38 | type RoleBindings, | 37 | type RoleBindings, |
| 39 | } from "./plugin/bindings"; | 38 | } from "./plugin/bindings"; |
| 40 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; | 39 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; |
| 40 | export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin"; | ||
| 41 | export { | 41 | export { |
| 42 | fetchGitHubSource, | 42 | fetchGitHubSource, |
| 43 | parseGitHubSourceUrl, | 43 | parseGitHubSourceUrl, |
| @@ -1,4 +1,3 @@ | |||
| 1 | import type { ScopeDbHandle } from "./seams/scope-db"; | ||
| 2 | import type { StandardSchemaV1 } from "./standard-schema"; | 1 | import type { StandardSchemaV1 } from "./standard-schema"; |
| 3 | 2 | ||
| 4 | export type JsonPrimitive = string | number | boolean | null; | 3 | export type JsonPrimitive = string | number | boolean | null; |
| @@ -16,8 +15,6 @@ export interface IntegrationDeclaration<Slug extends string = string> { · 11 unmodified lines | |||
| 16 | export type ToolHandlerContext< | 15 | export type ToolHandlerContext< |
| 17 | TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined, | 16 | TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined, |
| 18 | > = { | 17 | > = { |
| 19 | readonly db: ScopeDbHandle; | ||
| 20 | } & { | ||
| 21 | readonly [K in keyof NonNullable<TIntegrations>]: unknown; | 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 | expect(descriptor.inputJsonSchema.properties).toHaveProperty("since"); | 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 | const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); | 35 | const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]); |
| 36 | const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" })); | 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 | return []; | 59 | return []; |
| 61 | }), | 60 | }), |
| 62 | }; | 61 | }; |
| @@ -68,18 +67,43 @@ describe("QuickJS ToolSandbox", () => { · 5 unmodified lines | |||
| 68 | artifact: "issues-sync", | 67 | artifact: "issues-sync", |
| 69 | kind: "tool", | 68 | kind: "tool", |
| 70 | input: {}, | 69 | input: {}, |
| 71 | roots: { github: { kind: "single" }, db: { kind: "single" } }, | 70 | roots: { github: { kind: "single" } }, |
| 72 | }, | 71 | }, |
| 73 | bridge, | 72 | bridge, |
| 74 | ), | 73 | ), |
| 75 | ); | 74 | ); |
| 76 | 75 | ||
| 77 | expect(result.output).toEqual({ synced: 1, repos: 1 }); | 76 | expect(result.output).toEqual({ |
| 78 | // The db write went through the bridge (root "db"). | 77 | synced: 1, |
| 79 | expect(calls.some((c) => c.root === "db")).toBe(true); | 78 | repos: 1, |
| 79 | issues: [{ repo: "acme/app", number: 1, title: "Bug" }], | ||
| 80 | }); | ||
| 80 | expect(calls.some((c) => c.root === "github")).toBe(true); | 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 | it("surfaces Standard Schema validation issues before the handler runs", async () => { | 107 | it("surfaces Standard Schema validation issues before the handler runs", async () => { |
| 84 | const source = `import { defineTool } from "executor:app"; | 108 | const source = `import { defineTool } from "executor:app"; |
| 85 | import { z } from "zod"; | 109 | import { z } from "zod"; |
| @@ -29,6 +29,8 @@ import { stableStringify } from "../pipeline/descriptor"; · 28 unmodified lines | |||
| 29 | // `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the | 29 | // `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the |
| 30 | // QuickJS runtime already provides (`tools`/`__invokeTool`). Our | 30 | // QuickJS runtime already provides (`tools`/`__invokeTool`). Our |
| 31 | // `SandboxToolInvoker` decodes that and forwards to the host `HandleBridge`. | 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 | // Everything crossing is JSON (the cloud version is RPC), so the interface | 34 | // Everything crossing is JSON (the cloud version is RPC), so the interface |
| 33 | // stays honest. | 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 | var __executorApp = { | 78 | var __executorApp = { |
| 67 | integration: function(integration) { return { integration: integration }; }, | 79 | integration: function(integration) { return { integration: integration }; }, |
| 68 | defineTool: function(def) { __defs.tool = def; return def; }, | 80 | defineTool: function(def) { __defs.tool = def; return def; }, |
| @@ -167,6 +179,16 @@ return await (async () => { · 98 unmodified lines | |||
| 167 | }); | 179 | }); |
| 168 | }; | 180 | }; |
| 169 | var def = __defs.tool || (globalThis.__artifact && (globalThis.__artifact.default || globalThis.__artifact)); | 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 | var integrations = {}; | 192 | var integrations = {}; |
| 171 | if (def && def.integrations) { | 193 | if (def && def.integrations) { |
| 172 | for (var k in def.integrations) { | 194 | for (var k in def.integrations) { |
| @@ -226,6 +248,7 @@ return await (async () => { · 53 unmodified lines | |||
| 226 | if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind); | 248 | if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind); |
| 227 | injected[name] = __mkHandle(name, []); | 249 | injected[name] = __mkHandle(name, []); |
| 228 | } | 250 | } |
| 251 | injected.db = __unavailableStorage(); | ||
| 229 | var input = await validateWithStandardSchema('input', def.input, ${inputLiteral}); | 252 | var input = await validateWithStandardSchema('input', def.input, ${inputLiteral}); |
| 230 | var out = await def.handler(input, injected); | 253 | var out = await def.handler(input, injected); |
| 231 | out = await validateWithStandardSchema('output', def.output, out); | 254 | out = await validateWithStandardSchema('output', def.output, out); |
| @@ -267,7 +290,10 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 35 unmodified lines | |||
| 267 | Effect.flatMap((result) => { | 290 | Effect.flatMap((result) => { |
| 268 | if (result.error) { | 291 | if (result.error) { |
| 269 | const marker = parseMarker(result.error); | 292 | const marker = parseMarker(result.error); |
| 270 | if (isMarker(marker, "SchemaExportError")) { | 293 | if ( |
| 294 | isMarker(marker, "SchemaExportError") || | ||
| 295 | isMarker(marker, "StorageUnavailableError") | ||
| 296 | ) { | ||
| 271 | return Effect.fail( | 297 | return Effect.fail( |
| 272 | new ToolSandboxError({ | 298 | new ToolSandboxError({ |
| 273 | kind: "collect", | 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 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 7 | 3 | ||
| 8 | import { makeLibsqlScopeDb } from "./libsql-scope-db"; | ||
| 9 | import { makeSqliteAppsStore } from "./sqlite-apps-store"; | 4 | import { makeSqliteAppsStore } from "./sqlite-apps-store"; |
| 10 | import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor"; | 5 | import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor"; |
| 11 | import { scopeAddress } from "../seams/scope-address"; | ||
| 12 | 6 | ||
| 13 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | 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 | 10 | // Finding 9 regression: scopes that differ only by a character an old naming |
| 17 | // collapsed ("my-scope" vs "my_scope") must NOT share a database, and the | 11 | // scheme collapsed ("my-scope" vs "my_scope") must not share descriptor |
| 18 | // connection->scope mapping must route each to its own scope. Before the fix the | 12 | // storage. |
| 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). | ||
| 22 | // --------------------------------------------------------------------------- | 13 | // --------------------------------------------------------------------------- |
| 23 | 14 | ||
| 24 | describe("scope collision (Fix 9)", () => { | 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 | it("stores distinct descriptor scopes independently", async () => { | 16 | it("stores distinct descriptor scopes independently", async () => { |
| 51 | const store = makeSqliteAppsStore({ path: ":memory:" }); | 17 | const store = makeSqliteAppsStore({ path: ":memory:" }); |
| 52 | const descriptor = (scope: string): AppDescriptor => ({ | 18 | const descriptor = (scope: string): AppDescriptor => ({ |
| @@ -52,6 +52,7 @@ export { · 51 unmodified lines | |||
| 52 | type BindingContext, | 52 | type BindingContext, |
| 53 | } from "./plugin/bindings"; | 53 | } from "./plugin/bindings"; |
| 54 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; | 54 | export { makePluginCtxAppsResolver } from "./plugin/resolver"; |
| 55 | export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin"; | ||
| 55 | export { | 56 | export { |
| 56 | makeSelfHostAppsRuntime, | 57 | makeSelfHostAppsRuntime, |
| 57 | type SelfHostAppsRuntime, | 58 | type SelfHostAppsRuntime, |
| @@ -60,6 +61,5 @@ export { · 2 unmodified lines | |||
| 60 | 61 | ||
| 61 | // Self-host seam backings. | 62 | // Self-host seam backings. |
| 62 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; | 63 | export { makeGitArtifactStore } from "./backing/git-artifact-store"; |
| 63 | export { makeLibsqlScopeDb } from "./backing/libsql-scope-db"; | ||
| 64 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; | 64 | export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox"; |
| 65 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; | 65 | export { makeSqliteAppsStore } from "./backing/sqlite-apps-store"; |
| @@ -94,6 +94,34 @@ export default defineTool({ · 93 unmodified lines | |||
| 94 | expect(JSON.stringify(exit)).toContain("crm"); | 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 | it("rejects a Standard Schema vendor without JSON Schema export", async () => { | 125 | it("rejects a Standard Schema vendor without JSON Schema export", async () => { |
| 98 | const deps = makeDeps(); | 126 | const deps = makeDeps(); |
| 99 | const files = new Map<string, string>([ | 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 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| @@ -7,27 +7,13 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 7 | import { IntegrationSlug } from "@executor-js/sdk"; | 7 | import { IntegrationSlug } from "@executor-js/sdk"; |
| 8 | import { makeTestExecutor } from "@executor-js/sdk/testing"; | 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 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | 11 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 12 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; | 12 | import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; |
| 13 | import { dailyBriefFileSet } from "../testing/daily-brief"; | ||
| 13 | 14 | ||
| 14 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | 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 | describe("appsPlugin custom-tools contract", () => { | 17 | describe("appsPlugin custom-tools contract", () => { |
| 32 | it.effect("detects GitHub repo URLs for console auto-detect", () => | 18 | it.effect("detects GitHub repo URLs for console auto-detect", () => |
| 33 | Effect.gen(function* () { | 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 () => { | 66 | it.effect("rejects publishing through a different source door for a GitHub-managed app", () => |
| 81 | let dealListArgs: unknown; | 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 | const resolver = makeTestResolver( | 79 | const resolver = makeTestResolver( |
| 83 | { | 80 | { |
| 84 | dealcloud: { | 81 | github: { |
| 85 | "deals.list": (args) => { | 82 | "issues.listForRepo": (args) => { |
| 86 | dealListArgs = args[0]; | 83 | issueListArgs = args[0]; |
| 87 | return []; | 84 | return [{ number: 7, title: "Renewal diligence" }]; |
| 88 | }, | 85 | }, |
| 89 | }, | 86 | }, |
| 90 | "microsoft-sharepoint": { | 87 | gmail: { |
| 91 | "search.query": () => [], | 88 | "messages.search": () => ({ messages: [] }), |
| 92 | }, | 89 | }, |
| 93 | }, | 90 | }, |
| 94 | [ | 91 | [ |
| 95 | { | 92 | { |
| 96 | address: "tools.dealcloud.user.crm-main", | 93 | address: "tools.github.user.github-main", |
| 97 | integration: "dealcloud", | 94 | integration: "github", |
| 98 | name: "crm-main", | 95 | name: "github-main", |
| 99 | }, | 96 | }, |
| 100 | { | 97 | { |
| 101 | address: "tools.microsoft-sharepoint.user.sharepoint-main", | 98 | address: "tools.gmail.user.inbox-main", |
| 102 | integration: "microsoft-sharepoint", | 99 | integration: "gmail", |
| 103 | name: "sharepoint-main", | 100 | name: "inbox-main", |
| 104 | }, | 101 | }, |
| 105 | ], | 102 | ], |
| 106 | ); | 103 | ); |
| @@ -114,6 +111,7 @@ describe("appsPlugin custom-tools contract", () => { · 7 unmodified lines | |||
| 114 | const plugin = appsPlugin({ backings: host.backings }); | 111 | const plugin = appsPlugin({ backings: host.backings }); |
| 115 | const appIntegration = IntegrationSlug.make("rhys-tools"); | 112 | const appIntegration = IntegrationSlug.make("rhys-tools"); |
| 116 | const appConfig = { | 113 | const appConfig = { |
| 114 | origin: "github", | ||
| 117 | kind: "github", | 115 | kind: "github", |
| 118 | repoUrl: "https://github.com/rhys/tools", | 116 | repoUrl: "https://github.com/rhys/tools", |
| 119 | repo: "rhys/tools", | 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 | const resolved = await run( | 145 | const resolved = await run( |
| 148 | plugin.resolveTools!({ | 146 | plugin.resolveTools!({ |
| @@ -151,18 +149,18 @@ describe("appsPlugin custom-tools contract", () => { · 2 unmodified lines | |||
| 151 | connection: { name: "main" }, | 149 | connection: { name: "main" }, |
| 152 | } as never), | 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 | expect(syncTool).toBeTruthy(); | 153 | expect(syncTool).toBeTruthy(); |
| 156 | const persistedInputSchema = syncTool!.inputSchema as { | 154 | const persistedInputSchema = syncTool!.inputSchema as { |
| 157 | properties: Record<string, unknown>; | 155 | properties: Record<string, unknown>; |
| 158 | }; | 156 | }; |
| 159 | expect(persistedInputSchema.properties.crm).toBeUndefined(); | 157 | expect(persistedInputSchema.properties.github).toBeUndefined(); |
| 160 | 158 | ||
| 161 | const projected = await run( | 159 | const projected = await run( |
| 162 | plugin.projectToolSchema!({ | 160 | plugin.projectToolSchema!({ |
| 163 | ctx, | 161 | ctx, |
| 164 | toolRow: { | 162 | toolRow: { |
| 165 | name: "deal-pipeline-sync", | 163 | name: "issues-sync", |
| 166 | integration: appIntegration, | 164 | integration: appIntegration, |
| 167 | connection: "main", | 165 | connection: "main", |
| 168 | }, | 166 | }, |
| @@ -174,31 +172,38 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines | |||
| 174 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; | 172 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; |
| 175 | required?: string[]; | 173 | required?: string[]; |
| 176 | }; | 174 | }; |
| 177 | expect(inputSchema.properties.crm.enum).toEqual(["tools.dealcloud.user.crm-main"]); | 175 | expect(inputSchema.properties.github.enum).toEqual(["tools.github.user.github-main"]); |
| 178 | expect(inputSchema.properties.crm.default).toBe("tools.dealcloud.user.crm-main"); | 176 | expect(inputSchema.properties.github.default).toBe("tools.github.user.github-main"); |
| 179 | expect(inputSchema.properties.crm.description).toBe("Connection to use for crm (dealcloud)"); | 177 | expect(inputSchema.properties.github.description).toBe("Connection to use for github (github)"); |
| 180 | expect(inputSchema.required ?? []).not.toContain("crm"); | 178 | expect(inputSchema.required ?? []).not.toContain("github"); |
| 181 | 179 | ||
| 182 | const output = await run( | 180 | const output = await run( |
| 183 | plugin.invokeTool!({ | 181 | plugin.invokeTool!({ |
| 184 | ctx, | 182 | ctx, |
| 185 | toolRow: { | 183 | toolRow: { |
| 186 | name: "deal-pipeline-sync", | 184 | name: "issues-sync", |
| 187 | integration: appIntegration, | 185 | integration: appIntegration, |
| 188 | connection: "main", | 186 | connection: "main", |
| 189 | }, | 187 | }, |
| 190 | args: { | 188 | args: { |
| 191 | crm: "tools.dealcloud.user.crm-main", | 189 | github: "tools.github.user.github-main", |
| 192 | updatedSince: "2026-01-01T00:00:00Z", | 190 | repos: ["acme/tools"], |
| 191 | since: "2026-01-01T00:00:00Z", | ||
| 193 | }, | 192 | }, |
| 194 | } as never), | 193 | } as never), |
| 195 | ); | 194 | ); |
| 196 | 195 | ||
| 197 | expect(output).toEqual({ synced: 0 }); | 196 | expect(output).toEqual({ |
| 198 | expect(dealListArgs).toEqual({ | 197 | synced: 1, |
| 199 | status: "active", | 198 | repos: 1, |
| 200 | updatedSince: "2026-01-01T00:00:00Z", | 199 | issues: [{ repo: "acme/tools", number: 7, title: "Renewal diligence" }], |
| 201 | pageSize: 200, | 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 | await host.close(); | 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 | import { | 3 | import { |
| 4 | AuthTemplateSlug, | 4 | AuthTemplateSlug, |
| @@ -24,7 +24,6 @@ import { · 19 unmodified lines | |||
| 24 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; | 24 | import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor"; |
| 25 | import { PublishError } from "../pipeline/discover"; | 25 | import { PublishError } from "../pipeline/discover"; |
| 26 | import { ArtifactStoreError } from "../seams/artifact-store"; | 26 | import { ArtifactStoreError } from "../seams/artifact-store"; |
| 27 | import { ScopeDbError } from "../seams/scope-db"; | ||
| 28 | import { ToolSandboxError } from "../seams/tool-sandbox"; | 27 | import { ToolSandboxError } from "../seams/tool-sandbox"; |
| 29 | import { | 28 | import { |
| 30 | parseGitHubSourceUrl, | 29 | parseGitHubSourceUrl, |
| @@ -45,6 +44,7 @@ export const APPS_PLUGIN_ID = "apps"; · 14 unmodified lines | |||
| 45 | const APP_CONNECTION_NAME = connectionIdentifier("main"); | 44 | const APP_CONNECTION_NAME = connectionIdentifier("main"); |
| 46 | 45 | ||
| 47 | interface AppsGitHubSourceConfig { | 46 | interface AppsGitHubSourceConfig { |
| 47 | readonly origin: "github"; | ||
| 48 | readonly kind: "github"; | 48 | readonly kind: "github"; |
| 49 | readonly repoUrl: string; | 49 | readonly repoUrl: string; |
| 50 | readonly repo: string; | 50 | readonly repo: string; |
| @@ -59,6 +59,29 @@ type ResolverFactory = (input: { · 8 unmodified lines | |||
| 59 | readonly tool: string; | 59 | readonly tool: string; |
| 60 | }) => ClientResolver; | 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 | export type AppsPluginOptions = | 85 | export type AppsPluginOptions = |
| 63 | | { | 86 | | { |
| 64 | readonly backings: AppsBackings; | 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 | sandbox: { | 197 | sandbox: { |
| 190 | collect: () => | 198 | collect: () => |
| 191 | Effect.fail( | 199 | Effect.fail( |
| @@ -221,7 +229,7 @@ const configBaseUrl = (config: unknown): string | undefined => · 29 unmodified lines | |||
| 221 | : undefined; | 229 | : undefined; |
| 222 | 230 | ||
| 223 | const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { | 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 | const repoUrl = asString(config.repoUrl); | 233 | const repoUrl = asString(config.repoUrl); |
| 226 | const repo = asString(config.repo); | 234 | const repo = asString(config.repo); |
| 227 | const scope = asString(config.scope); | 235 | const scope = asString(config.scope); |
| @@ -235,6 +243,7 @@ const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { · 7 unmodified lines | |||
| 235 | } | 243 | } |
| 236 | : undefined; | 244 | : undefined; |
| 237 | return { | 245 | return { |
| 246 | origin: "github", | ||
| 238 | kind: "github", | 247 | kind: "github", |
| 239 | repoUrl, | 248 | repoUrl, |
| 240 | repo, | 249 | repo, |
| @@ -440,10 +449,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 199 unmodified lines | |||
| 440 | Effect.gen(function* () { | 449 | Effect.gen(function* () { |
| 441 | const slug = IntegrationSlug.make(slugValue); | 450 | const slug = IntegrationSlug.make(slugValue); |
| 442 | const record = yield* ctx.core.integrations.get(slug).pipe(Effect.orElseSucceed(() => null)); | 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; | 452 | if (!record) { |
| 444 | if (!record || !config) { | ||
| 445 | return syncFailure(`Custom tools source "${slugValue}" does not exist.`); | 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 | const github = yield* ctx.core.integrations | 464 | const github = yield* ctx.core.integrations |
| 448 | .get(IntegrationSlug.make("github")) | 465 | .get(IntegrationSlug.make("github")) |
| 449 | .pipe(Effect.orElseSucceed(() => null)); | 466 | .pipe(Effect.orElseSucceed(() => null)); |
| @@ -542,6 +559,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 92 unmodified lines | |||
| 542 | const descriptor = yield* runtime.getDescriptor(tenant, scope); | 559 | const descriptor = yield* runtime.getDescriptor(tenant, scope); |
| 543 | const source = descriptor?.source?.kind === "github" ? descriptor.source : null; | 560 | const source = descriptor?.source?.kind === "github" ? descriptor.source : null; |
| 544 | const config: AppsGitHubSourceConfig = { | 561 | const config: AppsGitHubSourceConfig = { |
| 562 | origin: "github", | ||
| 545 | kind: "github", | 563 | kind: "github", |
| 546 | repoUrl: source?.url ?? input.url.trim(), | 564 | repoUrl: source?.url ?? input.url.trim(), |
| 547 | repo: source?.repo ?? parsed.value.repo, | 565 | repo: source?.repo ?? parsed.value.repo, |
| @@ -1,5 +1,4 @@ | |||
| 1 | import type { ArtifactStore } from "../seams/artifact-store"; | 1 | import type { ArtifactStore } from "../seams/artifact-store"; |
| 2 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 3 | import type { ToolSandbox } from "../seams/tool-sandbox"; | 2 | import type { ToolSandbox } from "../seams/tool-sandbox"; |
| 4 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; | 3 | import { makeAppsRuntime, type AppsRuntime } from "./runtime"; |
| 5 | import type { AppsStore } from "./store"; | 4 | import type { AppsStore } from "./store"; |
| @@ -7,7 +6,6 @@ import type { ClientResolver } from "./bindings"; · 1 unmodified line | |||
| 7 | 6 | ||
| 8 | export interface AppsBackings { | 7 | export interface AppsBackings { |
| 9 | readonly artifactStore: ArtifactStore; | 8 | readonly artifactStore: ArtifactStore; |
| 10 | readonly scopeDb: ScopeDb; | ||
| 11 | readonly sandbox: ToolSandbox; | 9 | readonly sandbox: ToolSandbox; |
| 12 | readonly store: AppsStore; | 10 | readonly store: AppsStore; |
| 13 | readonly defaultTenant?: string; | 11 | readonly defaultTenant?: string; |
| @@ -20,7 +18,6 @@ export const makeAppsRuntimeFromBackings = ( · 6 unmodified lines | |||
| 20 | ): AppsRuntime => | 18 | ): AppsRuntime => |
| 21 | makeAppsRuntime({ | 19 | makeAppsRuntime({ |
| 22 | artifactStore: backings.artifactStore, | 20 | artifactStore: backings.artifactStore, |
| 23 | scopeDb: backings.scopeDb, | ||
| 24 | sandbox: backings.sandbox, | 21 | sandbox: backings.sandbox, |
| 25 | store: backings.store, | 22 | store: backings.store, |
| 26 | resolver: backings.resolver ?? fallbackResolver, | 23 | resolver: backings.resolver ?? fallbackResolver, |
| @@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; | 4 | import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings"; |
| 5 | import type { IntegrationDecl } from "../pipeline/descriptor"; | 5 | import type { IntegrationDecl } from "../pipeline/descriptor"; |
| 6 | import type { ScopeDbHandle } from "../seams/scope-db"; | ||
| 7 | 6 | ||
| 8 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 9 | // Strict HandleBridge dispatch (Fix 4, grafted from build A). The bridge is the | 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 | // malformed, reserved, undeclared, or out-of-range rather than resolve it. | 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 | const okResolver: ClientResolver = { | 13 | const okResolver: ClientResolver = { |
| 22 | listConnections: ({ integration }) => | 14 | listConnections: ({ integration }) => |
| 23 | Effect.succeed([ | 15 | Effect.succeed([ |
| @@ -55,7 +47,7 @@ describe("HandleBridge strict dispatch", () => { · 31 unmodified lines | |||
| 55 | const bindings = { | 47 | const bindings = { |
| 56 | gh: "tools.github.user.main", | 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 | it("rejects an empty method path", async () => { | 52 | it("rejects an empty method path", async () => { |
| 61 | expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true); | 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 | it("routes a valid single-connection call through the resolver", async () => { | 70 | it("routes a valid single-connection call through the resolver", async () => { |
| 83 | const out = await Effect.runPromise( | 71 | const out = await Effect.runPromise( |
| 84 | bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }), | 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 | import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox"; | 5 | import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox"; |
| 6 | import { ToolSandboxError } from "../seams/tool-sandbox"; | 6 | import { ToolSandboxError } from "../seams/tool-sandbox"; |
| 7 | import type { ScopeDbHandle } from "../seams/scope-db"; | ||
| 8 | import type { IntegrationDecl } from "../pipeline/descriptor"; | 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 | // has exactly one connection for a role's integration, that connection is used | 15 | // has exactly one connection for a role's integration, that connection is used |
| 17 | // as the default. Missing, unknown, or wrong-integration choices are typed | 16 | // as the default. Missing, unknown, or wrong-integration choices are typed |
| 18 | // BindingError failures. The handler receives clients under the declared role | 17 | // BindingError failures. The handler receives clients under the declared role |
| 19 | // names and `db`. | 18 | // names. |
| 20 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 21 | 20 | ||
| 22 | export class BindingError extends Data.TaggedError("BindingError")<{ | 21 | export class BindingError extends Data.TaggedError("BindingError")<{ |
| @@ -60,8 +59,6 @@ export interface BindingContext { · 37 unmodified lines | |||
| 60 | readonly declared: Readonly<Record<string, IntegrationDecl>>; | 59 | readonly declared: Readonly<Record<string, IntegrationDecl>>; |
| 61 | /** Resolved role -> connection address choices for this invocation. */ | 60 | /** Resolved role -> connection address choices for this invocation. */ |
| 62 | readonly bindings: RoleBindings; | 61 | readonly bindings: RoleBindings; |
| 63 | /** The invoking scope's app database (bound to the `db` root). */ | ||
| 64 | readonly db: ScopeDbHandle; | ||
| 65 | /** Routes a bound method call to the real integration. */ | 62 | /** Routes a bound method call to the real integration. */ |
| 66 | readonly resolver: ClientResolver; | 63 | readonly resolver: ClientResolver; |
| 67 | /** Caller-supplied invoke options to preserve approval/elicitation context. */ | 64 | /** Caller-supplied invoke options to preserve approval/elicitation context. */ |
| @@ -166,12 +163,12 @@ export const resolveIntegrationBindings = ( · 98 unmodified lines | |||
| 166 | return { input, bindings }; | 163 | return { input, bindings }; |
| 167 | }); | 164 | }); |
| 168 | 165 | ||
| 169 | /** Compute the sandbox handle roots: `db` plus one single root per declared | 166 | /** Compute the sandbox handle roots: one single root per declared integration |
| 170 | * integration role. */ | 167 | * role. */ |
| 171 | export const rootsFor = ( | 168 | export const rootsFor = ( |
| 172 | declared: Readonly<Record<string, IntegrationDecl>>, | 169 | declared: Readonly<Record<string, IntegrationDecl>>, |
| 173 | ): Readonly<Record<string, HandleRootSpec>> => { | 170 | ): Readonly<Record<string, HandleRootSpec>> => { |
| 174 | const roots: Record<string, HandleRootSpec> = { db: { kind: "single" } }; | 171 | const roots: Record<string, HandleRootSpec> = {}; |
| 175 | for (const role of Object.keys(declared)) { | 172 | for (const role of Object.keys(declared)) { |
| 176 | roots[role] = { kind: "single" }; | 173 | roots[role] = { kind: "single" }; |
| 177 | } | 174 | } |
| @@ -192,12 +189,11 @@ const invokeErr = (message: string): ToolSandboxError => · 14 unmodified lines | |||
| 192 | new ToolSandboxError({ kind: "invoke", message }); | 189 | new ToolSandboxError({ kind: "invoke", message }); |
| 193 | 190 | ||
| 194 | /** | 191 | /** |
| 195 | * Build the HandleBridge the sandbox calls out through. `db` routes to the | 192 | * Build the HandleBridge the sandbox calls out through. A declared role routes |
| 196 | * scope database; a declared role routes to its resolved connection through the | 193 | * to its resolved connection through the `ClientResolver`. The dispatch is |
| 197 | * `ClientResolver`. The dispatch is STRICT: an empty/malformed path, a reserved | 194 | * STRICT: an empty/malformed path, a reserved root, an undeclared root, or an |
| 198 | * root, an undeclared root, or an indexed root is an error, never a silent | 195 | * indexed root is an error, never a silent success. A `.account` read on a |
| 199 | * success. A `.account` read on a client returns bound-connection metadata | 196 | * client returns bound-connection metadata without a round-trip. |
| 200 | * without a round-trip. | ||
| 201 | */ | 197 | */ |
| 202 | export const buildBridge = (context: BindingContext): HandleBridge => ({ | 198 | export const buildBridge = (context: BindingContext): HandleBridge => ({ |
| 203 | call: ({ root, path, args }) => { | 199 | call: ({ root, path, args }) => { |
| @@ -211,23 +207,6 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 7 unmodified lines | |||
| 211 | return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`)); | 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 | const { role, index } = parseRoot(root); | 210 | const { role, index } = parseRoot(root); |
| 232 | if (index !== undefined) { | 211 | if (index !== undefined) { |
| 233 | return Effect.fail( | 212 | return Effect.fail( |
| @@ -7,7 +7,6 @@ import { Effect, Exit } from "effect"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | 8 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; | 9 | import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing"; |
| 10 | import { scopeAddress } from "../seams/scope-address"; | ||
| 11 | 10 | ||
| 12 | const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect); | 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 | describe("AppsRuntime end-to-end (publish -> invoke)", () => { | 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 | const store = makeInMemoryAppsStore(); | 39 | const store = makeInMemoryAppsStore(); |
| 41 | const resolver = makeTestResolver(githubHandlers, [ | 40 | const resolver = makeTestResolver(githubHandlers, [ |
| 42 | { address: "tools.github.user.rhys-github", integration: "github", name: "rhys-github" }, | 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 | tool: "issues-sync", | 60 | tool: "issues-sync", |
| 62 | args: { github: "tools.github.user.rhys-github" }, | 61 | args: { github: "tools.github.user.rhys-github" }, |
| 63 | }), | 62 | }), |
| 64 | )) as { synced: number; repos: number }; | 63 | )) as { |
| 65 | expect(syncResult).toEqual({ synced: 2, repos: 1 }); | 64 | synced: number; |
| 66 | 65 | repos: number; | |
| 67 | const db = await run(host.scopeDb.forScope(scopeAddress("org", "rhys"))); | 66 | issues: readonly { repo: string; number: number; title: string }[]; |
| 68 | const rows = await run(db.exec<{ n: number }>("SELECT COUNT(*) AS n FROM issues")); | 67 | }; |
| 69 | expect(Number(rows[0].n)).toBe(2); | 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 | await host.close(); | 77 | await host.close(); |
| 72 | }); | 78 | }); |
| @@ -2,7 +2,6 @@ import { Effect, Predicate } from "effect"; · 1 unmodified line | |||
| 2 | import type { InvokeOptions } from "@executor-js/sdk"; | 2 | import type { InvokeOptions } from "@executor-js/sdk"; |
| 3 | 3 | ||
| 4 | import type { ArtifactStore } from "../seams/artifact-store"; | 4 | import type { ArtifactStore } from "../seams/artifact-store"; |
| 5 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 6 | import { | 5 | import { |
| 7 | InputValidationError, | 6 | InputValidationError, |
| 8 | OutputValidationError, | 7 | OutputValidationError, |
| @@ -33,7 +32,6 @@ import type { GitHubSkippedArtifact } from "../source/github-source"; · 24 unmodified lines | |||
| 33 | 32 | ||
| 34 | export interface AppsRuntimeDeps { | 33 | export interface AppsRuntimeDeps { |
| 35 | readonly artifactStore: ArtifactStore; | 34 | readonly artifactStore: ArtifactStore; |
| 36 | readonly scopeDb: ScopeDb; | ||
| 37 | readonly sandbox: ToolSandbox; | 35 | readonly sandbox: ToolSandbox; |
| 38 | readonly store: AppsStore; | 36 | readonly store: AppsStore; |
| 39 | /** Routes a bound integration method call to the real API (policy/audit). */ | 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 | const roots = rootsFor(toolDesc.integrations); | 288 | const roots = rootsFor(toolDesc.integrations); |
| 291 | const code = yield* bundleFor(descriptor, toolDesc.sourcePath); | 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 | const bridge = buildBridge({ | 290 | const bridge = buildBridge({ |
| 303 | declared: toolDesc.integrations, | 291 | declared: toolDesc.integrations, |
| 304 | bindings: resolved.bindings, | 292 | bindings: resolved.bindings, |
| 305 | db, | ||
| 306 | resolver: activeResolver, | 293 | resolver: activeResolver, |
| 307 | invokeOptions, | 294 | invokeOptions, |
| 308 | }); | 295 | }); |
| @@ -434,9 +421,6 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 125 unmodified lines | |||
| 434 | yield* deps.artifactStore | 421 | yield* deps.artifactStore |
| 435 | .removeScope(address) | 422 | .removeScope(address) |
| 436 | .pipe(Effect.mapError(toPublishError("remove artifact scope failed"))); | 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 | import { join } from "node:path"; | 1 | import { join } from "node:path"; |
| 2 | 2 | ||
| 3 | import { Effect } from "effect"; | ||
| 4 | |||
| 5 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; | 3 | import { makeGitArtifactStore } from "../backing/git-artifact-store"; |
| 6 | import { makeLibsqlScopeDb } from "../backing/libsql-scope-db"; | ||
| 7 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; | 4 | import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox"; |
| 8 | import type { ScopeDb } from "../seams/scope-db"; | ||
| 9 | import { type AppsRuntime } from "./runtime"; | 5 | import { type AppsRuntime } from "./runtime"; |
| 10 | import type { AppsStore } from "./store"; | 6 | import type { AppsStore } from "./store"; |
| 11 | import type { ClientResolver } from "./bindings"; | 7 | import type { ClientResolver } from "./bindings"; |
| 12 | import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings"; | 8 | import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings"; |
| 13 | 9 | ||
| 14 | export interface SelfHostAppsRuntimeOptions { | 10 | export interface SelfHostAppsRuntimeOptions { |
| 15 | /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */ | 11 | /** Data dir root; `<root>/artifacts`. */ |
| 16 | readonly dataDir: string; | 12 | readonly dataDir: string; |
| 17 | /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */ | 13 | /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */ |
| 18 | readonly tenant?: string; | 14 | readonly tenant?: string; |
| @@ -26,7 +22,6 @@ export interface SelfHostAppsRuntimeOptions { · 7 unmodified lines | |||
| 26 | export interface SelfHostAppsRuntime { | 22 | export interface SelfHostAppsRuntime { |
| 27 | readonly runtime: AppsRuntime; | 23 | readonly runtime: AppsRuntime; |
| 28 | readonly backings: AppsBackings; | 24 | readonly backings: AppsBackings; |
| 29 | readonly scopeDb: ScopeDb; | ||
| 30 | readonly close: () => Promise<void>; | 25 | readonly close: () => Promise<void>; |
| 31 | } | 26 | } |
| 32 | 27 | ||
| @@ -37,13 +32,9 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines | |||
| 37 | const artifactStore = makeGitArtifactStore({ | 32 | const artifactStore = makeGitArtifactStore({ |
| 38 | root: inMem ? options.dataDir : join(options.dataDir, "artifacts"), | 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 | const sandbox = makeQuickjsToolSandbox(); | 35 | const sandbox = makeQuickjsToolSandbox(); |
| 44 | const backings: AppsBackings = { | 36 | const backings: AppsBackings = { |
| 45 | artifactStore, | 37 | artifactStore, |
| 46 | scopeDb, | ||
| 47 | sandbox, | 38 | sandbox, |
| 48 | store: options.store, | 39 | store: options.store, |
| 49 | resolver: options.resolver, | 40 | resolver: options.resolver, |
| @@ -54,9 +45,6 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines | |||
| 54 | return { | 45 | return { |
| 55 | runtime, | 46 | runtime, |
| 56 | backings, | 47 | backings, |
| 57 | scopeDb, | 48 | close: async () => {}, |
| 58 | close: async () => { | ||
| 59 | await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined))); | ||
| 60 | }, | ||
| 61 | }; | 49 | }; |
| 62 | }; | 50 | }; |
| @@ -6,7 +6,6 @@ import { describe, expect, it } from "@effect/vitest"; · 5 unmodified lines | |||
| 6 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 7 | 7 | ||
| 8 | import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; | 8 | import { makeSqliteAppsStore } from "../backing/sqlite-apps-store"; |
| 9 | import { scopeAddress } from "../seams/scope-address"; | ||
| 10 | import { syncGitHubSource } from "../source/github-source"; | 9 | import { syncGitHubSource } from "../source/github-source"; |
| 11 | import { makeTestResolver } from "../testing"; | 10 | import { makeTestResolver } from "../testing"; |
| 12 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; | 11 | import { makeSelfHostAppsRuntime } from "./self-host-runtime"; |
| @@ -18,11 +17,9 @@ import { z } from "zod"; · 5 unmodified lines | |||
| 18 | export default defineTool({ | 17 | export default defineTool({ |
| 19 | description: "${name}", | 18 | description: "${name}", |
| 20 | input: z.object({}), | 19 | input: z.object({}), |
| 21 | output: z.object({ ok: z.boolean() }), | 20 | output: z.object({ ok: z.boolean(), name: z.string() }), |
| 22 | async handler(_input, { db }) { | 21 | async handler() { |
| 23 | await db.sql\`CREATE TABLE IF NOT EXISTS markers (name TEXT NOT NULL)\`; | 22 | return { ok: true, name: ${JSON.stringify(name)} }; |
| 24 | await db.sql\`INSERT INTO markers (name) VALUES (${JSON.stringify(name)})\`; | ||
| 25 | return { ok: true }; | ||
| 26 | }, | 23 | }, |
| 27 | });`; | 24 | });`; |
| 28 | 25 | ||
| @@ -119,22 +116,6 @@ describe("tenant scope isolation", () => { · 90 unmodified lines | |||
| 119 | expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]); | 116 | expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]); |
| 120 | expect(descriptorB?.tools.map((tool) => tool.name)).toEqual(["beta"]); | 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 | await hostA.close(); | 119 | await hostA.close(); |
| 139 | await hostB.close(); | 120 | await hostB.close(); |
| 140 | }); | 121 | }); |
| @@ -2,5 +2,4 @@ · 1 unmodified line | |||
| 2 | // `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a | 2 | // `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a |
| 3 | // backing must pass, keeping a future Cloudflare backing honest. | 3 | // backing must pass, keeping a future Cloudflare backing honest. |
| 4 | export * from "./artifact-store"; | 4 | export * from "./artifact-store"; |
| 5 | export * from "./scope-db"; | ||
| 6 | export * from "./tool-sandbox"; | 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 | expect(seen[0].path).toEqual(["messages", "search"]); | 106 | expect(seen[0].path).toEqual(["messages", "search"]); |
| 107 | expect(seen[0].args[0]).toMatchObject({ q: "invoice" }); | 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 | * The serializable bridge the sandbox calls out through. `root` names an | 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 | * `path` is the method chain (`["events", "list"]`); `args` is the JSON call | 53 | * `path` is the method chain (`["events", "list"]`); `args` is the JSON call |
| 54 | * arguments. The return value is JSON. This is the ONE way sandboxed code | 54 | * arguments. The return value is JSON. This is the ONE way sandboxed code |
| 55 | * reaches the host — nothing else is wired. | 55 | * reaches the host — nothing else is wired. |
| @@ -62,9 +62,9 @@ export interface HandleBridge { · 6 unmodified lines | |||
| 62 | }) => Effect.Effect<unknown, ToolSandboxError>; | 62 | }) => Effect.Effect<unknown, ToolSandboxError>; |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | /** Which handle roots to inject. Each declared integration role and `db` are | 65 | /** Which handle roots to inject. Each declared integration role is a single |
| 66 | * single roots. Everything the handler can see is enumerated here — | 66 | * root. Everything the handler can see is enumerated here; undeclared roots |
| 67 | * undeclared roots are simply absent. */ | 67 | * are simply absent. */ |
| 68 | export interface HandleRootSpec { | 68 | export interface HandleRootSpec { |
| 69 | readonly kind: "single"; | 69 | readonly kind: "single"; |
| 70 | } | 70 | } |
| @@ -77,7 +77,7 @@ export interface InvokeRequest { · 6 unmodified lines | |||
| 77 | /** JSON input passed to the handler. */ | 77 | /** JSON input passed to the handler. */ |
| 78 | readonly input: unknown; | 78 | readonly input: unknown; |
| 79 | /** The handle roots to inject, keyed by the name the handler destructures | 79 | /** The handle roots to inject, keyed by the name the handler destructures |
| 80 | * (`github`, `db`, `inboxes`). */ | 80 | * (`github`, `inboxes`). */ |
| 81 | readonly roots: Readonly<Record<string, HandleRootSpec>>; | 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 | export default defineTool({ | 12 | export default defineTool({ |
| 13 | description: | 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 | integrations: { | 15 | integrations: { |
| 16 | github: integration("github"), | 16 | github: integration("github"), |
| 17 | }, | 17 | }, |
| @@ -19,38 +19,25 @@ export default defineTool({ · 1 unmodified line | |||
| 19 | repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"), | 19 | repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"), |
| 20 | since: z.string().optional().describe("ISO timestamp, only issues updated after this"), | 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 | annotations: { readOnly: false, destructive: false }, | 23 | annotations: { readOnly: false, destructive: false }, |
| 24 | async handler({ repos, since }, { github, db }) { | 24 | async handler({ repos, since }, { github }) { |
| 25 | const targets = | 25 | const targets = |
| 26 | repos ?? | 26 | repos ?? |
| 27 | (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name); | 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 | let synced = 0; | 29 | let synced = 0; |
| 30 | const collected = []; | ||
| 37 | for (const target of targets) { | 31 | for (const target of targets) { |
| 38 | const [owner, repo] = target.split("/"); | 32 | const [owner, repo] = target.split("/"); |
| 39 | const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 }); | 33 | const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 }); |
| 40 | for (const issue of issues) { | 34 | for (const issue of issues) { |
| 41 | if (issue.pull_request) continue; | 35 | if (issue.pull_request) continue; |
| 42 | await db.sql\` | 36 | collected.push({ repo: target, number: issue.number, title: issue.title }); |
| 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\`; | ||
| 50 | synced++; | 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 | `; |