History

Merge remote-tracking branch 'origin/codex/batch17-core-executor-boundaries' into codex/effect-lint-integration

Rhys Sullivan committed 6 months ago

Commit cd10e672 parents 36b20f5 44315a81 file · +340 −591Browse files

Unified · Split

packages/core/sdk/src/executor.ts+340−591
@@ -10,10 +10,7 @@ import { · 9 unmodified lines
10 type TypedAdapter,10 type TypedAdapter,
11} from "@executor-js/storage-core";11} from "@executor-js/storage-core";
12 12
13import {13import { pluginBlobStore, type BlobStore } from "./blob";
14 pluginBlobStore,
15 type BlobStore,
16} from "./blob";
17import {14import {
18 ConnectionProviderState,15 ConnectionProviderState,
19 ConnectionRef,16 ConnectionRef,
@@ -81,11 +78,7 @@ import type { · 61 unmodified lines
81 StorageDeps,78 StorageDeps,
82} from "./plugin";79} from "./plugin";
83import type { Scope } from "./scope";80import type { Scope } from "./scope";
84import {81import { SecretRef, SetSecretInput, type SecretProvider } from "./secrets";
85 SecretRef,
86 SetSecretInput,
87 type SecretProvider,
88} from "./secrets";
89import { Usage } from "./usages";82import { Usage } from "./usages";
90import {83import {
91 ToolSchema,84 ToolSchema,
@@ -126,9 +119,7 @@ export interface InvokeOptions { · 34 unmodified lines
126const acceptAllHandler: ElicitationHandler = () =>119const acceptAllHandler: ElicitationHandler = () =>
127 Effect.succeed(new ElicitationResponse({ action: "accept" }));120 Effect.succeed(new ElicitationResponse({ action: "accept" }));
128 121
129const resolveElicitationHandler = (122const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler =>
130 onElicitation: OnElicitation,
131): ElicitationHandler =>
132 onElicitation === "accept-all" ? acceptAllHandler : onElicitation;123 onElicitation === "accept-all" ? acceptAllHandler : onElicitation;
133 124
134// ---------------------------------------------------------------------------125// ---------------------------------------------------------------------------
@@ -148,16 +139,12 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 13 unmodified lines
148 readonly scopes: readonly Scope[];139 readonly scopes: readonly Scope[];
149 140
150 readonly tools: {141 readonly tools: {
151 readonly list: (142 readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>;
152 filter?: ToolListFilter,
153 ) => Effect.Effect<readonly Tool[], StorageFailure>;
154 /** Fetch a tool's full schema view: JSON schemas with `$defs`143 /** Fetch a tool's full schema view: JSON schemas with `$defs`
155 * attached from the core `definition` table, plus TypeScript144 * attached from the core `definition` table, plus TypeScript
156 * preview strings rendered from them. Returns `null` for unknown145 * preview strings rendered from them. Returns `null` for unknown
157 * tool ids. */146 * tool ids. */
158 readonly schema: (147 readonly schema: (toolId: string) => Effect.Effect<ToolSchema | null, StorageFailure>;
159 toolId: string,
160 ) => Effect.Effect<ToolSchema | null, StorageFailure>;
161 /** Every `$defs` entry across every source, grouped by source id.148 /** Every `$defs` entry across every source, grouped by source id.
162 * Used for bulk schema export and downstream TypeScript rendering. */149 * Used for bulk schema export and downstream TypeScript rendering. */
163 readonly definitions: () => Effect.Effect<150 readonly definitions: () => Effect.Effect<
@@ -206,12 +193,8 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 42 unmodified lines
206 * only, never calls the provider. Use this for UI state ("secret193 * only, never calls the provider. Use this for UI state ("secret
207 * missing, prompt to add") to avoid keychain permission prompts194 * missing, prompt to add") to avoid keychain permission prompts
208 * or 1password IPC roundtrips on a pre-flight check. */195 * or 1password IPC roundtrips on a pre-flight check. */
209 readonly status: (196 readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>;
210 id: string,197 readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, StorageFailure>;
211 ) => Effect.Effect<"resolved" | "missing", StorageFailure>;
212 readonly set: (
213 input: SetSecretInput,
214 ) => Effect.Effect<SecretRef, StorageFailure>;
215 /** Delete a bare (non-connection-owned) secret. Connection-owned198 /** Delete a bare (non-connection-owned) secret. Connection-owned
216 * secrets are rejected with `SecretOwnedByConnectionError` — use199 * secrets are rejected with `SecretOwnedByConnectionError` — use
217 * `connections.remove` instead. Refuses with `SecretInUseError`200 * `connections.remove` instead. Refuses with `SecretInUseError`
@@ -219,37 +202,24 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 1 unmodified line
219 * show the `usages(id)` list and ask the user to detach first. */202 * show the `usages(id)` list and ask the user to detach first. */
220 readonly remove: (203 readonly remove: (
221 id: string,204 id: string,
222 ) => Effect.Effect<205 ) => Effect.Effect<void, SecretOwnedByConnectionError | SecretInUseError | StorageFailure>;
223 void,
224 SecretOwnedByConnectionError | SecretInUseError | StorageFailure
225 >;
226 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;206 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
227 /** All places this secret is referenced — fans out across every207 /** All places this secret is referenced — fans out across every
228 * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by"208 * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by"
229 * list and by `remove` for its RESTRICT check. */209 * list and by `remove` for its RESTRICT check. */
230 readonly usages: (210 readonly usages: (id: string) => Effect.Effect<readonly Usage[], StorageFailure>;
231 id: string,
232 ) => Effect.Effect<readonly Usage[], StorageFailure>;
233 readonly providers: () => Effect.Effect<readonly string[]>;211 readonly providers: () => Effect.Effect<readonly string[]>;
234 };212 };
235 213
236 readonly connections: {214 readonly connections: {
237 readonly get: (215 readonly get: (id: string) => Effect.Effect<ConnectionRef | null, StorageFailure>;
238 id: string,
239 ) => Effect.Effect<ConnectionRef | null, StorageFailure>;
240 readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>;216 readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>;
241 readonly create: (217 readonly create: (
242 input: CreateConnectionInput,218 input: CreateConnectionInput,
243 ) => Effect.Effect<219 ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>;
244 ConnectionRef,
245 ConnectionProviderNotRegisteredError | StorageFailure
246 >;
247 readonly updateTokens: (220 readonly updateTokens: (
248 input: UpdateConnectionTokensInput,221 input: UpdateConnectionTokensInput,
249 ) => Effect.Effect<222 ) => Effect.Effect<ConnectionRef, ConnectionNotFoundError | StorageFailure>;
250 ConnectionRef,
251 ConnectionNotFoundError | StorageFailure
252 >;
253 readonly setIdentityLabel: (223 readonly setIdentityLabel: (
254 id: string,224 id: string,
255 label: string | null,225 label: string | null,
@@ -267,14 +237,10 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 11 unmodified lines
267 >;237 >;
268 /** Refuses with `ConnectionInUseError` if any plugin reports the238 /** Refuses with `ConnectionInUseError` if any plugin reports the
269 * connection as in use. */239 * connection as in use. */
270 readonly remove: (240 readonly remove: (id: string) => Effect.Effect<void, ConnectionInUseError | StorageFailure>;
271 id: string,
272 ) => Effect.Effect<void, ConnectionInUseError | StorageFailure>;
273 /** All places this connection is referenced — fans out across every241 /** All places this connection is referenced — fans out across every
274 * plugin's `usagesForConnection`. */242 * plugin's `usagesForConnection`. */
275 readonly usages: (243 readonly usages: (id: string) => Effect.Effect<readonly Usage[], StorageFailure>;
276 id: string,
277 ) => Effect.Effect<readonly Usage[], StorageFailure>;
278 readonly providers: () => Effect.Effect<readonly string[]>;244 readonly providers: () => Effect.Effect<readonly string[]>;
279 };245 };
280 246
@@ -289,28 +255,20 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 8 unmodified lines
289 readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;255 readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;
290 /** Create a new policy. Defaults to the top of the target scope's256 /** Create a new policy. Defaults to the top of the target scope's
291 * list (highest precedence) when `position` is omitted. */257 * list (highest precedence) when `position` is omitted. */
292 readonly create: (258 readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;
293 input: CreateToolPolicyInput,259 readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;
294 ) => Effect.Effect<ToolPolicy, StorageFailure>;
295 readonly update: (
296 input: UpdateToolPolicyInput,
297 ) => Effect.Effect<ToolPolicy, StorageFailure>;
298 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;260 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
299 /** Resolve the effective policy for a tool id by walking the scope-261 /** Resolve the effective policy for a tool id by walking the scope-
300 * stacked policy list with first-match-wins semantics. Returns262 * stacked policy list with first-match-wins semantics. Returns
301 * `undefined` when no rule matches (caller falls back to the263 * `undefined` when no rule matches (caller falls back to the
302 * plugin's `resolveAnnotations` output). */264 * plugin's `resolveAnnotations` output). */
303 readonly resolve: (265 readonly resolve: (toolId: string) => Effect.Effect<PolicyMatch | undefined, StorageFailure>;
304 toolId: string,
305 ) => Effect.Effect<PolicyMatch | undefined, StorageFailure>;
306 };266 };
307 267
308 readonly close: () => Effect.Effect<void, StorageFailure>;268 readonly close: () => Effect.Effect<void, StorageFailure>;
309} & PluginExtensions<TPlugins>;269} & PluginExtensions<TPlugins>;
310 270
311export interface ExecutorConfig<271export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> {
312 TPlugins extends readonly AnyPlugin[] = [],
313> {
314 /**272 /**
315 * Precedence-ordered scope stack. Innermost first; typical shape is273 * Precedence-ordered scope stack. Innermost first; typical shape is
316 * `[userInOrgScope, orgScope]`. Reads on scoped tables walk the274 * `[userInOrgScope, orgScope]`. Reads on scoped tables walk the
@@ -339,18 +297,19 @@ export interface ExecutorConfig< · 22 unmodified lines
339// manifest) before constructing the executor.297// manifest) before constructing the executor.
340// ---------------------------------------------------------------------------298// ---------------------------------------------------------------------------
341 299
342export const collectSchemas = (300export const collectSchemas = (plugins: readonly AnyPlugin[]): DBSchema => {
343 plugins: readonly AnyPlugin[],
344): DBSchema => {
345 const merged: Record<string, DBSchema[string]> = { ...coreSchema };301 const merged: Record<string, DBSchema[string]> = { ...coreSchema };
346 for (const plugin of plugins) {302 for (const plugin of plugins) {
347 if (!plugin.schema) continue;303 if (!plugin.schema) continue;
348 for (const [modelKey, model] of Object.entries(plugin.schema)) {304 for (const [modelKey, model] of Object.entries(plugin.schema)) {
349 if (merged[modelKey]) {305 if (merged[modelKey]) {
350 throw new Error(306 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: collectSchemas is a synchronous configuration API
351 `Duplicate model "${modelKey}" contributed by plugin "${plugin.id}"` +307 throw new StorageError({
308 message:
309 `Duplicate model "${modelKey}" contributed by plugin "${plugin.id}"` +
352 ` (reserved by core or another plugin)`,310 ` (reserved by core or another plugin)`,
353 );311 cause: undefined,
312 });
354 }313 }
355 merged[modelKey] = model as DBSchema[string];314 merged[modelKey] = model as DBSchema[string];
356 }315 }
@@ -375,10 +334,7 @@ const rowToSource = (row: SourceRow): Source => ({ · 18 unmodified lines
375 runtime: false,334 runtime: false,
376});335});
377 336
378const staticDeclToSource = (337const staticDeclToSource = (decl: StaticSourceDecl, pluginId: string): Source => ({
379 decl: StaticSourceDecl,
380 pluginId: string,
381): Source => ({
382 id: decl.id,338 id: decl.id,
383 scopeId: undefined,339 scopeId: undefined,
384 kind: decl.kind,340 kind: decl.kind,
@@ -394,17 +350,12 @@ const staticDeclToSource = ( · 9 unmodified lines
394const decodeJsonColumn = (value: unknown): unknown => {350const decodeJsonColumn = (value: unknown): unknown => {
395 if (value === null || value === undefined) return undefined;351 if (value === null || value === undefined) return undefined;
396 if (typeof value !== "string") return value;352 if (typeof value !== "string") return value;
397 try {353 return Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(value).pipe(
398 return JSON.parse(value);354 Option.getOrElse(() => value),
399 } catch {355 );
400 return value;
401 }
402};356};
403 357
404const rowToTool = (358const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({
405 row: ToolRow,
406 annotations?: ToolAnnotations,
407): Tool => ({
408 id: row.id,359 id: row.id,
409 sourceId: row.source_id,360 sourceId: row.source_id,
410 pluginId: row.plugin_id,361 pluginId: row.plugin_id,
@@ -579,10 +530,9 @@ const toolMatchesFilter = (tool: Tool, filter: ToolListFilter): boolean => { · 168 unmodified lines
579// query through the same sql.begin connection. This is what makes nested530// query through the same sql.begin connection. This is what makes nested
580// writes atomic on postgres + Hyperdrive without deadlocking a pool of 1.531// writes atomic on postgres + Hyperdrive without deadlocking a pool of 1.
581// ---------------------------------------------------------------------------532// ---------------------------------------------------------------------------
582const activeAdapterRef = Context.Reference<DBTransactionAdapter | null>(533const activeAdapterRef = Context.Reference<DBTransactionAdapter | null>("executor/ActiveAdapter", {
583 "executor/ActiveAdapter",534 defaultValue: () => null,
584 { defaultValue: () => null },535});
585);
586 536
587// A `DBAdapter` whose methods dispatch to the active adapter (tx handle or537// A `DBAdapter` whose methods dispatch to the active adapter (tx handle or
588// root) on every call. Stable identity for consumers (plugin storage,538// root) on every call. Stable identity for consumers (plugin storage,
@@ -593,7 +543,7 @@ const buildAdapterRouter = (root: DBAdapter): DBAdapter => { · 4 unmodified lines
593 use: (active: DBTransactionAdapter) => Effect.Effect<A, E>,543 use: (active: DBTransactionAdapter) => Effect.Effect<A, E>,
594 ): Effect.Effect<A, E> =>544 ): Effect.Effect<A, E> =>
595 Effect.flatMap(Effect.service(activeAdapterRef), (active) =>545 Effect.flatMap(Effect.service(activeAdapterRef), (active) =>
596 use((active ?? (root as DBTransactionAdapter))),546 use(active ?? (root as DBTransactionAdapter)),
597 );547 );
598 548
599 return {549 return {
@@ -646,27 +596,21 @@ interface PluginRuntime { · 46 unmodified lines
646 readonly ctx: PluginCtx<unknown>;596 readonly ctx: PluginCtx<unknown>;
647}597}
648 598
649export const createExecutor = <599export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []>(
650 const TPlugins extends readonly AnyPlugin[] = [],
651>(
652 config: ExecutorConfig<TPlugins>,600 config: ExecutorConfig<TPlugins>,
653): Effect.Effect<Executor<TPlugins>, Error> =>601): Effect.Effect<Executor<TPlugins>, StorageFailure> =>
654 Effect.gen(function* () {602 Effect.gen(function* () {
655 const defaultPlugins = (): TPlugins => {603 const defaultPlugins = (): TPlugins => {
656 const empty: readonly AnyPlugin[] = [];604 const empty: readonly AnyPlugin[] = [];
657 return empty as TPlugins;605 return empty as TPlugins;
658 };606 };
659 const {607 const { scopes, adapter: rootAdapter, blobs, plugins = defaultPlugins() } = config;
660 scopes,
661 adapter: rootAdapter,
662 blobs,
663 plugins = defaultPlugins(),
664 } = config;
665 608
666 if (scopes.length === 0) {609 if (scopes.length === 0) {
667 return yield* Effect.fail(610 return yield* new StorageError({
668 new Error("createExecutor requires a non-empty scopes array"),611 message: "createExecutor requires a non-empty scopes array",
669 );612 cause: undefined,
613 });
670 }614 }
671 615
672 // Scope-wrap the root adapter so every read on a tenant-scoped616 // Scope-wrap the root adapter so every read on a tenant-scoped
@@ -677,12 +621,8 @@ export const createExecutor = < · 4 unmodified lines
677 // visible. Only tables whose schema declares `scope_id` are621 // visible. Only tables whose schema declares `scope_id` are
678 // scoped.622 // scoped.
679 const schema = collectSchemas(plugins);623 const schema = collectSchemas(plugins);
680 const scopeIds = scopes.map((s) => s.id as string);624 const scopeIds = scopes.map((s) => s.id);
681 const scopedRoot = scopeAdapter(625 const scopedRoot = scopeAdapter(rootAdapter, { scopes: scopeIds }, schema);
682 rootAdapter,
683 { scopes: scopeIds },
684 schema,
685 );
686 const adapter = buildAdapterRouter(scopedRoot);626 const adapter = buildAdapterRouter(scopedRoot);
687 const core = typedAdapter<CoreSchema>(adapter);627 const core = typedAdapter<CoreSchema>(adapter);
688 628
@@ -703,9 +643,7 @@ export const createExecutor = < · 14 unmodified lines
703 ["google-discovery:google", "oauth2"],643 ["google-discovery:google", "oauth2"],
704 ["google-discovery:oauth2", "oauth2"],644 ["google-discovery:oauth2", "oauth2"],
705 ]);645 ]);
706 const resolveConnectionProvider = (646 const resolveConnectionProvider = (key: string): ConnectionProvider | undefined => {
707 key: string,
708 ): ConnectionProvider | undefined => {
709 const direct = connectionProviders.get(key);647 const direct = connectionProviders.get(key);
710 if (direct) return direct;648 if (direct) return direct;
711 const canonical = connectionProviderAliases.get(key);649 const canonical = connectionProviderAliases.get(key);
@@ -760,8 +698,12 @@ export const createExecutor = < · 48 unmodified lines
760 // Rows whose scope isn't in the stack get pushed to the end (they698 // Rows whose scope isn't in the stack get pushed to the end (they
761 // shouldn't reach us — the adapter filters by `scope_id IN (stack)` —699 // shouldn't reach us — the adapter filters by `scope_id IN (stack)` —
762 // but guarding here means a stray row can't silently win).700 // but guarding here means a stray row can't silently win).
763 const scopeRank = (row: { scope_id: unknown }) =>701 const rowScopeId = (row: { readonly scope_id: unknown }) =>
764 scopePrecedence.get(row.scope_id as string) ?? Infinity;702 typeof row.scope_id === "string" ? row.scope_id : null;
703 const scopeRank = (row: { readonly scope_id: unknown }) => {
704 const scopeId = rowScopeId(row);
705 return scopeId === null ? Infinity : (scopePrecedence.get(scopeId) ?? Infinity);
706 };
765 707
766 // Pick the innermost-scope row on a findOne-by-id against a scoped708 // Pick the innermost-scope row on a findOne-by-id against a scoped
767 // model. The scope-wrapped adapter returns rows from every scope in709 // model. The scope-wrapped adapter returns rows from every scope in
@@ -770,9 +712,7 @@ export const createExecutor = < · 2 unmodified lines
770 // and wrong when a user has shadowed an outer default. Callers that712 // and wrong when a user has shadowed an outer default. Callers that
771 // need a single logical row (invoke, tool schema, source removal)713 // need a single logical row (invoke, tool schema, source removal)
772 // must go through this path so the innermost write always wins.714 // must go through this path so the innermost write always wins.
773 const findInnermost = <T extends { scope_id: unknown }>(715 const findInnermost = <T extends { scope_id: unknown }>(rows: readonly T[]): T | null => {
774 rows: readonly T[],
775 ): T | null => {
776 if (rows.length === 0) return null;716 if (rows.length === 0) return null;
777 let winner: T | undefined;717 let winner: T | undefined;
778 let best = Infinity;718 let best = Infinity;
@@ -786,9 +726,7 @@ export const createExecutor = < · 7 unmodified lines
786 return winner ?? null;726 return winner ?? null;
787 };727 };
788 728
789 const secretRowsForId = (729 const secretRowsForId = (id: string): Effect.Effect<readonly SecretRow[], StorageFailure> =>
790 id: string,
791 ): Effect.Effect<readonly SecretRow[], StorageFailure> =>
792 core.findMany({730 core.findMany({
793 model: "secret",731 model: "secret",
794 where: [{ field: "id", value: id }],732 where: [{ field: "id", value: id }],
@@ -799,15 +737,11 @@ export const createExecutor = < · 4 unmodified lines
799 rows: readonly SecretRow[],737 rows: readonly SecretRow[],
800 ): Effect.Effect<string | null, StorageFailure> =>738 ): Effect.Effect<string | null, StorageFailure> =>
801 Effect.gen(function* () {739 Effect.gen(function* () {
802 const ordered = [...rows].sort(740 const ordered = [...rows].sort((a, b) => scopeRank(a) - scopeRank(b));
803 (a, b) =>
804 (scopePrecedence.get(a.scope_id as string) ?? Infinity) -
805 (scopePrecedence.get(b.scope_id as string) ?? Infinity),
806 );
807 for (const row of ordered) {741 for (const row of ordered) {
808 const provider = secretProviders.get(row.provider as string);742 const provider = secretProviders.get(row.provider);
809 if (!provider) continue;743 if (!provider) continue;
810 const value = yield* provider.get(id, row.scope_id as string);744 const value = yield* provider.get(id, row.scope_id);
811 if (value !== null) return value;745 if (value !== null) return value;
812 }746 }
813 747
@@ -819,14 +753,10 @@ export const createExecutor = < · 5 unmodified lines
819 // enumeration fallback doesn't know which scope the value753 // enumeration fallback doesn't know which scope the value
820 // lives in; flat providers ignore the arg.754 // lives in; flat providers ignore the arg.
821 const fallbackScope = scopeIds[0]!;755 const fallbackScope = scopeIds[0]!;
822 const candidates = [...secretProviders.values()].filter(756 const candidates = [...secretProviders.values()].filter((p) => p.list);
823 (p) => p.list,
824 );
825 const values = yield* Effect.all(757 const values = yield* Effect.all(
826 candidates.map((p) =>758 candidates.map((p) =>
827 p759 p.get(id, fallbackScope).pipe(Effect.catch(() => Effect.succeed(null))),
828 .get(id, fallbackScope)
829 .pipe(Effect.catch(() => Effect.succeed(null))),
830 ),760 ),
831 { concurrency: "unbounded" },761 { concurrency: "unbounded" },
832 );762 );
@@ -844,51 +774,40 @@ export const createExecutor = < · 11 unmodified lines
844 // must not expose them even if a token secret id is leaked.774 // must not expose them even if a token secret id is leaked.
845 const rows = yield* secretRowsForId(id);775 const rows = yield* secretRowsForId(id);
846 const owned = rows.find((row) => row.owned_by_connection_id);776 const owned = rows.find((row) => row.owned_by_connection_id);
847 if (owned) {777 const ownedByConnectionId = owned?.owned_by_connection_id;
848 return yield* Effect.fail(778 if (ownedByConnectionId) {
849 new SecretOwnedByConnectionError({779 return yield* new SecretOwnedByConnectionError({
850 secretId: SecretId.make(id),780 secretId: SecretId.make(id),
851 connectionId: ConnectionId.make(781 connectionId: ConnectionId.make(ownedByConnectionId),
852 owned.owned_by_connection_id as string,782 });
853 ),
854 }),
855 );
856 }783 }
857 return yield* resolveSecretValueFromRows(id, rows);784 return yield* resolveSecretValueFromRows(id, rows);
858 });785 });
859 786
860 const connectionSecretGet = (787 const connectionSecretGet = (id: string): Effect.Effect<string | null, StorageFailure> =>
861 id: string,
862 ): Effect.Effect<string | null, StorageFailure> =>
863 Effect.gen(function* () {788 Effect.gen(function* () {
864 const rows = yield* secretRowsForId(id);789 const rows = yield* secretRowsForId(id);
865 return yield* resolveSecretValueFromRows(id, rows);790 return yield* resolveSecretValueFromRows(id, rows);
866 });791 });
867 792
868 const secretRouteHasBackingValue = (row: SecretRow) => {793 const secretRouteHasBackingValue = (row: SecretRow) => {
869 const provider = secretProviders.get(row.provider as string);794 const provider = secretProviders.get(row.provider);
870 if (!provider?.has) return Effect.succeed(true);795 if (!provider?.has) return Effect.succeed(true);
871 return provider796 return provider.has(row.id, row.scope_id).pipe(Effect.catch(() => Effect.succeed(false)));
872 .has(row.id as string, row.scope_id as string)
873 .pipe(Effect.catch(() => Effect.succeed(false)));
874 };797 };
875 798
876 const secretsSet = (799 const secretsSet = (input: SetSecretInput): Effect.Effect<SecretRef, StorageFailure> =>
877 input: SetSecretInput,
878 ): Effect.Effect<SecretRef, StorageFailure> =>
879 Effect.gen(function* () {800 Effect.gen(function* () {
880 // Validate the write target up front. The adapter would reject801 // Validate the write target up front. The adapter would reject
881 // an out-of-stack scope too, but catching it here gives a802 // an out-of-stack scope too, but catching it here gives a
882 // clearer error before we touch the provider.803 // clearer error before we touch the provider.
883 if (!scopeIds.includes(input.scope as string)) {804 if (!scopeIds.includes(input.scope)) {
884 return yield* Effect.fail(805 return yield* new StorageError({
885 new StorageError({806 message:
886 message:807 `secrets.set targets scope "${input.scope}" which is not ` +
887 `secrets.set targets scope "${input.scope}" which is not ` +808 `in the executor's scope stack [${scopeIds.join(", ")}].`,
888 `in the executor's scope stack [${scopeIds.join(", ")}].`,809 cause: undefined,
889 cause: undefined,810 });
890 }),
891 );
892 }811 }
893 812
894 // Pick provider: explicit or first-writable. Misconfiguration813 // Pick provider: explicit or first-writable. Misconfiguration
@@ -899,12 +818,10 @@ export const createExecutor = < · 4 unmodified lines
899 if (input.provider) {818 if (input.provider) {
900 target = secretProviders.get(input.provider);819 target = secretProviders.get(input.provider);
901 if (!target) {820 if (!target) {
902 return yield* Effect.fail(821 return yield* new StorageError({
903 new StorageError({822 message: `Unknown secret provider: ${input.provider}`,
904 message: `Unknown secret provider: ${input.provider}`,823 cause: undefined,
905 cause: undefined,824 });
906 }),
907 );
908 }825 }
909 } else {826 } else {
910 for (const provider of secretProviders.values()) {827 for (const provider of secretProviders.values()) {
@@ -914,24 +831,20 @@ export const createExecutor = < · 3 unmodified lines
914 }831 }
915 }832 }
916 if (!target) {833 if (!target) {
917 return yield* Effect.fail(834 return yield* new StorageError({
918 new StorageError({835 message: "No writable secret providers registered",
919 message: "No writable secret providers registered",836 cause: undefined,
920 cause: undefined,837 });
921 }),
922 );
923 }838 }
924 }839 }
925 if (!target.writable || !target.set) {840 if (!target.writable || !target.set) {
926 return yield* Effect.fail(841 return yield* new StorageError({
927 new StorageError({842 message: `Secret provider "${target.key}" is read-only`,
928 message: `Secret provider "${target.key}" is read-only`,843 cause: undefined,
929 cause: undefined,844 });
930 }),
931 );
932 }845 }
933 846
934 yield* target.set(input.id, input.value, input.scope as string);847 yield* target.set(input.id, input.value, input.scope);
935 848
936 // Upsert metadata row in the core `secret` table at the849 // Upsert metadata row in the core `secret` table at the
937 // caller-named scope. Pin the delete to `scope_id = input.scope`850 // caller-named scope. Pin the delete to `scope_id = input.scope`
@@ -979,9 +892,7 @@ export const createExecutor = < · 41 unmodified lines
979 // errors fail the whole call so a transient plugin failure can't be892 // errors fail the whole call so a transient plugin failure can't be
980 // mistaken for "no usages" and let through a delete that creates893 // mistaken for "no usages" and let through a delete that creates
981 // dangling refs.894 // dangling refs.
982 const secretsUsagesStrict = (895 const secretsUsagesStrict = (id: string): Effect.Effect<readonly Usage[], StorageFailure> =>
983 id: string,
984 ): Effect.Effect<readonly Usage[], StorageFailure> =>
985 Effect.gen(function* () {896 Effect.gen(function* () {
986 const secretId = SecretId.make(id);897 const secretId = SecretId.make(id);
987 const perPlugin = yield* Effect.all(898 const perPlugin = yield* Effect.all(
@@ -1006,9 +917,7 @@ export const createExecutor = < · 18 unmodified lines
1006 return perPlugin.flat();917 return perPlugin.flat();
1007 });918 });
1008 919
1009 const secretsUsages = (920 const secretsUsages = (id: string): Effect.Effect<readonly Usage[], StorageFailure> =>
1010 id: string,
1011 ): Effect.Effect<readonly Usage[], StorageFailure> =>
1012 Effect.gen(function* () {921 Effect.gen(function* () {
1013 const secretId = SecretId.make(id);922 const secretId = SecretId.make(id);
1014 const perPlugin = yield* Effect.all(923 const perPlugin = yield* Effect.all(
@@ -1020,10 +929,9 @@ export const createExecutor = < · 5 unmodified lines
1020 args: { secretId },929 args: { secretId },
1021 }).pipe(930 }).pipe(
1022 Effect.catchCause((cause: unknown) =>931 Effect.catchCause((cause: unknown) =>
1023 Effect.logWarning(932 Effect.logWarning(`usagesForSecret failed for plugin ${r.plugin.id}`, cause).pipe(
1024 `usagesForSecret failed for plugin ${r.plugin.id}`,933 Effect.as([] as readonly Usage[]),
1025 cause,934 ),
1026 ).pipe(Effect.as([] as readonly Usage[])),
1027 ),935 ),
1028 ),936 ),
1029 ),937 ),
@@ -1032,9 +940,7 @@ export const createExecutor = < · 2 unmodified lines
1032 return perPlugin.flat();940 return perPlugin.flat();
1033 });941 });
1034 942
1035 const connectionsUsagesStrict = (943 const connectionsUsagesStrict = (id: string): Effect.Effect<readonly Usage[], StorageFailure> =>
1036 id: string,
1037 ): Effect.Effect<readonly Usage[], StorageFailure> =>
1038 Effect.gen(function* () {944 Effect.gen(function* () {
1039 const connectionId = ConnectionId.make(id);945 const connectionId = ConnectionId.make(id);
1040 const perPlugin = yield* Effect.all(946 const perPlugin = yield* Effect.all(
@@ -1059,9 +965,7 @@ export const createExecutor = < · 18 unmodified lines
1059 return perPlugin.flat();965 return perPlugin.flat();
1060 });966 });
1061 967
1062 const connectionsUsages = (968 const connectionsUsages = (id: string): Effect.Effect<readonly Usage[], StorageFailure> =>
1063 id: string,
1064 ): Effect.Effect<readonly Usage[], StorageFailure> =>
1065 Effect.gen(function* () {969 Effect.gen(function* () {
1066 const connectionId = ConnectionId.make(id);970 const connectionId = ConnectionId.make(id);
1067 const perPlugin = yield* Effect.all(971 const perPlugin = yield* Effect.all(
@@ -1087,10 +991,7 @@ export const createExecutor = < · 19 unmodified lines
1087 991
1088 const secretsRemove = (992 const secretsRemove = (
1089 id: string,993 id: string,
1090 ): Effect.Effect<994 ): Effect.Effect<void, SecretOwnedByConnectionError | SecretInUseError | StorageFailure> =>
1091 void,
1092 SecretOwnedByConnectionError | SecretInUseError | StorageFailure
1093 > =>
1094 Effect.gen(function* () {995 Effect.gen(function* () {
1095 // Remove is shadowing-aware: drop only the innermost-scope row.996 // Remove is shadowing-aware: drop only the innermost-scope row.
1096 // Removing a user-scope override on a secret that also has an997 // Removing a user-scope override on a secret that also has an
@@ -1107,14 +1008,10 @@ export const createExecutor = < · 10 unmodified lines
1107 // Refuse to delete connection-owned secrets. The connection owns1008 // Refuse to delete connection-owned secrets. The connection owns
1108 // the lifecycle — callers must go through connections.remove.1009 // the lifecycle — callers must go through connections.remove.
1109 if (target && target.owned_by_connection_id) {1010 if (target && target.owned_by_connection_id) {
1110 return yield* Effect.fail(1011 return yield* new SecretOwnedByConnectionError({
1111 new SecretOwnedByConnectionError({1012 secretId: SecretId.make(id),
1112 secretId: SecretId.make(id),1013 connectionId: ConnectionId.make(target.owned_by_connection_id),
1113 connectionId: ConnectionId.make(1014 });
1114 target.owned_by_connection_id as string,
1115 ),
1116 }),
1117 );
1118 }1015 }
1119 // RESTRICT: refuse if any source/binding still references this1016 // RESTRICT: refuse if any source/binding still references this
1120 // secret AND deleting the innermost row would leave the reference1017 // secret AND deleting the innermost row would leave the reference
@@ -1129,16 +1026,13 @@ export const createExecutor = < · 8 unmodified lines
1129 if (willDangle) {1026 if (willDangle) {
1130 const usages = yield* secretsUsagesStrict(id);1027 const usages = yield* secretsUsagesStrict(id);
1131 if (usages.length > 0) {1028 if (usages.length > 0) {
1132 return yield* Effect.fail(1029 return yield* new SecretInUseError({
1133 new SecretInUseError({1030 secretId: SecretId.make(id),
1134 secretId: SecretId.make(id),1031 usageCount: usages.length,
1135 usageCount: usages.length,1032 });
1136 }),
1137 );
1138 }1033 }
1139 }1034 }
1140 const targetScope = (target?.scope_id as string | undefined) ??1035 const targetScope = target?.scope_id ?? scopeIds[0]!;
1141 scopeIds[0]!;
1142 1036
1143 const deleters = [...secretProviders.values()].filter(1037 const deleters = [...secretProviders.values()].filter(
1144 (p): p is typeof p & { delete: NonNullable<typeof p.delete> } =>1038 (p): p is typeof p & { delete: NonNullable<typeof p.delete> } =>
@@ -1195,19 +1089,15 @@ export const createExecutor = < · 50 unmodified lines
1195 // leak them back in below.1089 // leak them back in below.
1196 const allRows = yield* core.findMany({ model: "secret" });1090 const allRows = yield* core.findMany({ model: "secret" });
1197 const connectionOwnedIds = new Set(1091 const connectionOwnedIds = new Set(
1198 allRows1092 allRows.filter((r) => r.owned_by_connection_id).map((r) => r.id),
1199 .filter((r) => r.owned_by_connection_id)
1200 .map((r) => r.id as string),
1201 );1093 );
1202 const rows = allRows.filter((r) => !r.owned_by_connection_id);1094 const rows = allRows.filter((r) => !r.owned_by_connection_id);
1203 const precedence = new Map<string, number>();1095 const pick = (row: (typeof rows)[number]) => {
1204 scopeIds.forEach((id, index) => precedence.set(id, index));
1205 const pick = (row: typeof rows[number]) => {
1206 const existing = byId.get(row.id);1096 const existing = byId.get(row.id);
1207 const incomingScope = row.scope_id as string;1097 const incomingScope = row.scope_id;
1208 const incomingRank = precedence.get(incomingScope) ?? Number.MAX_SAFE_INTEGER;1098 const incomingRank = scopeRank(row);
1209 if (existing) {1099 if (existing) {
1210 const existingRank = precedence.get(existing.scopeId as string) ?? Number.MAX_SAFE_INTEGER;1100 const existingRank = scopePrecedence.get(existing.scopeId) ?? Infinity;
1211 if (existingRank <= incomingRank) return;1101 if (existingRank <= incomingRank) return;
1212 }1102 }
1213 byId.set(1103 byId.set(
@@ -1217,10 +1107,7 @@ export const createExecutor = < · 3 unmodified lines
1217 scopeId: ScopeId.make(incomingScope),1107 scopeId: ScopeId.make(incomingScope),
1218 name: row.name,1108 name: row.name,
1219 provider: row.provider,1109 provider: row.provider,
1220 createdAt:1110 createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
1221 row.created_at instanceof Date
1222 ? row.created_at
1223 : new Date(row.created_at as string),
1224 }),1111 }),
1225 );1112 );
1226 };1113 };
@@ -1235,17 +1122,13 @@ export const createExecutor = < · 8 unmodified lines
1235 // whole list. Merge in registration order afterwards so the1122 // whole list. Merge in registration order afterwards so the
1236 // "first provider wins" precedence stays deterministic.1123 // "first provider wins" precedence stays deterministic.
1237 const attribution = scopes[0]!.id;1124 const attribution = scopes[0]!.id;
1238 const listers = [...secretProviders.entries()].filter(1125 const listers = [...secretProviders.entries()].filter(([, p]) => p.list);
1239 ([, p]) => p.list,
1240 );
1241 const lists = yield* Effect.all(1126 const lists = yield* Effect.all(
1242 listers.map(([key, p]) =>1127 listers.map(([key, p]) =>
1243 p1128 p.list!().pipe(
1244 .list!()1129 Effect.catch(() => Effect.succeed([] as const)),
1245 .pipe(1130 Effect.map((entries) => ({ key, entries })),
1246 Effect.catch(() => Effect.succeed([] as const)),1131 ),
1247 Effect.map((entries) => ({ key, entries })),
1248 ),
1249 ),1132 ),
1250 { concurrency: "unbounded" },1133 { concurrency: "unbounded" },
1251 );1134 );
@@ -1294,35 +1177,22 @@ export const createExecutor = < · 42 unmodified lines
1294 // behavior under the new SDK orchestration stays identical.1177 // behavior under the new SDK orchestration stays identical.
1295 const CONNECTION_REFRESH_SKEW_MS = 60_000;1178 const CONNECTION_REFRESH_SKEW_MS = 60_000;
1296 1179
1297 const decodeProviderState = Schema.decodeUnknownOption(1180 const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState);
1298 ConnectionProviderState,
1299 );
1300 1181
1301 const rowToConnection = (row: ConnectionRow): ConnectionRef =>1182 const rowToConnection = (row: ConnectionRow): ConnectionRef =>
1302 new ConnectionRef({1183 new ConnectionRef({
1303 id: ConnectionId.make(row.id as string),1184 id: ConnectionId.make(row.id),
1304 scopeId: ScopeId.make(row.scope_id as string),1185 scopeId: ScopeId.make(row.scope_id),
1305 provider: row.provider as string,1186 provider: row.provider,
1306 identityLabel: (row.identity_label as string | null | undefined) ?? null,1187 identityLabel: row.identity_label ?? null,
1307 accessTokenSecretId: SecretId.make(row.access_token_secret_id as string),1188 accessTokenSecretId: SecretId.make(row.access_token_secret_id),
1308 refreshTokenSecretId:1189 refreshTokenSecretId:
1309 row.refresh_token_secret_id != null1190 row.refresh_token_secret_id != null ? SecretId.make(row.refresh_token_secret_id) : null,
1310 ? SecretId.make(row.refresh_token_secret_id as string)1191 expiresAt: row.expires_at != null ? Number(row.expires_at) : null,
1311 : null,1192 oauthScope: row.scope ?? null,
1312 expiresAt:1193 providerState: Option.getOrNull(decodeProviderState(decodeJsonColumn(row.provider_state))),
1313 row.expires_at != null ? Number(row.expires_at as number) : null,1194 createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
1314 oauthScope: (row.scope as string | null | undefined) ?? null,1195 updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),
1315 providerState: Option.getOrNull(
1316 decodeProviderState(decodeJsonColumn(row.provider_state)),
1317 ),
1318 createdAt:
1319 row.created_at instanceof Date
1320 ? row.created_at
1321 : new Date(row.created_at as string),
1322 updatedAt:
1323 row.updated_at instanceof Date
1324 ? row.updated_at
1325 : new Date(row.updated_at as string),
1326 });1196 });
1327 1197
1328 const findInnermostConnectionRow = (1198 const findInnermostConnectionRow = (
@@ -1336,29 +1206,24 @@ export const createExecutor = < · 7 unmodified lines
1336 return findInnermost(rows as readonly ConnectionRow[]);1206 return findInnermost(rows as readonly ConnectionRow[]);
1337 });1207 });
1338 1208
1339 const connectionsGet = (1209 const connectionsGet = (id: string): Effect.Effect<ConnectionRef | null, StorageFailure> =>
1340 id: string,
1341 ): Effect.Effect<ConnectionRef | null, StorageFailure> =>
1342 Effect.gen(function* () {1210 Effect.gen(function* () {
1343 const row = yield* findInnermostConnectionRow(id);1211 const row = yield* findInnermostConnectionRow(id);
1344 return row ? rowToConnection(row) : null;1212 return row ? rowToConnection(row) : null;
1345 });1213 });
1346 1214
1347 const connectionsList = (): Effect.Effect<1215 const connectionsList = (): Effect.Effect<readonly ConnectionRef[], StorageFailure> =>
1348 readonly ConnectionRef[],
1349 StorageFailure
1350 > =>
1351 Effect.gen(function* () {1216 Effect.gen(function* () {
1352 const rows = yield* core.findMany({ model: "connection" });1217 const rows = yield* core.findMany({ model: "connection" });
1353 // Dedup by id, innermost scope wins — same rule as sources/tools.1218 // Dedup by id, innermost scope wins — same rule as sources/tools.
1354 const byId = new Map<string, ConnectionRow>();1219 const byId = new Map<string, ConnectionRow>();
1355 const byIdRank = new Map<string, number>();1220 const byIdRank = new Map<string, number>();
1356 for (const row of rows as readonly ConnectionRow[]) {1221 for (const row of rows as readonly ConnectionRow[]) {
1357 const rank = scopeRank(row as { scope_id: unknown });1222 const rank = scopeRank(row);
1358 const existing = byIdRank.get(row.id as string);1223 const existing = byIdRank.get(row.id);
1359 if (existing === undefined || rank < existing) {1224 if (existing === undefined || rank < existing) {
1360 byId.set(row.id as string, row);1225 byId.set(row.id, row);
1361 byIdRank.set(row.id as string, rank);1226 byIdRank.set(row.id, rank);
1362 }1227 }
1363 }1228 }
1364 return [...byId.values()].map(rowToConnection);1229 return [...byId.values()].map(rowToConnection);
@@ -1367,33 +1232,27 @@ export const createExecutor = < · 2 unmodified lines
1367 // Write a secret value through a specific provider, bypassing the1232 // Write a secret value through a specific provider, bypassing the
1368 // bare-secrets ownership check so the SDK can stamp1233 // bare-secrets ownership check so the SDK can stamp
1369 // `owned_by_connection_id` atomically alongside a connection row.1234 // `owned_by_connection_id` atomically alongside a connection row.
1370 const writeOwnedSecret = (1235 const writeOwnedSecret = (params: {
1371 params: {1236 id: string;
1372 id: string;1237 scope: string;
1373 scope: string;1238 name: string;
1374 name: string;1239 value: string;
1375 value: string;1240 provider: string;
1376 provider: string;1241 ownedByConnectionId: string;
1377 ownedByConnectionId: string;1242 }): Effect.Effect<void, StorageFailure> =>
1378 },
1379 ): Effect.Effect<void, StorageFailure> =>
1380 Effect.gen(function* () {1243 Effect.gen(function* () {
1381 const target = secretProviders.get(params.provider);1244 const target = secretProviders.get(params.provider);
1382 if (!target) {1245 if (!target) {
1383 return yield* Effect.fail(1246 return yield* new StorageError({
1384 new StorageError({1247 message: `Unknown secret provider: ${params.provider}`,
1385 message: `Unknown secret provider: ${params.provider}`,1248 cause: undefined,
1386 cause: undefined,1249 });
1387 }),
1388 );
1389 }1250 }
1390 if (!target.writable || !target.set) {1251 if (!target.writable || !target.set) {
1391 return yield* Effect.fail(1252 return yield* new StorageError({
1392 new StorageError({1253 message: `Secret provider "${target.key}" is read-only`,
1393 message: `Secret provider "${target.key}" is read-only`,1254 cause: undefined,
1394 cause: undefined,1255 });
1395 }),
1396 );
1397 }1256 }
1398 yield* target.set(params.id, params.value, params.scope);1257 yield* target.set(params.id, params.value, params.scope);
1399 1258
@@ -1426,50 +1285,39 @@ export const createExecutor = < · 26 unmodified lines
1426 if (requested) {1285 if (requested) {
1427 const p = secretProviders.get(requested);1286 const p = secretProviders.get(requested);
1428 if (!p) {1287 if (!p) {
1429 return yield* Effect.fail(1288 return yield* new StorageError({
1430 new StorageError({1289 message: `Unknown secret provider: ${requested}`,
1431 message: `Unknown secret provider: ${requested}`,1290 cause: undefined,
1432 cause: undefined,1291 });
1433 }),
1434 );
1435 }1292 }
1436 return p;1293 return p;
1437 }1294 }
1438 for (const p of secretProviders.values()) {1295 for (const p of secretProviders.values()) {
1439 if (p.writable && p.set) return p;1296 if (p.writable && p.set) return p;
1440 }1297 }
1441 return yield* Effect.fail(1298 return yield* new StorageError({
1442 new StorageError({1299 message: "No writable secret providers registered",
1443 message: "No writable secret providers registered",1300 cause: undefined,
1444 cause: undefined,1301 });
1445 }),
1446 );
1447 });1302 });
1448 1303
1449 const connectionsCreate = (1304 const connectionsCreate = (
1450 input: CreateConnectionInput,1305 input: CreateConnectionInput,
1451 ): Effect.Effect<1306 ): Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure> =>
1452 ConnectionRef,
1453 ConnectionProviderNotRegisteredError | StorageFailure
1454 > =>
1455 Effect.gen(function* () {1307 Effect.gen(function* () {
1456 if (!scopeIds.includes(input.scope as string)) {1308 if (!scopeIds.some((scopeId) => scopeId === input.scope)) {
1457 return yield* Effect.fail(1309 return yield* new StorageError({
1458 new StorageError({1310 message:
1459 message:1311 `connections.create targets scope "${input.scope}" which is not ` +
1460 `connections.create targets scope "${input.scope}" which is not ` +1312 `in the executor's scope stack [${scopeIds.join(", ")}].`,
1461 `in the executor's scope stack [${scopeIds.join(", ")}].`,1313 cause: undefined,
1462 cause: undefined,1314 });
1463 }),
1464 );
1465 }1315 }
1466 if (!resolveConnectionProvider(input.provider)) {1316 if (!resolveConnectionProvider(input.provider)) {
1467 return yield* Effect.fail(1317 return yield* new ConnectionProviderNotRegisteredError({
1468 new ConnectionProviderNotRegisteredError({1318 provider: input.provider,
1469 provider: input.provider,1319 connectionId: input.id,
1470 connectionId: input.id,1320 });
1471 }),
1472 );
1473 }1321 }
1474 1322
1475 const writable = yield* pickWritableProvider();1323 const writable = yield* pickWritableProvider();
@@ -1484,40 +1332,39 @@ export const createExecutor = < · 8 unmodified lines
1484 yield* core.delete({1332 yield* core.delete({
1485 model: "connection",1333 model: "connection",
1486 where: [1334 where: [
1487 { field: "id", value: input.id as string },1335 { field: "id", value: input.id },
1488 { field: "scope_id", value: input.scope as string },1336 { field: "scope_id", value: input.scope },
1489 ],1337 ],
1490 });1338 });
1491 1339
1492 yield* writeOwnedSecret({1340 yield* writeOwnedSecret({
1493 id: input.accessToken.secretId as string,1341 id: input.accessToken.secretId,
1494 scope: input.scope as string,1342 scope: input.scope,
1495 name: input.accessToken.name,1343 name: input.accessToken.name,
1496 value: input.accessToken.value,1344 value: input.accessToken.value,
1497 provider: writable.key,1345 provider: writable.key,
1498 ownedByConnectionId: input.id as string,1346 ownedByConnectionId: input.id,
1499 });1347 });
1500 if (input.refreshToken) {1348 if (input.refreshToken) {
1501 yield* writeOwnedSecret({1349 yield* writeOwnedSecret({
1502 id: input.refreshToken.secretId as string,1350 id: input.refreshToken.secretId,
1503 scope: input.scope as string,1351 scope: input.scope,
1504 name: input.refreshToken.name,1352 name: input.refreshToken.name,
1505 value: input.refreshToken.value,1353 value: input.refreshToken.value,
1506 provider: writable.key,1354 provider: writable.key,
1507 ownedByConnectionId: input.id as string,1355 ownedByConnectionId: input.id,
1508 });1356 });
1509 }1357 }
1510 1358
1511 yield* core.create({1359 yield* core.create({
1512 model: "connection",1360 model: "connection",
1513 data: {1361 data: {
1514 id: input.id as string,1362 id: input.id,
1515 scope_id: input.scope as string,1363 scope_id: input.scope,
1516 provider: input.provider,1364 provider: input.provider,
1517 identity_label: input.identityLabel ?? undefined,1365 identity_label: input.identityLabel ?? undefined,
1518 access_token_secret_id: input.accessToken.secretId as string,1366 access_token_secret_id: input.accessToken.secretId,
1519 refresh_token_secret_id:1367 refresh_token_secret_id: input.refreshToken?.secretId ?? undefined,
1520 input.refreshToken?.secretId ?? undefined,
1521 expires_at: input.expiresAt ?? undefined,1368 expires_at: input.expiresAt ?? undefined,
1522 scope: input.oauthScope ?? undefined,1369 scope: input.oauthScope ?? undefined,
1523 provider_state: input.providerState ?? undefined,1370 provider_state: input.providerState ?? undefined,
@@ -1533,8 +1380,7 @@ export const createExecutor = < · 9 unmodified lines
1533 provider: input.provider,1380 provider: input.provider,
1534 identityLabel: input.identityLabel,1381 identityLabel: input.identityLabel,
1535 accessTokenSecretId: input.accessToken.secretId,1382 accessTokenSecretId: input.accessToken.secretId,
1536 refreshTokenSecretId:1383 refreshTokenSecretId: input.refreshToken?.secretId ?? null,
1537 input.refreshToken?.secretId ?? null,
1538 expiresAt: input.expiresAt,1384 expiresAt: input.expiresAt,
1539 oauthScope: input.oauthScope,1385 oauthScope: input.oauthScope,
1540 providerState: input.providerState,1386 providerState: input.providerState,
@@ -1552,53 +1398,41 @@ export const createExecutor = < · 11 unmodified lines
1552 // configs still resolve.1398 // configs still resolve.
1553 const connectionsUpdateTokens = (1399 const connectionsUpdateTokens = (
1554 input: UpdateConnectionTokensInput,1400 input: UpdateConnectionTokensInput,
1555 ): Effect.Effect<1401 ): Effect.Effect<ConnectionRef, ConnectionNotFoundError | StorageFailure> =>
1556 ConnectionRef,
1557 ConnectionNotFoundError | StorageFailure
1558 > =>
1559 Effect.gen(function* () {1402 Effect.gen(function* () {
1560 const row = yield* findInnermostConnectionRow(input.id as string);1403 const row = yield* findInnermostConnectionRow(input.id);
1561 if (!row) {1404 if (!row) {
1562 return yield* Effect.fail(1405 return yield* new ConnectionNotFoundError({ connectionId: input.id });
1563 new ConnectionNotFoundError({ connectionId: input.id }),
1564 );
1565 }1406 }
1566 const writable = yield* pickWritableProvider();1407 const writable = yield* pickWritableProvider();
1567 const accessName =1408 const accessName = `Connection ${input.id} access token`;
1568 `Connection ${input.id as string} access token`;1409 const refreshName = `Connection ${input.id} refresh token`;
1569 const refreshName =
1570 `Connection ${input.id as string} refresh token`;
1571 1410
1572 return yield* adapter.transaction(() =>1411 return yield* adapter.transaction(() =>
1573 Effect.gen(function* () {1412 Effect.gen(function* () {
1574 yield* writeOwnedSecret({1413 yield* writeOwnedSecret({
1575 id: row.access_token_secret_id as string,1414 id: row.access_token_secret_id,
1576 scope: row.scope_id as string,1415 scope: row.scope_id,
1577 name: accessName,1416 name: accessName,
1578 value: input.accessToken,1417 value: input.accessToken,
1579 provider: writable.key,1418 provider: writable.key,
1580 ownedByConnectionId: row.id as string,1419 ownedByConnectionId: row.id,
1581 });1420 });
1582 const rotatedRefresh = input.refreshToken ?? undefined;1421 const rotatedRefresh = input.refreshToken ?? undefined;
1583 if (1422 if (rotatedRefresh && row.refresh_token_secret_id) {
1584 rotatedRefresh &&
1585 row.refresh_token_secret_id
1586 ) {
1587 yield* writeOwnedSecret({1423 yield* writeOwnedSecret({
1588 id: row.refresh_token_secret_id as string,1424 id: row.refresh_token_secret_id,
1589 scope: row.scope_id as string,1425 scope: row.scope_id,
1590 name: refreshName,1426 name: refreshName,
1591 value: rotatedRefresh,1427 value: rotatedRefresh,
1592 provider: writable.key,1428 provider: writable.key,
1593 ownedByConnectionId: row.id as string,1429 ownedByConnectionId: row.id,
1594 });1430 });
1595 }1431 }
1596 const now = new Date();1432 const now = new Date();
1597 const patch: Record<string, unknown> = { updated_at: now };1433 const patch: Record<string, unknown> = { updated_at: now };
1598 if (input.expiresAt !== undefined)1434 if (input.expiresAt !== undefined) patch.expires_at = input.expiresAt ?? undefined;
1599 patch.expires_at = input.expiresAt ?? undefined;1435 if (input.oauthScope !== undefined) patch.scope = input.oauthScope ?? undefined;
1600 if (input.oauthScope !== undefined)
1601 patch.scope = input.oauthScope ?? undefined;
1602 if (input.providerState !== undefined)1436 if (input.providerState !== undefined)
1603 patch.provider_state = input.providerState ?? undefined;1437 patch.provider_state = input.providerState ?? undefined;
1604 if (input.identityLabel !== undefined)1438 if (input.identityLabel !== undefined)
@@ -1606,18 +1440,16 @@ export const createExecutor = < · 1 unmodified line
1606 yield* core.update({1440 yield* core.update({
1607 model: "connection",1441 model: "connection",
1608 where: [1442 where: [
1609 { field: "id", value: row.id as string },1443 { field: "id", value: row.id },
1610 { field: "scope_id", value: row.scope_id as string },1444 { field: "scope_id", value: row.scope_id },
1611 ],1445 ],
1612 update: patch,1446 update: patch,
1613 });1447 });
1614 const updated = yield* findInnermostConnectionRow(1448 const updated = yield* findInnermostConnectionRow(row.id);
1615 row.id as string,
1616 );
1617 if (!updated) {1449 if (!updated) {
1618 return yield* Effect.fail(1450 return yield* new ConnectionNotFoundError({
1619 new ConnectionNotFoundError({ connectionId: input.id }),1451 connectionId: input.id,
1620 );1452 });
1621 }1453 }
1622 return rowToConnection(updated);1454 return rowToConnection(updated);
1623 }),1455 }),
@@ -1631,17 +1463,15 @@ export const createExecutor = < · 7 unmodified lines
1631 Effect.gen(function* () {1463 Effect.gen(function* () {
1632 const row = yield* findInnermostConnectionRow(id);1464 const row = yield* findInnermostConnectionRow(id);
1633 if (!row) {1465 if (!row) {
1634 return yield* Effect.fail(1466 return yield* new ConnectionNotFoundError({
1635 new ConnectionNotFoundError({1467 connectionId: ConnectionId.make(id),
1636 connectionId: ConnectionId.make(id),1468 });
1637 }),
1638 );
1639 }1469 }
1640 yield* core.update({1470 yield* core.update({
1641 model: "connection",1471 model: "connection",
1642 where: [1472 where: [
1643 { field: "id", value: id },1473 { field: "id", value: id },
1644 { field: "scope_id", value: row.scope_id as string },1474 { field: "scope_id", value: row.scope_id },
1645 ],1475 ],
1646 update: {1476 update: {
1647 identity_label: label ?? undefined,1477 identity_label: label ?? undefined,
@@ -1667,15 +1497,13 @@ export const createExecutor = < · 19 unmodified lines
1667 if (willDangle) {1497 if (willDangle) {
1668 const usages = yield* connectionsUsagesStrict(id);1498 const usages = yield* connectionsUsagesStrict(id);
1669 if (usages.length > 0) {1499 if (usages.length > 0) {
1670 return yield* Effect.fail(1500 return yield* new ConnectionInUseError({
1671 new ConnectionInUseError({1501 connectionId: ConnectionId.make(id),
1672 connectionId: ConnectionId.make(id),1502 usageCount: usages.length,
1673 usageCount: usages.length,1503 });
1674 }),
1675 );
1676 }1504 }
1677 }1505 }
1678 const scope = row.scope_id as string;1506 const scope = row.scope_id;
1679 yield* adapter.transaction(() =>1507 yield* adapter.transaction(() =>
1680 Effect.gen(function* () {1508 Effect.gen(function* () {
1681 // Find every owned secret at this scope and drop through1509 // Find every owned secret at this scope and drop through
@@ -1697,14 +1525,16 @@ export const createExecutor = < · 15 unmodified lines
1697 for (const secret of owned) {1525 for (const secret of owned) {
1698 yield* Effect.all(1526 yield* Effect.all(
1699 deleters.map((p) =>1527 deleters.map((p) =>
1700 p.delete(secret.id as string, scope).pipe(1528 p
1701 Effect.catchCause((cause) =>1529 .delete(secret.id, scope)
1702 Effect.logWarning(1530 .pipe(
1703 `Failed to delete connection-owned secret from provider ${p.key}`,1531 Effect.catchCause((cause) =>
1704 cause,1532 Effect.logWarning(
1705 ).pipe(Effect.as(false)),1533 `Failed to delete connection-owned secret from provider ${p.key}`,
1534 cause,
1535 ).pipe(Effect.as(false)),
1536 ),
1706 ),1537 ),
1707 ),
1708 ),1538 ),
1709 { concurrency: "unbounded" },1539 { concurrency: "unbounded" },
1710 );1540 );
@@ -1743,26 +1573,20 @@ export const createExecutor = < · 32 unmodified lines
1743 // The actual work of a single refresh cycle, factored out so the1573 // The actual work of a single refresh cycle, factored out so the
1744 // concurrency gate (`connectionsAccessToken`) stays readable. Runs1574 // concurrency gate (`connectionsAccessToken`) stays readable. Runs
1745 // for the fiber that wins the `refreshInFlight` race.1575 // for the fiber that wins the `refreshInFlight` race.
1746 const performRefresh = (1576 const performRefresh = (ref: ConnectionRef): Effect.Effect<string, AccessTokenError> =>
1747 ref: ConnectionRef,
1748 ): Effect.Effect<string, AccessTokenError> =>
1749 Effect.gen(function* () {1577 Effect.gen(function* () {
1750 const provider = resolveConnectionProvider(ref.provider);1578 const provider = resolveConnectionProvider(ref.provider);
1751 if (!provider) {1579 if (!provider) {
1752 return yield* Effect.fail(1580 return yield* new ConnectionProviderNotRegisteredError({
1753 new ConnectionProviderNotRegisteredError({1581 provider: ref.provider,
1754 provider: ref.provider,1582 connectionId: ref.id,
1755 connectionId: ref.id,1583 });
1756 }),
1757 );
1758 }1584 }
1759 if (!provider.refresh) {1585 if (!provider.refresh) {
1760 return yield* Effect.fail(1586 return yield* new ConnectionRefreshNotSupportedError({
1761 new ConnectionRefreshNotSupportedError({1587 connectionId: ref.id,
1762 connectionId: ref.id,1588 provider: ref.provider,
1763 provider: ref.provider,1589 });
1764 }),
1765 );
1766 }1590 }
1767 1591
1768 const refreshTokenValue = ref.refreshTokenSecretId1592 const refreshTokenValue = ref.refreshTokenSecretId
@@ -1774,31 +1598,28 @@ export const createExecutor = < · 5 unmodified lines
1774 // stored refresh token can't recover. Translate into the1598 // stored refresh token can't recover. Translate into the
1775 // caller-visible "re-authenticate" error so the UI can1599 // caller-visible "re-authenticate" error so the UI can
1776 // prompt sign-in instead of silently retrying.1600 // prompt sign-in instead of silently retrying.
1777 const rawResult: Result.Result<1601 const rawResult: Result.Result<ConnectionRefreshResult, ConnectionRefreshError> =
1778 ConnectionRefreshResult,1602 yield* Effect.result(
1779 ConnectionRefreshError1603 provider.refresh({
1780 > = yield* Effect.result(1604 connectionId: ref.id,
1781 provider.refresh({1605 scopeId: ref.scopeId,
1782 connectionId: ref.id,1606 identityLabel: ref.identityLabel,
1783 scopeId: ref.scopeId,1607 refreshToken: refreshTokenValue,
1784 identityLabel: ref.identityLabel,1608 providerState: ref.providerState,
1785 refreshToken: refreshTokenValue,1609 oauthScope: ref.oauthScope,
1786 providerState: ref.providerState,1610 }),
1787 oauthScope: ref.oauthScope,1611 );
1788 }),
1789 );
1790 if (Result.isFailure(rawResult)) {1612 if (Result.isFailure(rawResult)) {
1791 const err = rawResult.failure;1613 const err = rawResult.failure;
1792 if (err.reauthRequired) {1614 if (err.reauthRequired) {
1793 return yield* Effect.fail(1615 return yield* new ConnectionReauthRequiredError({
1794 new ConnectionReauthRequiredError({1616 connectionId: err.connectionId,
1795 connectionId: err.connectionId,1617 provider: ref.provider,
1796 provider: ref.provider,1618 // oxlint-disable-next-line executor/no-unknown-error-message -- typed: ConnectionRefreshError.message is provider-facing domain data, not an unknown caught error
1797 message: err.message,1619 message: err["message"],
1798 }),1620 });
1799 );
1800 }1621 }
1801 return yield* Effect.fail(err);1622 return yield* err;
1802 }1623 }
1803 const result = rawResult.success;1624 const result = rawResult.success;
1804 1625
@@ -1826,28 +1647,21 @@ export const createExecutor = < · 21 unmodified lines
1826 // observes the Deferred and awaits its completion. The Deferred is1647 // observes the Deferred and awaits its completion. The Deferred is
1827 // pulled out of the map before the refresh result resolves so1648 // pulled out of the map before the refresh result resolves so
1828 // later invokes don't reuse a completed slot.1649 // later invokes don't reuse a completed slot.
1829 const connectionsAccessToken = (1650 const connectionsAccessToken = (id: string): Effect.Effect<string, AccessTokenError> =>
1830 id: string,
1831 ): Effect.Effect<string, AccessTokenError> =>
1832 Effect.gen(function* () {1651 Effect.gen(function* () {
1833 const row = yield* findInnermostConnectionRow(id);1652 const row = yield* findInnermostConnectionRow(id);
1834 if (!row) {1653 if (!row) {
1835 return yield* Effect.fail(1654 return yield* new ConnectionNotFoundError({
1836 new ConnectionNotFoundError({1655 connectionId: ConnectionId.make(id),
1837 connectionId: ConnectionId.make(id),1656 });
1838 }),
1839 );
1840 }1657 }
1841 const ref = rowToConnection(row);1658 const ref = rowToConnection(row);
1842 const now = Date.now();1659 const now = Date.now();
1843 const needsRefresh =1660 const needsRefresh =
1844 ref.expiresAt !== null &&1661 ref.expiresAt !== null && ref.expiresAt - CONNECTION_REFRESH_SKEW_MS <= now;
1845 ref.expiresAt - CONNECTION_REFRESH_SKEW_MS <= now;
1846 1662
1847 if (!needsRefresh) {1663 if (!needsRefresh) {
1848 const current = yield* connectionSecretGet(1664 const current = yield* connectionSecretGet(ref.accessTokenSecretId);
1849 ref.accessTokenSecretId,
1850 );
1851 if (current !== null) return current;1665 if (current !== null) return current;
1852 // Fall through to refresh if the stored token vanished — a1666 // Fall through to refresh if the stored token vanished — a
1853 // genuinely-missing secret with no way to refresh is a1667 // genuinely-missing secret with no way to refresh is a
@@ -1901,17 +1715,12 @@ export const createExecutor = < · 47 unmodified lines
1901 rawAdapter: adapter,1715 rawAdapter: adapter,
1902 secretsGet: (id) =>1716 secretsGet: (id) =>
1903 secretsGet(id).pipe(1717 secretsGet(id).pipe(
1904 Effect.catchTag("SecretOwnedByConnectionError", () =>1718 Effect.catchTag("SecretOwnedByConnectionError", () => Effect.succeed(null)),
1905 Effect.succeed(null),
1906 ),
1907 ),1719 ),
1908 secretsSet: (input) => secretsSet(input),1720 secretsSet: (input) => secretsSet(input),
1909 connectionsCreate: (input) => connectionsCreate(input),1721 connectionsCreate: (input) => connectionsCreate(input),
1910 });1722 });
1911 connectionProviders.set(1723 connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider);
1912 oauthBundle.connectionProvider.key,
1913 oauthBundle.connectionProvider,
1914 );
1915 1724
1916 // ------------------------------------------------------------------1725 // ------------------------------------------------------------------
1917 // Plugin wiring — build ctx, run extension, populate static pools,1726 // Plugin wiring — build ctx, run extension, populate static pools,
@@ -1919,9 +1728,10 @@ export const createExecutor = < · 1 unmodified line
1919 // ------------------------------------------------------------------1728 // ------------------------------------------------------------------
1920 for (const plugin of plugins) {1729 for (const plugin of plugins) {
1921 if (runtimes.has(plugin.id)) {1730 if (runtimes.has(plugin.id)) {
1922 return yield* Effect.fail(1731 return yield* new StorageError({
1923 new Error(`Duplicate plugin id: ${plugin.id}`),1732 message: `Duplicate plugin id: ${plugin.id}`,
1924 );1733 cause: undefined,
1734 });
1925 }1735 }
1926 1736
1927 // Plugin-facing typed view. `StorageError` and `UniqueViolationError`1737 // Plugin-facing typed view. `StorageError` and `UniqueViolationError`
@@ -1957,22 +1767,18 @@ export const createExecutor = < · 29 unmodified lines
1957 // share the same string space. Fails as `StorageError`1767 // share the same string space. Fails as `StorageError`
1958 // so the HTTP edge surfaces it as `InternalError(traceId)`.1768 // so the HTTP edge surfaces it as `InternalError(traceId)`.
1959 if (staticSources.has(input.id)) {1769 if (staticSources.has(input.id)) {
1960 return yield* Effect.fail(1770 return yield* new StorageError({
1961 new StorageError({1771 message: `Source id "${input.id}" collides with a static source`,
1962 message: `Source id "${input.id}" collides with a static source`,1772 cause: undefined,
1963 cause: undefined,1773 });
1964 }),
1965 );
1966 }1774 }
1967 for (const tool of input.tools) {1775 for (const tool of input.tools) {
1968 const fqid = `${input.id}.${tool.name}`;1776 const fqid = `${input.id}.${tool.name}`;
1969 if (staticTools.has(fqid)) {1777 if (staticTools.has(fqid)) {
1970 return yield* Effect.fail(1778 return yield* new StorageError({
1971 new StorageError({1779 message: `Tool id "${fqid}" collides with a static tool`,
1972 message: `Tool id "${fqid}" collides with a static tool`,1780 cause: undefined,
1973 cause: undefined,1781 });
1974 }),
1975 );
1976 }1782 }
1977 }1783 }
1978 // Wrap in adapter.transaction so a standalone register()1784 // Wrap in adapter.transaction so a standalone register()
@@ -1981,9 +1787,7 @@ export const createExecutor = < · 2 unmodified lines
1981 // the router short-circuits to the active tx handle1787 // the router short-circuits to the active tx handle
1982 // instead of opening a nested sql.begin — that nested1788 // instead of opening a nested sql.begin — that nested
1983 // sql.begin is the postgres.js + pool=1 deadlock path.1789 // sql.begin is the postgres.js + pool=1 deadlock path.
1984 yield* adapter.transaction(() =>1790 yield* adapter.transaction(() => writeSourceInput(core, plugin.id, input));
1985 writeSourceInput(core, plugin.id, input),
1986 );
1987 }),1791 }),
1988 unregister: (sourceId: string) =>1792 unregister: (sourceId: string) =>
1989 // `unregister` is scoped to a specific source row — look up1793 // `unregister` is scoped to a specific source row — look up
@@ -2001,32 +1805,28 @@ export const createExecutor = < · 11 unmodified lines
2001 });1805 });
2002 const row = findInnermost(rows);1806 const row = findInnermost(rows);
2003 if (!row) return;1807 if (!row) return;
2004 yield* deleteSourceById(1808 yield* deleteSourceById(core, sourceId, row.scope_id);
2005 core,
2006 sourceId,
2007 row.scope_id as string,
2008 );
2009 }),1809 }),
2010 ),1810 ),
2011 update: (input) =>1811 update: (input) =>
2012 core.update({1812 core
2013 model: "source",1813 .update({
2014 where: [1814 model: "source",
2015 { field: "id", value: input.id },1815 where: [
2016 { field: "scope_id", value: input.scope },1816 { field: "id", value: input.id },
2017 ],1817 { field: "scope_id", value: input.scope },
2018 update: {1818 ],
2019 ...(input.name !== undefined ? { name: input.name } : {}),1819 update: {
2020 ...(input.url !== undefined ? { url: input.url ?? undefined } : {}),1820 ...(input.name !== undefined ? { name: input.name } : {}),
2021 updated_at: new Date(),1821 ...(input.url !== undefined ? { url: input.url ?? undefined } : {}),
2022 },1822 updated_at: new Date(),
2023 }).pipe(Effect.asVoid),1823 },
1824 })
1825 .pipe(Effect.asVoid),
2024 },1826 },
2025 definitions: {1827 definitions: {
2026 register: (input: DefinitionsInput) =>1828 register: (input: DefinitionsInput) =>
2027 adapter.transaction(() =>1829 adapter.transaction(() => writeDefinitions(core, plugin.id, input)),
2028 writeDefinitions(core, plugin.id, input),
2029 ),
2030 },1830 },
2031 },1831 },
2032 secrets: {1832 secrets: {
@@ -2040,8 +1840,7 @@ export const createExecutor = < · 7 unmodified lines
2040 list: () => connectionsListForCtx(),1840 list: () => connectionsListForCtx(),
2041 create: (input) => connectionsCreate(input),1841 create: (input) => connectionsCreate(input),
2042 updateTokens: (input) => connectionsUpdateTokens(input),1842 updateTokens: (input) => connectionsUpdateTokens(input),
2043 setIdentityLabel: (id, label) =>1843 setIdentityLabel: (id, label) => connectionsSetIdentityLabel(id, label),
2044 connectionsSetIdentityLabel(id, label),
2045 accessToken: (id) => connectionsAccessToken(id),1844 accessToken: (id) => connectionsAccessToken(id),
2046 remove: (id) => connectionsRemove(id),1845 remove: (id) => connectionsRemove(id),
2047 },1846 },
@@ -2052,45 +1851,36 @@ export const createExecutor = < · 4 unmodified lines
2052 // propagate unchanged; storage failures also stay typed1851 // propagate unchanged; storage failures also stay typed
2053 // (`StorageFailure`) so the HTTP edge wrapper can translate them.1852 // (`StorageFailure`) so the HTTP edge wrapper can translate them.
2054 transaction: <A, E>(effect: Effect.Effect<A, E>) =>1853 transaction: <A, E>(effect: Effect.Effect<A, E>) =>
2055 adapter.transaction(() => effect) as Effect.Effect<1854 adapter.transaction(() => effect) as Effect.Effect<A, E | StorageFailure>,
2056 A,
2057 E | StorageFailure
2058 >,
2059 };1855 };
2060 1856
2061 // Build extension FIRST so it's available as `self` when resolving1857 // Build extension FIRST so it's available as `self` when resolving
2062 // staticSources. Field ordering in the plugin spec matters — TS1858 // staticSources. Field ordering in the plugin spec matters — TS
2063 // infers TExtension from `extension`'s return type, then NoInfer1859 // infers TExtension from `extension`'s return type, then NoInfer
2064 // locks `self` to that inferred type on `staticSources`.1860 // locks `self` to that inferred type on `staticSources`.
2065 const extension: object = plugin.extension1861 const extension: object = plugin.extension ? plugin.extension(ctx) : {};
2066 ? plugin.extension(ctx)
2067 : {};
2068 if (plugin.extension) {1862 if (plugin.extension) {
2069 extensions[plugin.id] = extension;1863 extensions[plugin.id] = extension;
2070 }1864 }
2071 1865
2072 // Resolve static declarations to the in-memory pools. NO DB WRITES.1866 // Resolve static declarations to the in-memory pools. NO DB WRITES.
2073 const decls = plugin.staticSources1867 const decls = plugin.staticSources ? plugin.staticSources(extension) : [];
2074 ? plugin.staticSources(extension)
2075 : [];
2076 for (const source of decls) {1868 for (const source of decls) {
2077 if (staticSources.has(source.id)) {1869 if (staticSources.has(source.id)) {
2078 return yield* Effect.fail(1870 return yield* new StorageError({
2079 new Error(1871 message: `Duplicate static source id: ${source.id} (plugin ${plugin.id})`,
2080 `Duplicate static source id: ${source.id} (plugin ${plugin.id})`,1872 cause: undefined,
2081 ),1873 });
2082 );
2083 }1874 }
2084 staticSources.set(source.id, { source, pluginId: plugin.id });1875 staticSources.set(source.id, { source, pluginId: plugin.id });
2085 1876
2086 for (const tool of source.tools) {1877 for (const tool of source.tools) {
2087 const fqid = `${source.id}.${tool.name}`;1878 const fqid = `${source.id}.${tool.name}`;
2088 if (staticTools.has(fqid)) {1879 if (staticTools.has(fqid)) {
2089 return yield* Effect.fail(1880 return yield* new StorageError({
2090 new Error(1881 message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`,
2091 `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`,1882 cause: undefined,
2092 ),1883 });
2093 );
2094 }1884 }
2095 staticTools.set(fqid, {1885 staticTools.set(fqid, {
2096 source,1886 source,
@@ -2111,11 +1901,10 @@ export const createExecutor = < · 14 unmodified lines
2111 const providers = Effect.isEffect(raw) ? yield* raw : raw;1901 const providers = Effect.isEffect(raw) ? yield* raw : raw;
2112 for (const provider of providers) {1902 for (const provider of providers) {
2113 if (secretProviders.has(provider.key)) {1903 if (secretProviders.has(provider.key)) {
2114 return yield* Effect.fail(1904 return yield* new StorageError({
2115 new Error(1905 message: `Duplicate secret provider key: ${provider.key} (from plugin ${plugin.id})`,
2116 `Duplicate secret provider key: ${provider.key} (from plugin ${plugin.id})`,1906 cause: undefined,
2117 ),1907 });
2118 );
2119 }1908 }
2120 secretProviders.set(provider.key, provider);1909 secretProviders.set(provider.key, provider);
2121 }1910 }
@@ -2129,11 +1918,10 @@ export const createExecutor = < · 7 unmodified lines
2129 const providers = Effect.isEffect(raw) ? yield* raw : raw;1918 const providers = Effect.isEffect(raw) ? yield* raw : raw;
2130 for (const provider of providers) {1919 for (const provider of providers) {
2131 if (connectionProviders.has(provider.key)) {1920 if (connectionProviders.has(provider.key)) {
2132 return yield* Effect.fail(1921 return yield* new StorageError({
2133 new Error(1922 message: `Duplicate connection provider key: ${provider.key} (from plugin ${plugin.id})`,
2134 `Duplicate connection provider key: ${provider.key} (from plugin ${plugin.id})`,1923 cause: undefined,
2135 ),1924 });
2136 );
2137 }1925 }
2138 connectionProviders.set(provider.key, provider);1926 connectionProviders.set(provider.key, provider);
2139 }1927 }
@@ -2151,7 +1939,7 @@ export const createExecutor = < · 11 unmodified lines
2151 // two rows — their override and the outer default — which is1939 // two rows — their override and the outer default — which is
2152 // inconsistent with how `secrets.list` and every other list1940 // inconsistent with how `secrets.list` and every other list
2153 // surface dedup shadowed entries.1941 // surface dedup shadowed entries.
2154 const byId = new Map<string, typeof dynamic[number]>();1942 const byId = new Map<string, (typeof dynamic)[number]>();
2155 const byIdRank = new Map<string, number>();1943 const byIdRank = new Map<string, number>();
2156 for (const row of dynamic) {1944 for (const row of dynamic) {
2157 const rank = scopeRank(row);1945 const rank = scopeRank(row);
@@ -2201,10 +1989,7 @@ export const createExecutor = < · 43 unmodified lines
2201 [...groups],1989 [...groups],
2202 ([key, groupRows]) =>1990 ([key, groupRows]) =>
2203 Effect.gen(function* () {1991 Effect.gen(function* () {
2204 const [pluginId, sourceId] = key.split("\u0000") as [1992 const [pluginId, sourceId] = key.split("\u0000") as [string, string];
2205 string,
2206 string,
2207 ];
2208 const runtime = runtimes.get(pluginId);1993 const runtime = runtimes.get(pluginId);
2209 if (!runtime?.plugin.resolveAnnotations) return undefined;1994 if (!runtime?.plugin.resolveAnnotations) return undefined;
2210 return yield* runtime.plugin.resolveAnnotations({1995 return yield* runtime.plugin.resolveAnnotations({
@@ -2228,14 +2013,12 @@ export const createExecutor = < · 17 unmodified lines
2228 Effect.gen(function* () {2013 Effect.gen(function* () {
2229 const dynamic = yield* core.findMany({2014 const dynamic = yield* core.findMany({
2230 model: "tool",2015 model: "tool",
2231 where: filter?.sourceId2016 where: filter?.sourceId ? [{ field: "source_id", value: filter.sourceId }] : undefined,
2232 ? [{ field: "source_id", value: filter.sourceId }]
2233 : undefined,
2234 });2017 });
2235 // Dedup by tool id, innermost scope winning — same reason as2018 // Dedup by tool id, innermost scope winning — same reason as
2236 // `listSources` above: a shadowed id must surface as one entry2019 // `listSources` above: a shadowed id must surface as one entry
2237 // (the inner one), not two.2020 // (the inner one), not two.
2238 const byId = new Map<string, typeof dynamic[number]>();2021 const byId = new Map<string, (typeof dynamic)[number]>();
2239 const byIdRank = new Map<string, number>();2022 const byIdRank = new Map<string, number>();
2240 for (const row of dynamic) {2023 for (const row of dynamic) {
2241 const rank = scopeRank(row);2024 const rank = scopeRank(row);
@@ -2261,9 +2044,7 @@ export const createExecutor = < · 19 unmodified lines
2261 for (const row of dynamicDeduped) {2044 for (const row of dynamicDeduped) {
2262 out.push(rowToTool(row, annotations.get(row.id)));2045 out.push(rowToTool(row, annotations.get(row.id)));
2263 }2046 }
2264 const filtered = filter2047 const filtered = filter ? out.filter((t) => toolMatchesFilter(t, filter)) : out;
2265 ? out.filter((t) => toolMatchesFilter(t, filter))
2266 : out;
2267 2048
2268 // Drop tools blocked by user policy unless the caller explicitly2049 // Drop tools blocked by user policy unless the caller explicitly
2269 // asked to see them (the settings UI does, agent surfaces don't).2050 // asked to see them (the settings UI does, agent surfaces don't).
@@ -2306,7 +2087,7 @@ export const createExecutor = < · 36 unmodified lines
2306 model: "definition",2087 model: "definition",
2307 where: [{ field: "source_id", value: sourceId }],2088 where: [{ field: "source_id", value: sourceId }],
2308 });2089 });
2309 const winners = new Map<string, { row: typeof defRows[number]; rank: number }>();2090 const winners = new Map<string, { row: (typeof defRows)[number]; rank: number }>();
2310 for (const row of defRows) {2091 for (const row of defRows) {
2311 const rank = scopeRank(row);2092 const rank = scopeRank(row);
2312 const existing = winners.get(row.name);2093 const existing = winners.get(row.name);
@@ -2432,7 +2213,7 @@ export const createExecutor = < · 119 unmodified lines
2432 const toolsDefinitions = () =>2213 const toolsDefinitions = () =>
2433 Effect.gen(function* () {2214 Effect.gen(function* () {
2434 const rows = yield* core.findMany({ model: "definition" });2215 const rows = yield* core.findMany({ model: "definition" });
2435 const winners = new Map<string, { row: typeof rows[number]; rank: number }>();2216 const winners = new Map<string, { row: (typeof rows)[number]; rank: number }>();
2436 for (const row of rows) {2217 for (const row of rows) {
2437 const key = `${row.source_id}\u0000${row.name}`;2218 const key = `${row.source_id}\u0000${row.name}`;
2438 const rank = scopeRank(row);2219 const rank = scopeRank(row);
@@ -2453,19 +2234,13 @@ export const createExecutor = < · 14 unmodified lines
2453 return out;2234 return out;
2454 });2235 });
2455 2236
2456 const defaultElicitationHandler = resolveElicitationHandler(2237 const defaultElicitationHandler = resolveElicitationHandler(config.onElicitation);
2457 config.onElicitation,
2458 );
2459 const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler =>2238 const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler =>
2460 options?.onElicitation2239 options?.onElicitation
2461 ? resolveElicitationHandler(options.onElicitation)2240 ? resolveElicitationHandler(options.onElicitation)
2462 : defaultElicitationHandler;2241 : defaultElicitationHandler;
2463 2242
2464 const buildElicit = (2243 const buildElicit = (toolId: string, args: unknown, handler: ElicitationHandler): Elicit => {
2465 toolId: string,
2466 args: unknown,
2467 handler: ElicitationHandler,
2468 ): Elicit => {
2469 return (request: ElicitationRequest) =>2244 return (request: ElicitationRequest) =>
2470 Effect.gen(function* () {2245 Effect.gen(function* () {
2471 const tid = ToolId.make(toolId);2246 const tid = ToolId.make(toolId);
@@ -2494,8 +2269,7 @@ export const createExecutor = < · 22 unmodified lines
2494 // used as today.2269 // used as today.
2495 // ------------------------------------------------------------------2270 // ------------------------------------------------------------------
2496 2271
2497 const loadAllPolicies = () =>2272 const loadAllPolicies = () => core.findMany({ model: "tool_policy" });
2498 core.findMany({ model: "tool_policy" });
2499 2273
2500 const resolveToolPolicyForId = (toolId: string) =>2274 const resolveToolPolicyForId = (toolId: string) =>
2501 Effect.gen(function* () {2275 Effect.gen(function* () {
@@ -2539,13 +2313,13 @@ export const createExecutor = < · 37 unmodified lines
2539 }2313 }
2540 });2314 });
2541 2315
2542 const invokeTool = (2316 const invokeTool = (toolId: string, args: unknown, options?: InvokeOptions) => {
2543 toolId: string,
2544 args: unknown,
2545 options?: InvokeOptions,
2546 ) => {
2547 const handler = pickHandler(options);2317 const handler = pickHandler(options);
2548 return Effect.gen(function* () {2318 return Effect.gen(function* () {
2319 const formatInvocationCauseMessage = (cause: unknown): string => {
2320 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public invoke error message wrapping for unknown plugin failures
2321 return cause instanceof Error ? cause.message : String(cause);
2322 };
2549 const wrapInvocationError = <A, E>(2323 const wrapInvocationError = <A, E>(
2550 effect: Effect.Effect<A, E>,2324 effect: Effect.Effect<A, E>,
2551 ): Effect.Effect<A, ToolInvocationError> =>2325 ): Effect.Effect<A, ToolInvocationError> =>
@@ -2554,8 +2328,7 @@ export const createExecutor = < · 2 unmodified lines
2554 (cause) =>2328 (cause) =>
2555 new ToolInvocationError({2329 new ToolInvocationError({
2556 toolId: ToolId.make(toolId),2330 toolId: ToolId.make(toolId),
2557 message:2331 message: formatInvocationCauseMessage(cause),
2558 cause instanceof Error ? cause.message : String(cause),
2559 cause,2332 cause,
2560 }),2333 }),
2561 ),2334 ),
@@ -2583,13 +2356,9 @@ export const createExecutor = < · 21 unmodified lines
2583 "executor.source_kind": staticEntry.source.kind,2356 "executor.source_kind": staticEntry.source.kind,
2584 "executor.plugin_id": staticEntry.pluginId,2357 "executor.plugin_id": staticEntry.pluginId,
2585 });2358 });
2586 yield* enforceApproval(2359 yield* enforceApproval(staticEntry.tool.annotations, toolId, args, policy, handler).pipe(
2587 staticEntry.tool.annotations,2360 Effect.withSpan("executor.tool.enforce_approval"),
2588 toolId,2361 );
2589 args,
2590 policy,
2591 handler,
2592 ).pipe(Effect.withSpan("executor.tool.enforce_approval"));
2593 return yield* wrapInvocationError(2362 return yield* wrapInvocationError(
2594 staticEntry.tool.handler({2363 staticEntry.tool.handler({
2595 ctx: staticEntry.ctx,2364 ctx: staticEntry.ctx,
@@ -2702,14 +2471,10 @@ export const createExecutor = < · 106 unmodified lines
2702 yield* runtime.plugin.removeSource({2471 yield* runtime.plugin.removeSource({
2703 ctx: runtime.ctx,2472 ctx: runtime.ctx,
2704 sourceId,2473 sourceId,
2705 scope: sourceRow.scope_id as string,2474 scope: sourceRow.scope_id,
2706 });2475 });
2707 }2476 }
2708 yield* deleteSourceById(2477 yield* deleteSourceById(core, sourceId, sourceRow.scope_id);
2709 core,
2710 sourceId,
2711 sourceRow.scope_id as string,
2712 );
2713 }),2478 }),
2714 );2479 );
2715 });2480 });
@@ -2730,7 +2495,7 @@ export const createExecutor = < · 14 unmodified lines
2730 yield* runtime.plugin.refreshSource({2495 yield* runtime.plugin.refreshSource({
2731 ctx: runtime.ctx,2496 ctx: runtime.ctx,
2732 sourceId,2497 sourceId,
2733 scope: sourceRow.scope_id as string,2498 scope: sourceRow.scope_id,
2734 });2499 });
2735 }2500 }
2736 });2501 });
@@ -2739,9 +2504,7 @@ export const createExecutor = < · 2 unmodified lines
2739 // `detect` hook. Collect all non-null results. Plugin-level detect2504 // `detect` hook. Collect all non-null results. Plugin-level detect
2740 // implementations should swallow fetch errors and return null, so2505 // implementations should swallow fetch errors and return null, so
2741 // one flaky plugin doesn't block the whole dispatch.2506 // one flaky plugin doesn't block the whole dispatch.
2742 const detectionConfidenceScore = (2507 const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => {
2743 confidence: SourceDetectionResult["confidence"],
2744 ) => {
2745 switch (confidence) {2508 switch (confidence) {
2746 case "high":2509 case "high":
2747 return 3;2510 return 3;
@@ -2763,24 +2526,19 @@ export const createExecutor = < · 15 unmodified lines
2763 if (result) results.push(result);2526 if (result) results.push(result);
2764 }2527 }
2765 return results.sort(2528 return results.sort(
2766 (a, b) =>2529 (a, b) => detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence),
2767 detectionConfidenceScore(b.confidence) -
2768 detectionConfidenceScore(a.confidence),
2769 );2530 );
2770 });2531 });
2771 2532
2772 // Per-source definitions accessor — one query, one mapping pass.2533 // Per-source definitions accessor — one query, one mapping pass.
2773 const sourceDefinitions = (sourceId: string) =>2534 const sourceDefinitions = (sourceId: string) => loadDefinitionsForSource(sourceId);
2774 loadDefinitionsForSource(sourceId);
2775 2535
2776 // Existence check for user-facing secret pickers. Core `secret`2536 // Existence check for user-facing secret pickers. Core `secret`
2777 // rows are routing metadata; when a provider can answer `has()`,2537 // rows are routing metadata; when a provider can answer `has()`,
2778 // confirm the backing value still exists. Providers without `has()`2538 // confirm the backing value still exists. Providers without `has()`
2779 // remain conservative so keychain/1password don't need to return2539 // remain conservative so keychain/1password don't need to return
2780 // the value or prompt just to populate picker/status UI.2540 // the value or prompt just to populate picker/status UI.
2781 const secretsStatus = (2541 const secretsStatus = (id: string): Effect.Effect<"resolved" | "missing", StorageFailure> =>
2782 id: string,
2783 ): Effect.Effect<"resolved" | "missing", StorageFailure> =>
2784 Effect.gen(function* () {2542 Effect.gen(function* () {
2785 const rows = yield* secretRowsForId(id);2543 const rows = yield* secretRowsForId(id);
2786 if (rows.some((row) => row.owned_by_connection_id)) return "missing";2544 if (rows.some((row) => row.owned_by_connection_id)) return "missing";
@@ -2853,7 +2611,7 @@ export const createExecutor = < · 66 unmodified lines
2853 });2611 });
2854 let min: string | null = null;2612 let min: string | null = null;
2855 for (const row of existing) {2613 for (const row of existing) {
2856 const p = row.position as string;2614 const p = row.position;
2857 if (min === null || p < min) min = p;2615 if (min === null || p < min) min = p;
2858 }2616 }
2859 position = generateKeyBetween(null, min);2617 position = generateKeyBetween(null, min);
@@ -2923,13 +2681,13 @@ export const createExecutor = < · 63 unmodified lines
2923 model: "tool_policy",2681 model: "tool_policy",
2924 where: [2682 where: [
2925 { field: "id", value: input.id },2683 { field: "id", value: input.id },
2926 { field: "scope_id", value: row.scope_id as string },2684 { field: "scope_id", value: row.scope_id },
2927 ],2685 ],
2928 update: {2686 update: {
2929 pattern: updated.pattern as string,2687 pattern: updated.pattern,
2930 action: updated.action as string,2688 action: updated.action,
2931 position: updated.position as string,2689 position: updated.position,
2932 updated_at: updated.updated_at as Date,2690 updated_at: updated.updated_at,
2933 },2691 },
2934 });2692 });
2935 return rowToToolPolicy(updated);2693 return rowToToolPolicy(updated);
@@ -2944,9 +2702,7 @@ export const createExecutor = < · 8 unmodified lines
2944 .pipe(Effect.asVoid, Effect.withSpan("executor.policies.remove"));2702 .pipe(Effect.asVoid, Effect.withSpan("executor.policies.remove"));
2945 2703
2946 const policiesResolve = (toolId: string) =>2704 const policiesResolve = (toolId: string) =>
2947 resolveToolPolicyForId(toolId).pipe(2705 resolveToolPolicyForId(toolId).pipe(Effect.withSpan("executor.policies.resolve"));
2948 Effect.withSpan("executor.policies.resolve"),
2949 );
2950 2706
2951 const close = () =>2707 const close = () =>
2952 Effect.gen(function* () {2708 Effect.gen(function* () {
@@ -2984,10 +2740,7 @@ export const createExecutor = < · 31 unmodified lines
2984 remove: secretsRemove,2740 remove: secretsRemove,
2985 list: secretsList,2741 list: secretsList,
2986 usages: secretsUsages,2742 usages: secretsUsages,
2987 providers: () =>2743 providers: () => Effect.sync(() => Array.from(secretProviders.keys()) as readonly string[]),
2988 Effect.sync(
2989 () => Array.from(secretProviders.keys()) as readonly string[],
2990 ),
2991 },2744 },
2992 connections: {2745 connections: {
2993 get: connectionsGet,2746 get: connectionsGet,
@@ -2999,10 +2752,7 @@ export const createExecutor = < · 5 unmodified lines
2999 remove: connectionsRemove,2752 remove: connectionsRemove,
3000 usages: connectionsUsages,2753 usages: connectionsUsages,
3001 providers: () =>2754 providers: () =>
3002 Effect.sync(2755 Effect.sync(() => Array.from(connectionProviders.keys()) as readonly string[]),
3003 () =>
3004 Array.from(connectionProviders.keys()) as readonly string[],
3005 ),
3006 },2756 },
3007 oauth: oauthBundle.service,2757 oauth: oauthBundle.service,
3008 policies: {2758 policies: {
@@ -3020,7 +2770,6 @@ export const createExecutor = < · 11 unmodified lines
3020 // those leak via the helper functions and won't be cleaned until2770 // those leak via the helper functions and won't be cleaned until
3021 // every plugin tightens its surface to typed errors. The runtime2771 // every plugin tightens its surface to typed errors. The runtime
3022 // shape matches `Executor<TPlugins>`.2772 // shape matches `Executor<TPlugins>`.
3023 const toExecutor = (value: unknown): Executor<TPlugins> =>2773 const toExecutor = (value: unknown): Executor<TPlugins> => value as Executor<TPlugins>;
3024 value as Executor<TPlugins>;
3025 return toExecutor(Object.assign(base, extensions));2774 return toExecutor(Object.assign(base, extensions));
3026 });2775 });