import { Effect } from "effect"; import { definePluginStorageCollection, type PluginStorageFacade, type StorageFailure, } from "@executor-js/sdk"; import type { AppDescriptor } from "../pipeline/descriptor"; // --------------------------------------------------------------------------- // AppsStore — descriptor + snapshot pointer persistence for the apps plugin, // over the host-owned `pluginStorage` (collections) + `blobs` (content-addressed // bundles / skill bodies). Executor plugins do not contribute DB tables, so the // versioned descriptor is a JSON document in a plugin-storage collection keyed // by scope, and the compiled bundles + skill bodies are blobs. // // One published descriptor per scope (the current published pointer). History // is the ArtifactStore's git log; the runtime only runs the published snapshot. // --------------------------------------------------------------------------- /** The published descriptor document, keyed by scope. */ export const descriptorCollection = definePluginStorageCollection("published_descriptor", { Type: {} as { readonly scope: string; readonly snapshotId: string; readonly descriptor: AppDescriptor; readonly publishedAt: number; }, }); /** The explicit connection-name -> scope mapping (Fix 9), keyed by the * (normalized) connection name. */ export const scopeConnectionCollection = definePluginStorageCollection("apps_scope_connection", { Type: {} as { readonly connectionName: string; readonly scope: string; }, }); export interface AppsStore { /** Persist the published descriptor for a scope (the published pointer). */ readonly putDescriptor: ( owner: "org" | "user", descriptor: AppDescriptor, ) => Effect.Effect; /** Read the current published descriptor for a scope, or null. */ readonly getDescriptor: (scope: string) => Effect.Effect; /** Store a compiled bundle / skill body blob (content-addressed). */ readonly putBlob: (key: string, value: string) => Effect.Effect; /** Read a blob by key. */ readonly getBlob: (key: string) => Effect.Effect; /** * Record the explicit connection-name -> scope mapping (Fix 9). The executor * normalizes connection names to identifiers, so distinct scopes ("my-scope" * vs "my_scope") can normalize to the SAME name if we tried to parse the scope * back out. Storing the mapping at connect time makes the lookup exact and * collision-free; `resolveTools`/`invokeTool` consult it instead of reversing * the name. Idempotent (upsert). */ readonly putScopeForConnection: ( connectionName: string, scope: string, ) => Effect.Effect; /** The scope a connection name maps to, or null if none was recorded. */ readonly getScopeForConnection: ( connectionName: string, ) => Effect.Effect; } export interface AppsStoreDeps { readonly pluginStorage: PluginStorageFacade; readonly blobs: { readonly get: (key: string) => Effect.Effect; readonly put: ( key: string, value: string, options: { readonly owner: "org" | "user" }, ) => Effect.Effect; }; } export const makeAppsStore = (deps: AppsStoreDeps): AppsStore => { const descriptors = deps.pluginStorage.collection(descriptorCollection); const scopeConnections = deps.pluginStorage.collection(scopeConnectionCollection); return { putScopeForConnection: (connectionName, scope) => scopeConnections .put({ owner: "org", key: connectionName, data: { connectionName, scope } }) .pipe(Effect.asVoid), getScopeForConnection: (connectionName) => scopeConnections .get({ key: connectionName }) .pipe(Effect.map((entry) => entry?.data.scope ?? null)), putDescriptor: (owner, descriptor) => descriptors .put({ owner, key: descriptor.scope, data: { scope: descriptor.scope, snapshotId: descriptor.snapshotId, descriptor, publishedAt: Date.now(), }, }) .pipe(Effect.asVoid), getDescriptor: (scope) => descriptors.get({ key: scope }).pipe(Effect.map((entry) => entry?.data.descriptor ?? null)), putBlob: (key, value) => deps.blobs.put(key, value, { owner: "org" }), getBlob: (key) => deps.blobs.get(key), }; };