History

Remove custom tools storage

Rhys Sullivan committed 4 months ago

Commit ff362d21 parent 4f647b525 files · +221 −617Browse files

Unified · Split

25 files
  1. apps/host-selfhost/src/apps-backings.ts +1 −10
  2. packages/plugins/apps/src/api.ts +1 −1
  3. packages/plugins/apps/src/authoring.ts +0 −3
  4. packages/plugins/apps/src/backing/libsql-scope-db.test.ts +0 −4
  5. packages/plugins/apps/src/backing/libsql-scope-db.ts +0 −187
  6. packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts +30 −6
  7. packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts +27 −1
  8. packages/plugins/apps/src/backing/scope-collision.test.ts +3 −37
  9. packages/plugins/apps/src/index.ts +1 −1
  10. packages/plugins/apps/src/pipeline/publish.test.ts +28 −0
  11. packages/plugins/apps/src/plugin/apps-plugin.test.ts +52 −47
  12. packages/plugins/apps/src/plugin/apps-plugin.ts +38 −20
  13. packages/plugins/apps/src/plugin/backings.ts +0 −3
  14. packages/plugins/apps/src/plugin/bindings.strict.test.ts +1 −13
  15. packages/plugins/apps/src/plugin/bindings.ts +9 −30
  16. packages/plugins/apps/src/plugin/runtime.test.ts +14 −8
  17. packages/plugins/apps/src/plugin/runtime.ts +0 −16
  18. packages/plugins/apps/src/plugin/self-host-runtime.ts +2 −14
  19. packages/plugins/apps/src/plugin/tenant-isolation.test.ts +3 −22
  20. packages/plugins/apps/src/seams/index.ts +0 −1
  21. packages/plugins/apps/src/seams/scope-db.conformance.ts +0 −70
  22. packages/plugins/apps/src/seams/scope-db.ts +0 −44
  23. packages/plugins/apps/src/seams/tool-sandbox.conformance.ts +0 −55
  24. packages/plugins/apps/src/seams/tool-sandbox.ts +5 −5
  25. packages/plugins/apps/src/testing/daily-brief.ts +6 −19
apps/host-selfhost/src/apps-backings.ts+1−10
@@ -1,10 +1,7 @@
11 import { join } from "node:path";
22
3−import { Effect } from "effect";
4−
53 import {
64 makeGitArtifactStore,
7− makeLibsqlScopeDb,
85 makeQuickjsToolSandbox,
96 makeSqliteAppsStore,
107 type AppsBackings,
@@ -19,19 +16,13 @@ interface SelfHostAppsBackings { · 8 unmodified lines
1916
2017 const createBackings = (dataDir: string): SelfHostAppsBackings => {
2118 const appsDir = join(dataDir, "apps");
22− const scopeDb = makeLibsqlScopeDb({
23− root: join(appsDir, "scope-db"),
24− });
2519 return {
2620 backings: {
2721 artifactStore: makeGitArtifactStore({ root: join(appsDir, "artifacts") }),
28− scopeDb,
2922 sandbox: makeQuickjsToolSandbox(),
3023 store: makeSqliteAppsStore({ path: join(appsDir, "store.sqlite") }),
3124 },
32− close: async () => {
33− await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
34− },
25+ close: async () => {},
3526 };
3627 };
3728
packages/plugins/apps/src/api.ts+1−1
@@ -28,7 +28,6 @@ export { · 27 unmodified lines
2828 export { type AppsRuntime, type GitHubCustomToolsSourceSummary } from "./plugin/runtime";
2929 export { makeAppsRuntimeFromBackings, type AppsBackings } from "./plugin/backings";
3030 export { makeGitArtifactStore } from "./backing/git-artifact-store";
31−export { makeLibsqlScopeDb } from "./backing/libsql-scope-db";
3231 export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";
3332 export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
3433 export {
@@ -38,6 +37,7 @@ export { · 3 unmodified lines
3837 type RoleBindings,
3938 } from "./plugin/bindings";
4039 export { makePluginCtxAppsResolver } from "./plugin/resolver";
40+export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin";
4141 export {
4242 fetchGitHubSource,
4343 parseGitHubSourceUrl,
packages/plugins/apps/src/authoring.ts+0−3
@@ -1,4 +1,3 @@
1−import type { ScopeDbHandle } from "./seams/scope-db";
21 import type { StandardSchemaV1 } from "./standard-schema";
32
43 export type JsonPrimitive = string | number | boolean | null;
@@ -16,8 +15,6 @@ export interface IntegrationDeclaration<Slug extends string = string> { · 11 unmodified lines
1615 export type ToolHandlerContext<
1716 TIntegrations extends Readonly<Record<string, IntegrationDeclaration>> | undefined,
1817 > = {
19− readonly db: ScopeDbHandle;
20−} & {
2118 readonly [K in keyof NonNullable<TIntegrations>]: unknown;
2219 };
2320
packages/plugins/apps/src/backing/libsql-scope-db.test.ts+0−4
@@ -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:" }));
packages/plugins/apps/src/backing/libsql-scope-db.ts+0−187
@@ -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−};
packages/plugins/apps/src/backing/quickjs-tool-sandbox.test.ts+30−6
@@ -31,7 +31,7 @@ describe("QuickJS ToolSandbox", () => { · 30 unmodified lines
3131 expect(descriptor.inputJsonSchema.properties).toHaveProperty("since");
3232 });
3333
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 () => {
3535 const files = new Map([["tools/issues-sync.ts", ISSUES_SYNC_TS]]);
3636 const { code } = await run(bundleEntry({ files, entry: "tools/issues-sync.ts" }));
3737
@@ -56,7 +56,6 @@ describe("QuickJS ToolSandbox", () => { · 18 unmodified lines
5656 },
5757 ];
5858 }
59− // db.sql calls return empty rows
6059 return [];
6160 }),
6261 };
@@ -68,18 +67,43 @@ describe("QuickJS ToolSandbox", () => { · 5 unmodified lines
6867 artifact: "issues-sync",
6968 kind: "tool",
7069 input: {},
71− roots: { github: { kind: "single" }, db: { kind: "single" } },
70+ roots: { github: { kind: "single" } },
7271 },
7372 bridge,
7473 ),
7574 );
7675
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+ });
8081 expect(calls.some((c) => c.root === "github")).toBe(true);
8182 });
8283
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+
83107 it("surfaces Standard Schema validation issues before the handler runs", async () => {
84108 const source = `import { defineTool } from "executor:app";
85109 import { z } from "zod";
packages/plugins/apps/src/backing/quickjs-tool-sandbox.ts+27−1
@@ -29,6 +29,8 @@ import { stableStringify } from "../pipeline/descriptor"; · 28 unmodified lines
2929 // `await __invokeTool("__handle__", { root, path, args })` — the ONE bridge the
3030 // QuickJS runtime already provides (`tools`/`__invokeTool`). Our
3131 // `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.
3234 // Everything crossing is JSON (the cloud version is RPC), so the interface
3335 // stays honest.
3436 //
@@ -63,6 +65,16 @@ function __mkHandle(root, prefix) { · 28 unmodified lines
6365 }
6466 });
6567 }
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+}
6678 var __executorApp = {
6779 integration: function(integration) { return { integration: integration }; },
6880 defineTool: function(def) { __defs.tool = def; return def; },
@@ -167,6 +179,16 @@ return await (async () => { · 98 unmodified lines
167179 });
168180 };
169181 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+ }
170192 var integrations = {};
171193 if (def && def.integrations) {
172194 for (var k in def.integrations) {
@@ -226,6 +248,7 @@ return await (async () => { · 53 unmodified lines
226248 if (spec.kind !== 'single') throw new Error('unsupported handle root kind: ' + spec.kind);
227249 injected[name] = __mkHandle(name, []);
228250 }
251+ injected.db = __unavailableStorage();
229252 var input = await validateWithStandardSchema('input', def.input, ${inputLiteral});
230253 var out = await def.handler(input, injected);
231254 out = await validateWithStandardSchema('output', def.output, out);
@@ -267,7 +290,10 @@ export const makeQuickjsToolSandbox = (options: QuickjsToolSandboxOptions = {}): · 35 unmodified lines
267290 Effect.flatMap((result) => {
268291 if (result.error) {
269292 const marker = parseMarker(result.error);
270− if (isMarker(marker, "SchemaExportError")) {
293+ if (
294+ isMarker(marker, "SchemaExportError") ||
295+ isMarker(marker, "StorageUnavailableError")
296+ ) {
271297 return Effect.fail(
272298 new ToolSandboxError({
273299 kind: "collect",
packages/plugins/apps/src/backing/scope-collision.test.ts+3−37
@@ -1,52 +1,18 @@
1−import { mkdtempSync, readdirSync } from "node:fs";
2−import { tmpdir } from "node:os";
3−import { join } from "node:path";
4−
51 import { describe, expect, it } from "@effect/vitest";
62 import { Effect } from "effect";
73
8−import { makeLibsqlScopeDb } from "./libsql-scope-db";
94 import { makeSqliteAppsStore } from "./sqlite-apps-store";
105 import { DESCRIPTOR_VERSION, type AppDescriptor } from "../pipeline/descriptor";
11−import { scopeAddress } from "../seams/scope-address";
126
137 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
148
159 // ---------------------------------------------------------------------------
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.
2213 // ---------------------------------------------------------------------------
2314
2415 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−
5016 it("stores distinct descriptor scopes independently", async () => {
5117 const store = makeSqliteAppsStore({ path: ":memory:" });
5218 const descriptor = (scope: string): AppDescriptor => ({
packages/plugins/apps/src/index.ts+1−1
@@ -52,6 +52,7 @@ export { · 51 unmodified lines
5252 type BindingContext,
5353 } from "./plugin/bindings";
5454 export { makePluginCtxAppsResolver } from "./plugin/resolver";
55+export { SourceOriginError, assertSourceOrigin } from "./plugin/apps-plugin";
5556 export {
5657 makeSelfHostAppsRuntime,
5758 type SelfHostAppsRuntime,
@@ -60,6 +61,5 @@ export { · 2 unmodified lines
6061
6162 // Self-host seam backings.
6263 export { makeGitArtifactStore } from "./backing/git-artifact-store";
63−export { makeLibsqlScopeDb } from "./backing/libsql-scope-db";
6464 export { makeQuickjsToolSandbox } from "./backing/quickjs-tool-sandbox";
6565 export { makeSqliteAppsStore } from "./backing/sqlite-apps-store";
packages/plugins/apps/src/pipeline/publish.test.ts+28−0
@@ -94,6 +94,34 @@ export default defineTool({ · 93 unmodified lines
9494 expect(JSON.stringify(exit)).toContain("crm");
9595 });
9696
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+
97125 it("rejects a Standard Schema vendor without JSON Schema export", async () => {
98126 const deps = makeDeps();
99127 const files = new Map<string, string>([
packages/plugins/apps/src/plugin/apps-plugin.test.ts+52−47
@@ -1,4 +1,4 @@
1−import { mkdtempSync, readFileSync } from "node:fs";
1+import { mkdtempSync } from "node:fs";
22 import { tmpdir } from "node:os";
33 import { join } from "node:path";
44
@@ -7,27 +7,13 @@ import { Effect } from "effect"; · 2 unmodified lines
77 import { IntegrationSlug } from "@executor-js/sdk";
88 import { makeTestExecutor } from "@executor-js/sdk/testing";
99
10−import { appsPlugin } from "./apps-plugin";
10+import { SourceOriginError, appsPlugin, assertSourceOrigin } from "./apps-plugin";
1111 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
1212 import { makeInMemoryAppsStore, makeTestResolver } from "../testing";
13+import { dailyBriefFileSet } from "../testing/daily-brief";
1314
1415 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
1516
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−
3117 describe("appsPlugin custom-tools contract", () => {
3218 it.effect("detects GitHub repo URLs for console auto-detect", () =>
3319 Effect.gen(function* () {
@@ -77,30 +63,41 @@ describe("appsPlugin custom-tools contract", () => { · 43 unmodified lines
7763 }),
7864 );
7965
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;
8279 const resolver = makeTestResolver(
8380 {
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" }];
8885 },
8986 },
90− "microsoft-sharepoint": {
91− "search.query": () => [],
87+ gmail: {
88+ "messages.search": () => ({ messages: [] }),
9289 },
9390 },
9491 [
9592 {
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",
9996 },
10097 {
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",
104101 },
105102 ],
106103 );
@@ -114,6 +111,7 @@ describe("appsPlugin custom-tools contract", () => { · 7 unmodified lines
114111 const plugin = appsPlugin({ backings: host.backings });
115112 const appIntegration = IntegrationSlug.make("rhys-tools");
116113 const appConfig = {
114+ origin: "github",
117115 kind: "github",
118116 repoUrl: "https://github.com/rhys/tools",
119117 repo: "rhys/tools",
@@ -142,7 +140,7 @@ describe("appsPlugin custom-tools contract", () => { · 22 unmodified lines
142140 },
143141 };
144142
145− await run(runtime.publish({ scope: "rhys", files: prototypeFileSet() }));
143+ await run(runtime.publish({ scope: "rhys", files: dailyBriefFileSet() }));
146144
147145 const resolved = await run(
148146 plugin.resolveTools!({
@@ -151,18 +149,18 @@ describe("appsPlugin custom-tools contract", () => { · 2 unmodified lines
151149 connection: { name: "main" },
152150 } as never),
153151 );
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");
155153 expect(syncTool).toBeTruthy();
156154 const persistedInputSchema = syncTool!.inputSchema as {
157155 properties: Record<string, unknown>;
158156 };
159− expect(persistedInputSchema.properties.crm).toBeUndefined();
157+ expect(persistedInputSchema.properties.github).toBeUndefined();
160158
161159 const projected = await run(
162160 plugin.projectToolSchema!({
163161 ctx,
164162 toolRow: {
165− name: "deal-pipeline-sync",
163+ name: "issues-sync",
166164 integration: appIntegration,
167165 connection: "main",
168166 },
@@ -174,31 +172,38 @@ describe("appsPlugin custom-tools contract", () => { · 5 unmodified lines
174172 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;
175173 required?: string[];
176174 };
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");
181179
182180 const output = await run(
183181 plugin.invokeTool!({
184182 ctx,
185183 toolRow: {
186− name: "deal-pipeline-sync",
184+ name: "issues-sync",
187185 integration: appIntegration,
188186 connection: "main",
189187 },
190188 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",
193192 },
194193 } as never),
195194 );
196195
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,
202207 });
203208 await host.close();
204209 });
packages/plugins/apps/src/plugin/apps-plugin.ts+38−20
@@ -1,4 +1,4 @@
1−import { Effect, Result } from "effect";
1+import { Data, Effect, Result } from "effect";
22
33 import {
44 AuthTemplateSlug,
@@ -24,7 +24,6 @@ import { · 19 unmodified lines
2424 import type { IntegrationDecl, ToolDescriptor } from "../pipeline/descriptor";
2525 import { PublishError } from "../pipeline/discover";
2626 import { ArtifactStoreError } from "../seams/artifact-store";
27−import { ScopeDbError } from "../seams/scope-db";
2827 import { ToolSandboxError } from "../seams/tool-sandbox";
2928 import {
3029 parseGitHubSourceUrl,
@@ -45,6 +44,7 @@ export const APPS_PLUGIN_ID = "apps"; · 14 unmodified lines
4544 const APP_CONNECTION_NAME = connectionIdentifier("main");
4645
4746 interface AppsGitHubSourceConfig {
47+ readonly origin: "github";
4848 readonly kind: "github";
4949 readonly repoUrl: string;
5050 readonly repo: string;
@@ -59,6 +59,29 @@ type ResolverFactory = (input: { · 8 unmodified lines
5959 readonly tool: string;
6060 }) => ClientResolver;
6161
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+
6285 export type AppsPluginOptions =
6386 | {
6487 readonly backings: AppsBackings;
@@ -171,21 +194,6 @@ const missingRuntime = (): AppsRuntime => ({ · 106 unmodified lines
171194 }),
172195 ),
173196 },
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− },
189197 sandbox: {
190198 collect: () =>
191199 Effect.fail(
@@ -221,7 +229,7 @@ const configBaseUrl = (config: unknown): string | undefined => · 29 unmodified lines
221229 : undefined;
222230
223231 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;
225233 const repoUrl = asString(config.repoUrl);
226234 const repo = asString(config.repo);
227235 const scope = asString(config.scope);
@@ -235,6 +243,7 @@ const decodeSourceConfig = (config: unknown): AppsGitHubSourceConfig | null => { · 7 unmodified lines
235243 }
236244 : undefined;
237245 return {
246+ origin: "github",
238247 kind: "github",
239248 repoUrl,
240249 repo,
@@ -440,10 +449,18 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 199 unmodified lines
440449 Effect.gen(function* () {
441450 const slug = IntegrationSlug.make(slugValue);
442451 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) {
445453 return syncFailure(`Custom tools source "${slugValue}" does not exist.`);
446454 }
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.`);
447464 const github = yield* ctx.core.integrations
448465 .get(IntegrationSlug.make("github"))
449466 .pipe(Effect.orElseSucceed(() => null));
@@ -542,6 +559,7 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 92 unmodified lines
542559 const descriptor = yield* runtime.getDescriptor(tenant, scope);
543560 const source = descriptor?.source?.kind === "github" ? descriptor.source : null;
544561 const config: AppsGitHubSourceConfig = {
562+ origin: "github",
545563 kind: "github",
546564 repoUrl: source?.url ?? input.url.trim(),
547565 repo: source?.repo ?? parsed.value.repo,
packages/plugins/apps/src/plugin/backings.ts+0−3
@@ -1,5 +1,4 @@
11 import type { ArtifactStore } from "../seams/artifact-store";
2−import type { ScopeDb } from "../seams/scope-db";
32 import type { ToolSandbox } from "../seams/tool-sandbox";
43 import { makeAppsRuntime, type AppsRuntime } from "./runtime";
54 import type { AppsStore } from "./store";
@@ -7,7 +6,6 @@ import type { ClientResolver } from "./bindings"; · 1 unmodified line
76
87 export interface AppsBackings {
98 readonly artifactStore: ArtifactStore;
10− readonly scopeDb: ScopeDb;
119 readonly sandbox: ToolSandbox;
1210 readonly store: AppsStore;
1311 readonly defaultTenant?: string;
@@ -20,7 +18,6 @@ export const makeAppsRuntimeFromBackings = ( · 6 unmodified lines
2018 ): AppsRuntime =>
2119 makeAppsRuntime({
2220 artifactStore: backings.artifactStore,
23− scopeDb: backings.scopeDb,
2421 sandbox: backings.sandbox,
2522 store: backings.store,
2623 resolver: backings.resolver ?? fallbackResolver,
packages/plugins/apps/src/plugin/bindings.strict.test.ts+1−13
@@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; · 2 unmodified lines
33
44 import { buildBridge, resolveIntegrationBindings, type ClientResolver } from "./bindings";
55 import type { IntegrationDecl } from "../pipeline/descriptor";
6−import type { ScopeDbHandle } from "../seams/scope-db";
76
87 // ---------------------------------------------------------------------------
98 // 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
1110 // malformed, reserved, undeclared, or out-of-range rather than resolve it.
1211 // ---------------------------------------------------------------------------
1312
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−
2113 const okResolver: ClientResolver = {
2214 listConnections: ({ integration }) =>
2315 Effect.succeed([
@@ -55,7 +47,7 @@ describe("HandleBridge strict dispatch", () => { · 31 unmodified lines
5547 const bindings = {
5648 gh: "tools.github.user.main",
5749 };
58− const bridge = buildBridge({ declared, bindings, db: okDb, resolver: okResolver });
50+ const bridge = buildBridge({ declared, bindings, resolver: okResolver });
5951
6052 it("rejects an empty method path", async () => {
6153 expect(await fails(bridge.call({ root: "gh", path: [], args: [] }))).toBe(true);
@@ -75,10 +67,6 @@ describe("HandleBridge strict dispatch", () => { · 13 unmodified lines
7567 );
7668 });
7769
78− it("rejects an unsupported db call", async () => {
79− expect(await fails(bridge.call({ root: "db", path: ["drop"], args: [] }))).toBe(true);
80− });
81−
8270 it("routes a valid single-connection call through the resolver", async () => {
8371 const out = await Effect.runPromise(
8472 bridge.call({ root: "gh", path: ["repos", "list"], args: [{}] }),
packages/plugins/apps/src/plugin/bindings.ts+9−30
@@ -4,7 +4,6 @@ import type { InvokeOptions } from "@executor-js/sdk"; · 3 unmodified lines
44
55 import type { HandleBridge, HandleRootSpec } from "../seams/tool-sandbox";
66 import { ToolSandboxError } from "../seams/tool-sandbox";
7−import type { ScopeDbHandle } from "../seams/scope-db";
87 import type { IntegrationDecl } from "../pipeline/descriptor";
98
109 // ---------------------------------------------------------------------------
@@ -16,7 +15,7 @@ import type { IntegrationDecl } from "../pipeline/descriptor"; · 5 unmodified lines
1615 // has exactly one connection for a role's integration, that connection is used
1716 // as the default. Missing, unknown, or wrong-integration choices are typed
1817 // BindingError failures. The handler receives clients under the declared role
19−// names and `db`.
18+// names.
2019 // ---------------------------------------------------------------------------
2120
2221 export class BindingError extends Data.TaggedError("BindingError")<{
@@ -60,8 +59,6 @@ export interface BindingContext { · 37 unmodified lines
6059 readonly declared: Readonly<Record<string, IntegrationDecl>>;
6160 /** Resolved role -> connection address choices for this invocation. */
6261 readonly bindings: RoleBindings;
63− /** The invoking scope's app database (bound to the `db` root). */
64− readonly db: ScopeDbHandle;
6562 /** Routes a bound method call to the real integration. */
6663 readonly resolver: ClientResolver;
6764 /** Caller-supplied invoke options to preserve approval/elicitation context. */
@@ -166,12 +163,12 @@ export const resolveIntegrationBindings = ( · 98 unmodified lines
166163 return { input, bindings };
167164 });
168165
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. */
171168 export const rootsFor = (
172169 declared: Readonly<Record<string, IntegrationDecl>>,
173170 ): Readonly<Record<string, HandleRootSpec>> => {
174− const roots: Record<string, HandleRootSpec> = { db: { kind: "single" } };
171+ const roots: Record<string, HandleRootSpec> = {};
175172 for (const role of Object.keys(declared)) {
176173 roots[role] = { kind: "single" };
177174 }
@@ -192,12 +189,11 @@ const invokeErr = (message: string): ToolSandboxError => · 14 unmodified lines
192189 new ToolSandboxError({ kind: "invoke", message });
193190
194191 /**
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.
201197 */
202198 export const buildBridge = (context: BindingContext): HandleBridge => ({
203199 call: ({ root, path, args }) => {
@@ -211,23 +207,6 @@ export const buildBridge = (context: BindingContext): HandleBridge => ({ · 7 unmodified lines
211207 return Effect.fail(invokeErr(`reserved handle root is not callable: ${root}`));
212208 }
213209
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−
231210 const { role, index } = parseRoot(root);
232211 if (index !== undefined) {
233212 return Effect.fail(
packages/plugins/apps/src/plugin/runtime.test.ts+14−8
@@ -7,7 +7,6 @@ import { Effect, Exit } from "effect"; · 6 unmodified lines
77
88 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
99 import { makeInMemoryAppsStore, makeTestResolver, dailyBriefFileSet } from "../testing";
10−import { scopeAddress } from "../seams/scope-address";
1110
1211 const run = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => Effect.runPromise(effect);
1312
@@ -36,7 +35,7 @@ const githubHandlers = { · 22 unmodified lines
3635 };
3736
3837 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 () => {
4039 const store = makeInMemoryAppsStore();
4140 const resolver = makeTestResolver(githubHandlers, [
4241 { 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
6160 tool: "issues-sync",
6261 args: { github: "tools.github.user.rhys-github" },
6362 }),
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+ });
7076
7177 await host.close();
7278 });
packages/plugins/apps/src/plugin/runtime.ts+0−16
@@ -2,7 +2,6 @@ import { Effect, Predicate } from "effect"; · 1 unmodified line
22 import type { InvokeOptions } from "@executor-js/sdk";
33
44 import type { ArtifactStore } from "../seams/artifact-store";
5−import type { ScopeDb } from "../seams/scope-db";
65 import {
76 InputValidationError,
87 OutputValidationError,
@@ -33,7 +32,6 @@ import type { GitHubSkippedArtifact } from "../source/github-source"; · 24 unmodified lines
3332
3433 export interface AppsRuntimeDeps {
3534 readonly artifactStore: ArtifactStore;
36− readonly scopeDb: ScopeDb;
3735 readonly sandbox: ToolSandbox;
3836 readonly store: AppsStore;
3937 /** 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
289287 );
290288 const roots = rootsFor(toolDesc.integrations);
291289 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− );
302290 const bridge = buildBridge({
303291 declared: toolDesc.integrations,
304292 bindings: resolved.bindings,
305− db,
306293 resolver: activeResolver,
307294 invokeOptions,
308295 });
@@ -434,9 +421,6 @@ export const makeAppsRuntime = (deps: AppsRuntimeDeps): AppsRuntime => { · 125 unmodified lines
434421 yield* deps.artifactStore
435422 .removeScope(address)
436423 .pipe(Effect.mapError(toPublishError("remove artifact scope failed")));
437− yield* deps.scopeDb
438− .removeScope(address)
439− .pipe(Effect.mapError(toPublishError("remove scope db failed")));
440424 });
441425 },
442426
packages/plugins/apps/src/plugin/self-host-runtime.ts+2−14
@@ -1,18 +1,14 @@
11 import { join } from "node:path";
22
3−import { Effect } from "effect";
4−
53 import { makeGitArtifactStore } from "../backing/git-artifact-store";
6−import { makeLibsqlScopeDb } from "../backing/libsql-scope-db";
74 import { makeQuickjsToolSandbox } from "../backing/quickjs-tool-sandbox";
8−import type { ScopeDb } from "../seams/scope-db";
95 import { type AppsRuntime } from "./runtime";
106 import type { AppsStore } from "./store";
117 import type { ClientResolver } from "./bindings";
128 import { makeAppsRuntimeFromBackings, type AppsBackings } from "./backings";
139
1410 export interface SelfHostAppsRuntimeOptions {
15− /** Data dir root; `<root>/artifacts` and `<root>/scope-db`. */
11+ /** Data dir root; `<root>/artifacts`. */
1612 readonly dataDir: string;
1713 /** Default tenant for direct runtime calls; request paths pass tenant explicitly. */
1814 readonly tenant?: string;
@@ -26,7 +22,6 @@ export interface SelfHostAppsRuntimeOptions { · 7 unmodified lines
2622 export interface SelfHostAppsRuntime {
2723 readonly runtime: AppsRuntime;
2824 readonly backings: AppsBackings;
29− readonly scopeDb: ScopeDb;
3025 readonly close: () => Promise<void>;
3126 }
3227
@@ -37,13 +32,9 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines
3732 const artifactStore = makeGitArtifactStore({
3833 root: inMem ? options.dataDir : join(options.dataDir, "artifacts"),
3934 });
40− const scopeDb = makeLibsqlScopeDb({
41− root: inMem ? ":memory:" : join(options.dataDir, "scope-db"),
42− });
4335 const sandbox = makeQuickjsToolSandbox();
4436 const backings: AppsBackings = {
4537 artifactStore,
46− scopeDb,
4738 sandbox,
4839 store: options.store,
4940 resolver: options.resolver,
@@ -54,9 +45,6 @@ export const makeSelfHostAppsRuntime = ( · 4 unmodified lines
5445 return {
5546 runtime,
5647 backings,
57− scopeDb,
58− close: async () => {
59− await Effect.runPromise(scopeDb.close().pipe(Effect.orElseSucceed(() => undefined)));
60− },
48+ close: async () => {},
6149 };
6250 };
packages/plugins/apps/src/plugin/tenant-isolation.test.ts+3−22
@@ -6,7 +6,6 @@ import { describe, expect, it } from "@effect/vitest"; · 5 unmodified lines
66 import { Effect } from "effect";
77
88 import { makeSqliteAppsStore } from "../backing/sqlite-apps-store";
9−import { scopeAddress } from "../seams/scope-address";
109 import { syncGitHubSource } from "../source/github-source";
1110 import { makeTestResolver } from "../testing";
1211 import { makeSelfHostAppsRuntime } from "./self-host-runtime";
@@ -18,11 +17,9 @@ import { z } from "zod"; · 5 unmodified lines
1817 export default defineTool({
1918 description: "${name}",
2019 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)} };
2623 },
2724 });`;
2825
@@ -119,22 +116,6 @@ describe("tenant scope isolation", () => { · 90 unmodified lines
119116 expect(descriptorA?.tools.map((tool) => tool.name)).toEqual(["alpha"]);
120117 expect(descriptorB?.tools.map((tool) => tool.name)).toEqual(["beta"]);
121118
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−
138119 await hostA.close();
139120 await hostB.close();
140121 });
packages/plugins/apps/src/seams/index.ts+0−1
@@ -2,5 +2,4 @@ · 1 unmodified line
22 // `src/backing/`; each seam has a conformance suite (`*.conformance.ts`) that a
33 // backing must pass, keeping a future Cloudflare backing honest.
44 export * from "./artifact-store";
5−export * from "./scope-db";
65 export * from "./tool-sandbox";
packages/plugins/apps/src/seams/scope-db.conformance.ts+0−70
@@ -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−};
packages/plugins/apps/src/seams/scope-db.ts+0−44
@@ -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−}
packages/plugins/apps/src/seams/tool-sandbox.conformance.ts+0−55
@@ -106,60 +106,5 @@ export default defineTool({ · 105 unmodified lines
106106 expect(seen[0].path).toEqual(["messages", "search"]);
107107 expect(seen[0].args[0]).toMatchObject({ q: "invoice" });
108108 });
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− });
164109 });
165110 };
packages/plugins/apps/src/seams/tool-sandbox.ts+5−5
@@ -49,7 +49,7 @@ export class OutputValidationError extends Data.TaggedError("OutputValidationErr · 48 unmodified lines
4949
5050 /**
5151 * 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);
5353 * `path` is the method chain (`["events", "list"]`); `args` is the JSON call
5454 * arguments. The return value is JSON. This is the ONE way sandboxed code
5555 * reaches the host — nothing else is wired.
@@ -62,9 +62,9 @@ export interface HandleBridge { · 6 unmodified lines
6262 }) => Effect.Effect<unknown, ToolSandboxError>;
6363 }
6464
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. */
6868 export interface HandleRootSpec {
6969 readonly kind: "single";
7070 }
@@ -77,7 +77,7 @@ export interface InvokeRequest { · 6 unmodified lines
7777 /** JSON input passed to the handler. */
7878 readonly input: unknown;
7979 /** The handle roots to inject, keyed by the name the handler destructures
80− * (`github`, `db`, `inboxes`). */
80+ * (`github`, `inboxes`). */
8181 readonly roots: Readonly<Record<string, HandleRootSpec>>;
8282 }
8383
packages/plugins/apps/src/testing/daily-brief.ts+6−19
@@ -11,7 +11,7 @@ import { defineTool, integration } from "executor:app"; · 10 unmodified lines
1111
1212 export default defineTool({
1313 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).",
1515 integrations: {
1616 github: integration("github"),
1717 },
@@ -19,38 +19,25 @@ export default defineTool({ · 1 unmodified line
1919 repos: z.array(z.string()).optional().describe("owner/repo entries; omit to sync all accessible repos"),
2020 since: z.string().optional().describe("ISO timestamp, only issues updated after this"),
2121 }),
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() })) }),
2323 annotations: { readOnly: false, destructive: false },
24− async handler({ repos, since }, { github, db }) {
24+ async handler({ repos, since }, { github }) {
2525 const targets =
2626 repos ??
2727 (await github.repos.listForAuthenticatedUser({ per_page: 100 })).map((r) => r.full_name);
2828
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−
3629 let synced = 0;
30+ const collected = [];
3731 for (const target of targets) {
3832 const [owner, repo] = target.split("/");
3933 const issues = await github.issues.listForRepo({ owner, repo, state: "open", since, per_page: 100 });
4034 for (const issue of issues) {
4135 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 });
5037 synced++;
5138 }
5239 }
53− return { synced, repos: targets.length };
40+ return { synced, repos: targets.length, issues: collected };
5441 },
5542 });
5643 `;