Unified · Split
| @@ -120,8 +120,13 @@ import type { · 119 unmodified lines | ||
| 120 | 120 | } from "./plugin"; |
| 121 | 121 | import { |
| 122 | 122 | pluginStorageId, |
| 123 | + type PluginStorageCollectionData, | |
| 124 | + type PluginStorageCollectionDefinition, | |
| 125 | + type PluginStorageCollectionQueryInput, | |
| 123 | 126 | type PluginStorageEntry, |
| 124 | 127 | type PluginStorageFacade, |
| 128 | + type PluginStorageRuntimeCollectionDefinition, | |
| 129 | + type PluginStorageRuntimeIndexSpec, | |
| 125 | 130 | } from "./plugin-storage"; |
| 126 | 131 | import type { Scope } from "./scope"; |
| 127 | 132 | import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; |
| @@ -801,6 +806,108 @@ const pluginStorageEntryFromRow = <T>(row: CoreRow<"plugin_storage">): PluginSto · 673 unmodified lines | ||
| 801 | 806 | updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), |
| 802 | 807 | }); |
| 803 | 808 | |
| 809 | +const pluginStorageIndexSpecFields = (spec: PluginStorageRuntimeIndexSpec): readonly string[] => | |
| 810 | + typeof spec === "string" ? [spec] : spec; | |
| 811 | + | |
| 812 | +const pluginStorageCollectionIndexedFields = ( | |
| 813 | + definition: PluginStorageRuntimeCollectionDefinition, | |
| 814 | +): ReadonlySet<string> => | |
| 815 | + new Set(definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec))); | |
| 816 | + | |
| 817 | +const pluginStorageQueryValidationError = ( | |
| 818 | + definition: PluginStorageRuntimeCollectionDefinition, | |
| 819 | + query: PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition> | undefined, | |
| 820 | +): StorageError | null => { | |
| 821 | + if (!query) return null; | |
| 822 | + | |
| 823 | + const indexedFields = pluginStorageCollectionIndexedFields(definition); | |
| 824 | + const fields = new Set<string>([ | |
| 825 | + ...Object.keys(query.where ?? {}), | |
| 826 | + ...(query.orderBy ?? []).map((order) => order.field), | |
| 827 | + ]); | |
| 828 | + for (const field of fields) { | |
| 829 | + if (!indexedFields.has(field)) { | |
| 830 | + return new StorageError({ | |
| 831 | + message: `Plugin storage collection "${definition.name}" cannot query field "${field}" because it is not declared as an index`, | |
| 832 | + cause: undefined, | |
| 833 | + }); | |
| 834 | + } | |
| 835 | + } | |
| 836 | + | |
| 837 | + if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) { | |
| 838 | + return new StorageError({ | |
| 839 | + message: `Plugin storage collection "${definition.name}" received an invalid query limit`, | |
| 840 | + cause: undefined, | |
| 841 | + }); | |
| 842 | + } | |
| 843 | + if (query.offset !== undefined && (!Number.isInteger(query.offset) || query.offset < 0)) { | |
| 844 | + return new StorageError({ | |
| 845 | + message: `Plugin storage collection "${definition.name}" received an invalid query offset`, | |
| 846 | + cause: undefined, | |
| 847 | + }); | |
| 848 | + } | |
| 849 | + | |
| 850 | + return null; | |
| 851 | +}; | |
| 852 | + | |
| 853 | +const isPluginStorageRecord = (value: unknown): value is Readonly<Record<string, unknown>> => | |
| 854 | + value !== null && typeof value === "object" && !Array.isArray(value); | |
| 855 | + | |
| 856 | +const pluginStorageWhereOperators = ["eq", "in", "gt", "gte", "lt", "lte"] as const; | |
| 857 | + | |
| 858 | +const isPluginStorageWhereFilter = (value: unknown): value is Readonly<Record<string, unknown>> => | |
| 859 | + isPluginStorageRecord(value) && pluginStorageWhereOperators.some((operator) => operator in value); | |
| 860 | + | |
| 861 | +const pluginStorageComparableValue = (value: unknown): string | number | boolean | null => { | |
| 862 | + if (value instanceof Date) return value.getTime(); | |
| 863 | + if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") { | |
| 864 | + return value; | |
| 865 | + } | |
| 866 | + if (value == null) return null; | |
| 867 | + return JSON.stringify(value); | |
| 868 | +}; | |
| 869 | + | |
| 870 | +const comparePluginStorageValues = (left: unknown, right: unknown): number => { | |
| 871 | + const leftValue = pluginStorageComparableValue(left); | |
| 872 | + const rightValue = pluginStorageComparableValue(right); | |
| 873 | + if (leftValue === rightValue) return 0; | |
| 874 | + if (leftValue === null) return -1; | |
| 875 | + if (rightValue === null) return 1; | |
| 876 | + if (typeof leftValue === "number" && typeof rightValue === "number") { | |
| 877 | + return leftValue - rightValue; | |
| 878 | + } | |
| 879 | + return String(leftValue).localeCompare(String(rightValue)); | |
| 880 | +}; | |
| 881 | + | |
| 882 | +const pluginStorageDataField = (data: unknown, field: string): unknown => | |
| 883 | + isPluginStorageRecord(data) ? data[field] : undefined; | |
| 884 | + | |
| 885 | +const matchesPluginStorageWhereValue = (actual: unknown, expected: unknown): boolean => { | |
| 886 | + if (!isPluginStorageWhereFilter(expected)) return Object.is(actual, expected); | |
| 887 | + | |
| 888 | + if ("eq" in expected && !Object.is(actual, expected.eq)) return false; | |
| 889 | + if ("in" in expected) { | |
| 890 | + const values = expected.in; | |
| 891 | + if (!Array.isArray(values) || !values.some((value) => Object.is(actual, value))) return false; | |
| 892 | + } | |
| 893 | + if ("gt" in expected && !(comparePluginStorageValues(actual, expected.gt) > 0)) return false; | |
| 894 | + if ("gte" in expected && !(comparePluginStorageValues(actual, expected.gte) >= 0)) return false; | |
| 895 | + if ("lt" in expected && !(comparePluginStorageValues(actual, expected.lt) < 0)) return false; | |
| 896 | + if ("lte" in expected && !(comparePluginStorageValues(actual, expected.lte) <= 0)) return false; | |
| 897 | + | |
| 898 | + return true; | |
| 899 | +}; | |
| 900 | + | |
| 901 | +const rowMatchesPluginStorageWhere = ( | |
| 902 | + row: CoreRow<"plugin_storage">, | |
| 903 | + where: Readonly<Record<string, unknown>> | undefined, | |
| 904 | +): boolean => { | |
| 905 | + if (!where) return true; | |
| 906 | + return Object.entries(where).every(([field, expected]) => | |
| 907 | + matchesPluginStorageWhereValue(pluginStorageDataField(row.data, field), expected), | |
| 908 | + ); | |
| 909 | +}; | |
| 910 | + | |
| 804 | 911 | const makePluginStorageFacade = (input: { |
| 805 | 912 | readonly core: ReturnType<typeof makeCoreDb>; |
| 806 | 913 | readonly pluginId: string; |
| @@ -828,7 +935,158 @@ const makePluginStorageFacade = (input: { · 21 unmodified lines | ||
| 828 | 935 | .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null)) |
| 829 | 936 | .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null))); |
| 830 | 937 | |
| 938 | + const queryCollection = <TDefinition extends PluginStorageCollectionDefinition>( | |
| 939 | + definition: TDefinition, | |
| 940 | + queryInput?: PluginStorageCollectionQueryInput<TDefinition>, | |
| 941 | + ) => | |
| 942 | + Effect.gen(function* () { | |
| 943 | + const validationError = pluginStorageQueryValidationError( | |
| 944 | + definition, | |
| 945 | + queryInput as | |
| 946 | + | PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition> | |
| 947 | + | undefined, | |
| 948 | + ); | |
| 949 | + if (validationError) return yield* validationError; | |
| 950 | + | |
| 951 | + const rows = yield* input.core.findMany("plugin_storage", { | |
| 952 | + where: whereFor(definition.name), | |
| 953 | + }); | |
| 954 | + const filtered = sortByScopePrecedence(rows) | |
| 955 | + .filter((row) => | |
| 956 | + queryInput?.keyPrefix === undefined ? true : row.key.startsWith(queryInput.keyPrefix), | |
| 957 | + ) | |
| 958 | + .filter((row) => | |
| 959 | + rowMatchesPluginStorageWhere( | |
| 960 | + row, | |
| 961 | + queryInput?.where as Readonly<Record<string, unknown>> | undefined, | |
| 962 | + ), | |
| 963 | + ); | |
| 964 | + | |
| 965 | + const sorted = | |
| 966 | + queryInput?.orderBy && queryInput.orderBy.length > 0 | |
| 967 | + ? [...filtered].sort((left, right) => { | |
| 968 | + for (const order of queryInput.orderBy ?? []) { | |
| 969 | + const direction = order.direction === "desc" ? -1 : 1; | |
| 970 | + const compared = | |
| 971 | + comparePluginStorageValues( | |
| 972 | + pluginStorageDataField(left.data, order.field), | |
| 973 | + pluginStorageDataField(right.data, order.field), | |
| 974 | + ) * direction; | |
| 975 | + if (compared !== 0) return compared; | |
| 976 | + } | |
| 977 | + return ( | |
| 978 | + input.scopeIds.indexOf(left.scope_id) - input.scopeIds.indexOf(right.scope_id) || | |
| 979 | + left.key.localeCompare(right.key) | |
| 980 | + ); | |
| 981 | + }) | |
| 982 | + : filtered; | |
| 983 | + | |
| 984 | + const offset = queryInput?.offset ?? 0; | |
| 985 | + const limited = | |
| 986 | + queryInput?.limit === undefined | |
| 987 | + ? sorted.slice(offset) | |
| 988 | + : sorted.slice(offset, offset + queryInput.limit); | |
| 989 | + return limited.map((row) => | |
| 990 | + pluginStorageEntryFromRow<PluginStorageCollectionData<TDefinition>>(row), | |
| 991 | + ); | |
| 992 | + }); | |
| 993 | + | |
| 831 | 994 | return { |
| 995 | + collection: (definition) => ({ | |
| 996 | + get: (storageInput) => | |
| 997 | + getVisible(definition.name, storageInput.key) as Effect.Effect< | |
| 998 | + PluginStorageEntry<PluginStorageCollectionData<typeof definition>> | null, | |
| 999 | + StorageFailure | |
| 1000 | + >, | |
| 1001 | + getAtScope: (storageInput) => | |
| 1002 | + input.core | |
| 1003 | + .findFirst("plugin_storage", { | |
| 1004 | + where: byScopedId( | |
| 1005 | + storageInput.scope, | |
| 1006 | + pluginStorageId({ | |
| 1007 | + pluginId: input.pluginId, | |
| 1008 | + collection: definition.name, | |
| 1009 | + key: storageInput.key, | |
| 1010 | + }), | |
| 1011 | + ), | |
| 1012 | + }) | |
| 1013 | + .pipe( | |
| 1014 | + Effect.map((row) => | |
| 1015 | + row | |
| 1016 | + ? pluginStorageEntryFromRow<PluginStorageCollectionData<typeof definition>>(row) | |
| 1017 | + : null, | |
| 1018 | + ), | |
| 1019 | + ), | |
| 1020 | + list: (storageInput) => | |
| 1021 | + queryCollection(definition, { | |
| 1022 | + keyPrefix: storageInput?.keyPrefix, | |
| 1023 | + }), | |
| 1024 | + put: (storageInput) => | |
| 1025 | + Effect.gen(function* () { | |
| 1026 | + if (!input.scopeIds.includes(storageInput.scope)) { | |
| 1027 | + return yield* new StorageError({ | |
| 1028 | + message: `Unknown plugin storage target scope: ${storageInput.scope}`, | |
| 1029 | + cause: undefined, | |
| 1030 | + }); | |
| 1031 | + } | |
| 1032 | + const row = yield* input.core.findFirst("plugin_storage", { | |
| 1033 | + where: byScopedId( | |
| 1034 | + storageInput.scope, | |
| 1035 | + pluginStorageId({ | |
| 1036 | + pluginId: input.pluginId, | |
| 1037 | + collection: definition.name, | |
| 1038 | + key: storageInput.key, | |
| 1039 | + }), | |
| 1040 | + ), | |
| 1041 | + }); | |
| 1042 | + if (row) { | |
| 1043 | + const now = new Date(); | |
| 1044 | + yield* input.core.updateMany("plugin_storage", { | |
| 1045 | + where: byScopedId(storageInput.scope, row.id), | |
| 1046 | + set: { | |
| 1047 | + data: storageInput.data, | |
| 1048 | + updated_at: now, | |
| 1049 | + }, | |
| 1050 | + }); | |
| 1051 | + return pluginStorageEntryFromRow({ | |
| 1052 | + ...row, | |
| 1053 | + data: storageInput.data, | |
| 1054 | + updated_at: now, | |
| 1055 | + }); | |
| 1056 | + } | |
| 1057 | + | |
| 1058 | + const now = new Date(); | |
| 1059 | + const created = yield* input.core.create("plugin_storage", { | |
| 1060 | + id: pluginStorageId({ | |
| 1061 | + pluginId: input.pluginId, | |
| 1062 | + collection: definition.name, | |
| 1063 | + key: storageInput.key, | |
| 1064 | + }), | |
| 1065 | + scope_id: storageInput.scope, | |
| 1066 | + plugin_id: input.pluginId, | |
| 1067 | + collection: definition.name, | |
| 1068 | + key: storageInput.key, | |
| 1069 | + data: storageInput.data, | |
| 1070 | + created_at: now, | |
| 1071 | + updated_at: now, | |
| 1072 | + }); | |
| 1073 | + return pluginStorageEntryFromRow(created); | |
| 1074 | + }), | |
| 1075 | + query: (queryInput) => queryCollection(definition, queryInput), | |
| 1076 | + count: (queryInput) => | |
| 1077 | + queryCollection(definition, queryInput).pipe(Effect.map((entries) => entries.length)), | |
| 1078 | + remove: (storageInput) => | |
| 1079 | + input.core.deleteMany("plugin_storage", { | |
| 1080 | + where: byScopedId( | |
| 1081 | + storageInput.scope, | |
| 1082 | + pluginStorageId({ | |
| 1083 | + pluginId: input.pluginId, | |
| 1084 | + collection: definition.name, | |
| 1085 | + key: storageInput.key, | |
| 1086 | + }), | |
| 1087 | + ), | |
| 1088 | + }), | |
| 1089 | + }), | |
| 832 | 1090 | get: (storageInput) => getVisible(storageInput.collection, storageInput.key), |
| 833 | 1091 | getAtScope: (storageInput) => |
| 834 | 1092 | input.core |
| @@ -199,6 +199,37 @@ export { · 198 unmodified lines | ||
| 199 | 199 | makeInMemoryBlobStore, |
| 200 | 200 | } from "./blob"; |
| 201 | 201 | |
| 202 | +// Plugin storage | |
| 203 | +export { | |
| 204 | + definePluginStorageCollection, | |
| 205 | + pluginStorageId, | |
| 206 | + type PluginStorageCollectionDefinition, | |
| 207 | + type PluginStorageCollectionFacade, | |
| 208 | + type PluginStorageCollectionIndexedField, | |
| 209 | + type PluginStorageCollectionKeyInput, | |
| 210 | + type PluginStorageCollectionListInput, | |
| 211 | + type PluginStorageCollectionOrderBy, | |
| 212 | + type PluginStorageCollectionPutInput, | |
| 213 | + type PluginStorageCollectionQueryInput, | |
| 214 | + type PluginStorageCollectionScopedKeyInput, | |
| 215 | + type PluginStorageCollectionWhere, | |
| 216 | + type PluginStorageConfig, | |
| 217 | + type PluginStorageEntry, | |
| 218 | + type PluginStorageFacade, | |
| 219 | + type PluginStorageIndexField, | |
| 220 | + type PluginStorageIndexSpec, | |
| 221 | + type PluginStorageKeyInput, | |
| 222 | + type PluginStorageListInput, | |
| 223 | + type PluginStoragePutInput, | |
| 224 | + type PluginStorageRuntimeCollectionDefinition, | |
| 225 | + type PluginStorageRuntimeIndexSpec, | |
| 226 | + type PluginStorageSchema, | |
| 227 | + type PluginStorageSchemaType, | |
| 228 | + type PluginStorageScopedKeyInput, | |
| 229 | + type PluginStorageWhereFilter, | |
| 230 | + type PluginStorageWhereValue, | |
| 231 | +} from "./plugin-storage"; | |
| 232 | + | |
| 202 | 233 | // OAuth 2.1 |
| 203 | 234 | export { |
| 204 | 235 | type OAuthService, |
| @@ -320,15 +351,6 @@ export { · 115 unmodified lines | ||
| 320 | 351 | definePlugin, |
| 321 | 352 | tool, |
| 322 | 353 | } from "./plugin"; |
| 323 | −export { | |
| 324 | − pluginStorageId, | |
| 325 | − type PluginStorageEntry, | |
| 326 | − type PluginStorageFacade, | |
| 327 | − type PluginStorageKeyInput, | |
| 328 | − type PluginStorageListInput, | |
| 329 | − type PluginStoragePutInput, | |
| 330 | − type PluginStorageScopedKeyInput, | |
| 331 | −} from "./plugin-storage"; | |
| 332 | 354 | |
| 333 | 355 | // Executor |
| 334 | 356 | export { |
| @@ -0,0 +1,255 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Cause, Effect, Exit, Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { createExecutor } from "./executor"; | |
| 5 | +import { StorageError } from "./fuma-runtime"; | |
| 6 | +import { ScopeId } from "./ids"; | |
| 7 | +import { definePlugin } from "./plugin"; | |
| 8 | +import { | |
| 9 | + definePluginStorageCollection, | |
| 10 | + type PluginStorageCollectionFacade, | |
| 11 | + type PluginStorageCollectionQueryInput, | |
| 12 | + type PluginStorageCollectionWhere, | |
| 13 | +} from "./plugin-storage"; | |
| 14 | +import { Scope } from "./scope"; | |
| 15 | +import { makeTestConfig, makeTestExecutor } from "./testing"; | |
| 16 | + | |
| 17 | +const ToolCall = Schema.Struct({ | |
| 18 | + runId: Schema.String, | |
| 19 | + toolId: Schema.String, | |
| 20 | + userId: Schema.NullOr(Schema.String), | |
| 21 | + clientName: Schema.NullOr(Schema.String), | |
| 22 | + status: Schema.Literals(["ok", "failed", "blocked"]), | |
| 23 | + startedAt: Schema.String, | |
| 24 | + durationMs: Schema.Number, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const toolCalls = definePluginStorageCollection("toolCalls", ToolCall, { | |
| 28 | + indexes: ["runId", "toolId", "status", "clientName", "startedAt", ["toolId", "startedAt"]], | |
| 29 | +}); | |
| 30 | + | |
| 31 | +type ToolCall = typeof ToolCall.Type; | |
| 32 | + | |
| 33 | +const assertPluginStorageTypes = (storage: PluginStorageCollectionFacade<typeof toolCalls>) => { | |
| 34 | + const validQuery = storage.query({ where: { toolId: "shell" } }); | |
| 35 | + | |
| 36 | + // @ts-expect-error durationMs is part of the data shape but is not declared as an index. | |
| 37 | + const invalidWhereQuery = storage.query({ where: { durationMs: 100 } }); | |
| 38 | + | |
| 39 | + // @ts-expect-error orderBy is also restricted to declared index fields. | |
| 40 | + const invalidOrderQuery = storage.query({ orderBy: [{ field: "durationMs" }] }); | |
| 41 | + | |
| 42 | + // @ts-expect-error indexes must point at fields in the collection schema. | |
| 43 | + definePluginStorageCollection("bad", ToolCall, { indexes: ["missing"] }); | |
| 44 | + | |
| 45 | + void validQuery; | |
| 46 | + void invalidWhereQuery; | |
| 47 | + void invalidOrderQuery; | |
| 48 | +}; | |
| 49 | +void assertPluginStorageTypes; | |
| 50 | + | |
| 51 | +const uncheckedToolCallWhere = ( | |
| 52 | + where: Readonly<Record<string, unknown>>, | |
| 53 | +): PluginStorageCollectionWhere<typeof toolCalls> => | |
| 54 | + where as PluginStorageCollectionWhere<typeof toolCalls>; | |
| 55 | + | |
| 56 | +const executionHistoryPlugin = definePlugin(() => ({ | |
| 57 | + id: "executionHistory" as const, | |
| 58 | + pluginStorage: { toolCalls }, | |
| 59 | + storage: ({ pluginStorage }) => ({ | |
| 60 | + toolCalls: pluginStorage.collection(toolCalls), | |
| 61 | + }), | |
| 62 | + extension: (ctx) => ({ | |
| 63 | + record: (scope: string, key: string, data: ToolCall) => | |
| 64 | + ctx.storage.toolCalls.put({ scope, key, data }), | |
| 65 | + get: (key: string) => ctx.storage.toolCalls.get({ key }), | |
| 66 | + query: (input?: PluginStorageCollectionQueryInput<typeof toolCalls>) => | |
| 67 | + ctx.storage.toolCalls.query(input), | |
| 68 | + count: ( | |
| 69 | + input?: Omit< | |
| 70 | + PluginStorageCollectionQueryInput<typeof toolCalls>, | |
| 71 | + "orderBy" | "limit" | "offset" | |
| 72 | + >, | |
| 73 | + ) => ctx.storage.toolCalls.count(input), | |
| 74 | + queryUnindexed: () => | |
| 75 | + ctx.storage.toolCalls.query({ | |
| 76 | + where: uncheckedToolCallWhere({ durationMs: 100 }), | |
| 77 | + }), | |
| 78 | + }), | |
| 79 | +}))(); | |
| 80 | + | |
| 81 | +const scope = (id: string, name: string) => | |
| 82 | + Scope.make({ | |
| 83 | + id: ScopeId.make(id), | |
| 84 | + name, | |
| 85 | + createdAt: new Date(), | |
| 86 | + }); | |
| 87 | + | |
| 88 | +const call = (input: { | |
| 89 | + readonly runId: string; | |
| 90 | + readonly toolId: string; | |
| 91 | + readonly status: ToolCall["status"]; | |
| 92 | + readonly startedAt: string; | |
| 93 | + readonly clientName?: string | null; | |
| 94 | + readonly userId?: string | null; | |
| 95 | + readonly durationMs?: number; | |
| 96 | +}): ToolCall => ({ | |
| 97 | + runId: input.runId, | |
| 98 | + toolId: input.toolId, | |
| 99 | + userId: input.userId ?? null, | |
| 100 | + clientName: input.clientName ?? null, | |
| 101 | + status: input.status, | |
| 102 | + startedAt: input.startedAt, | |
| 103 | + durationMs: input.durationMs ?? 0, | |
| 104 | +}); | |
| 105 | + | |
| 106 | +describe("plugin storage collections", () => { | |
| 107 | + it.effect("queries declared indexes through the executor's SQLite FumaDB target", () => | |
| 108 | + Effect.gen(function* () { | |
| 109 | + const executor = yield* makeTestExecutor({ | |
| 110 | + backend: "sqlite", | |
| 111 | + plugins: [executionHistoryPlugin] as const, | |
| 112 | + }); | |
| 113 | + const targetScope = "test-scope"; | |
| 114 | + | |
| 115 | + yield* executor.executionHistory.record( | |
| 116 | + targetScope, | |
| 117 | + "call-1", | |
| 118 | + call({ | |
| 119 | + runId: "run-a", | |
| 120 | + toolId: "browser", | |
| 121 | + status: "failed", | |
| 122 | + clientName: "codex", | |
| 123 | + startedAt: "2026-05-29T10:00:00.000Z", | |
| 124 | + durationMs: 320, | |
| 125 | + }), | |
| 126 | + ); | |
| 127 | + yield* executor.executionHistory.record( | |
| 128 | + targetScope, | |
| 129 | + "call-2", | |
| 130 | + call({ | |
| 131 | + runId: "run-a", | |
| 132 | + toolId: "shell", | |
| 133 | + status: "ok", | |
| 134 | + clientName: "codex", | |
| 135 | + startedAt: "2026-05-29T10:01:00.000Z", | |
| 136 | + durationMs: 42, | |
| 137 | + }), | |
| 138 | + ); | |
| 139 | + yield* executor.executionHistory.record( | |
| 140 | + targetScope, | |
| 141 | + "call-3", | |
| 142 | + call({ | |
| 143 | + runId: "run-b", | |
| 144 | + toolId: "shell", | |
| 145 | + status: "failed", | |
| 146 | + clientName: "codex", | |
| 147 | + startedAt: "2026-05-29T10:02:00.000Z", | |
| 148 | + durationMs: 77, | |
| 149 | + }), | |
| 150 | + ); | |
| 151 | + | |
| 152 | + const failed = yield* executor.executionHistory.query({ | |
| 153 | + where: { | |
| 154 | + clientName: "codex", | |
| 155 | + status: "failed", | |
| 156 | + }, | |
| 157 | + orderBy: [{ field: "startedAt", direction: "desc" }], | |
| 158 | + limit: 10, | |
| 159 | + }); | |
| 160 | + expect(failed.map((entry) => entry.key)).toEqual(["call-3", "call-1"]); | |
| 161 | + expect(failed.map((entry) => entry.data.toolId)).toEqual(["shell", "browser"]); | |
| 162 | + | |
| 163 | + const shellCount = yield* executor.executionHistory.count({ | |
| 164 | + where: { toolId: "shell" }, | |
| 165 | + }); | |
| 166 | + expect(shellCount).toBe(2); | |
| 167 | + }), | |
| 168 | + ); | |
| 169 | + | |
| 170 | + it.effect("uses the executor scope stack while sharing one plugin_storage table", () => | |
| 171 | + Effect.gen(function* () { | |
| 172 | + const org = scope("org", "Org"); | |
| 173 | + const user = scope("user", "User"); | |
| 174 | + const plugins = [executionHistoryPlugin] as const; | |
| 175 | + const config = makeTestConfig({ backend: "sqlite", plugins, scopes: [org] }); | |
| 176 | + | |
| 177 | + const orgExecutor = yield* createExecutor({ | |
| 178 | + ...config, | |
| 179 | + scopes: [org], | |
| 180 | + plugins, | |
| 181 | + }); | |
| 182 | + yield* orgExecutor.executionHistory.record( | |
| 183 | + org.id, | |
| 184 | + "shared", | |
| 185 | + call({ | |
| 186 | + runId: "run-scope", | |
| 187 | + toolId: "shell", | |
| 188 | + status: "ok", | |
| 189 | + startedAt: "2026-05-29T11:00:00.000Z", | |
| 190 | + }), | |
| 191 | + ); | |
| 192 | + | |
| 193 | + const userOnlyExecutor = yield* createExecutor({ | |
| 194 | + ...config, | |
| 195 | + scopes: [user], | |
| 196 | + plugins, | |
| 197 | + }); | |
| 198 | + const userOnlyRows = yield* userOnlyExecutor.executionHistory.query({ | |
| 199 | + where: { runId: "run-scope" }, | |
| 200 | + }); | |
| 201 | + expect(userOnlyRows).toEqual([]); | |
| 202 | + | |
| 203 | + const stackedExecutor = yield* createExecutor({ | |
| 204 | + ...config, | |
| 205 | + scopes: [user, org], | |
| 206 | + plugins, | |
| 207 | + }); | |
| 208 | + yield* stackedExecutor.executionHistory.record( | |
| 209 | + user.id, | |
| 210 | + "shared", | |
| 211 | + call({ | |
| 212 | + runId: "run-scope", | |
| 213 | + toolId: "browser", | |
| 214 | + status: "failed", | |
| 215 | + startedAt: "2026-05-29T11:01:00.000Z", | |
| 216 | + }), | |
| 217 | + ); | |
| 218 | + | |
| 219 | + const visibleShared = yield* stackedExecutor.executionHistory.get("shared"); | |
| 220 | + expect(visibleShared?.scopeId).toBe(user.id); | |
| 221 | + expect(visibleShared?.data.toolId).toBe("browser"); | |
| 222 | + | |
| 223 | + const scopedRows = yield* stackedExecutor.executionHistory.query({ | |
| 224 | + where: { runId: "run-scope" }, | |
| 225 | + orderBy: [{ field: "startedAt" }], | |
| 226 | + }); | |
| 227 | + expect( | |
| 228 | + scopedRows.map((entry) => [entry.key, String(entry.scopeId), entry.data.toolId]), | |
| 229 | + ).toEqual([ | |
| 230 | + ["shared", org.id, "shell"], | |
| 231 | + ["shared", user.id, "browser"], | |
| 232 | + ]); | |
| 233 | + }), | |
| 234 | + ); | |
| 235 | + | |
| 236 | + it.effect("rejects runtime queries against undeclared index fields", () => | |
| 237 | + Effect.gen(function* () { | |
| 238 | + const executor = yield* makeTestExecutor({ | |
| 239 | + backend: "sqlite", | |
| 240 | + plugins: [executionHistoryPlugin] as const, | |
| 241 | + }); | |
| 242 | + | |
| 243 | + const exit = yield* Effect.exit(executor.executionHistory.queryUnindexed()); | |
| 244 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 245 | + if (!Exit.isFailure(exit)) return; | |
| 246 | + | |
| 247 | + const reason = exit.cause.reasons.find(Cause.isFailReason); | |
| 248 | + expect(reason?.error).toBeInstanceOf(StorageError); | |
| 249 | + expect(reason?.error).toMatchObject({ | |
| 250 | + message: | |
| 251 | + 'Plugin storage collection "toolCalls" cannot query field "durationMs" because it is not declared as an index', | |
| 252 | + }); | |
| 253 | + }), | |
| 254 | + ); | |
| 255 | +}); | |
| @@ -3,6 +3,98 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import type { StorageFailure } from "./fuma-runtime"; |
| 4 | 4 | import type { ScopeId } from "./ids"; |
| 5 | 5 | |
| 6 | +export type PluginStorageSchema = { | |
| 7 | + readonly Type: object; | |
| 8 | +}; | |
| 9 | + | |
| 10 | +export type PluginStorageSchemaType<TSchema extends PluginStorageSchema> = TSchema["Type"]; | |
| 11 | + | |
| 12 | +export type PluginStorageIndexField<TData extends object> = Extract<keyof TData, string>; | |
| 13 | + | |
| 14 | +export type PluginStorageIndexSpec<TData extends object> = | |
| 15 | + | PluginStorageIndexField<TData> | |
| 16 | + | readonly PluginStorageIndexField<TData>[]; | |
| 17 | + | |
| 18 | +export type PluginStorageRuntimeIndexSpec = string | readonly string[]; | |
| 19 | + | |
| 20 | +export interface PluginStorageRuntimeCollectionDefinition { | |
| 21 | + readonly name: string; | |
| 22 | + readonly schema: PluginStorageSchema; | |
| 23 | + readonly indexes: readonly PluginStorageRuntimeIndexSpec[]; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface PluginStorageCollectionDefinition< | |
| 27 | + TName extends string = string, | |
| 28 | + TData extends object = Record<string, unknown>, | |
| 29 | + TIndexes extends readonly PluginStorageIndexSpec<TData>[] = | |
| 30 | + readonly PluginStorageIndexSpec<TData>[], | |
| 31 | +> extends PluginStorageRuntimeCollectionDefinition { | |
| 32 | + readonly name: TName; | |
| 33 | + readonly schema: PluginStorageSchema; | |
| 34 | + readonly indexes: TIndexes; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export type PluginStorageConfig = Readonly< | |
| 38 | + Record<string, PluginStorageRuntimeCollectionDefinition> | |
| 39 | +>; | |
| 40 | + | |
| 41 | +export const definePluginStorageCollection = < | |
| 42 | + const TName extends string, | |
| 43 | + const TSchema extends PluginStorageSchema, | |
| 44 | + const TIndexes extends readonly PluginStorageIndexSpec<PluginStorageSchemaType<TSchema>>[] = | |
| 45 | + readonly [], | |
| 46 | +>( | |
| 47 | + name: TName, | |
| 48 | + schema: TSchema, | |
| 49 | + options?: { | |
| 50 | + readonly indexes?: TIndexes; | |
| 51 | + }, | |
| 52 | +): PluginStorageCollectionDefinition<TName, PluginStorageSchemaType<TSchema>, TIndexes> => ({ | |
| 53 | + name, | |
| 54 | + schema, | |
| 55 | + indexes: (options?.indexes ?? []) as TIndexes, | |
| 56 | +}); | |
| 57 | + | |
| 58 | +export type PluginStorageCollectionData<TDefinition> = | |
| 59 | + TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer TData, infer _Indexes> | |
| 60 | + ? TData | |
| 61 | + : never; | |
| 62 | + | |
| 63 | +export type PluginStorageIndexFields<TIndexes> = TIndexes extends readonly (infer TIndex)[] | |
| 64 | + ? TIndex extends readonly (infer TField)[] | |
| 65 | + ? Extract<TField, string> | |
| 66 | + : Extract<TIndex, string> | |
| 67 | + : never; | |
| 68 | + | |
| 69 | +export type PluginStorageCollectionIndexedField<TDefinition> = | |
| 70 | + TDefinition extends PluginStorageCollectionDefinition<infer _Name, infer _Data, infer TIndexes> | |
| 71 | + ? PluginStorageIndexFields<TIndexes> | |
| 72 | + : never; | |
| 73 | + | |
| 74 | +export interface PluginStorageWhereFilter<TValue> { | |
| 75 | + readonly eq?: TValue; | |
| 76 | + readonly in?: readonly TValue[]; | |
| 77 | + readonly gt?: TValue; | |
| 78 | + readonly gte?: TValue; | |
| 79 | + readonly lt?: TValue; | |
| 80 | + readonly lte?: TValue; | |
| 81 | +} | |
| 82 | + | |
| 83 | +export type PluginStorageWhereValue<TValue> = TValue | PluginStorageWhereFilter<TValue>; | |
| 84 | + | |
| 85 | +export type PluginStorageCollectionWhere<TDefinition> = { | |
| 86 | + readonly [TField in PluginStorageCollectionIndexedField<TDefinition>]?: PluginStorageWhereValue< | |
| 87 | + TField extends keyof PluginStorageCollectionData<TDefinition> | |
| 88 | + ? PluginStorageCollectionData<TDefinition>[TField] | |
| 89 | + : never | |
| 90 | + >; | |
| 91 | +}; | |
| 92 | + | |
| 93 | +export interface PluginStorageCollectionOrderBy<TDefinition> { | |
| 94 | + readonly field: PluginStorageCollectionIndexedField<TDefinition>; | |
| 95 | + readonly direction?: "asc" | "desc"; | |
| 96 | +} | |
| 97 | + | |
| 6 | 98 | export interface PluginStorageKeyInput { |
| 7 | 99 | readonly collection: string; |
| 8 | 100 | readonly key: string; |
| @@ -21,6 +113,32 @@ export interface PluginStoragePutInput extends PluginStorageScopedKeyInput { · 12 unmodified lines | ||
| 21 | 113 | readonly data: unknown; |
| 22 | 114 | } |
| 23 | 115 | |
| 116 | +export interface PluginStorageCollectionKeyInput { | |
| 117 | + readonly key: string; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export interface PluginStorageCollectionScopedKeyInput extends PluginStorageCollectionKeyInput { | |
| 121 | + readonly scope: ScopeId | string; | |
| 122 | +} | |
| 123 | + | |
| 124 | +export interface PluginStorageCollectionListInput { | |
| 125 | + readonly keyPrefix?: string; | |
| 126 | +} | |
| 127 | + | |
| 128 | +export interface PluginStorageCollectionPutInput< | |
| 129 | + TData extends object, | |
| 130 | +> extends PluginStorageCollectionScopedKeyInput { | |
| 131 | + readonly data: TData; | |
| 132 | +} | |
| 133 | + | |
| 134 | +export interface PluginStorageCollectionQueryInput<TDefinition> { | |
| 135 | + readonly keyPrefix?: string; | |
| 136 | + readonly where?: PluginStorageCollectionWhere<TDefinition>; | |
| 137 | + readonly orderBy?: readonly PluginStorageCollectionOrderBy<TDefinition>[]; | |
| 138 | + readonly limit?: number; | |
| 139 | + readonly offset?: number; | |
| 140 | +} | |
| 141 | + | |
| 24 | 142 | export interface PluginStorageEntry<T = unknown> { |
| 25 | 143 | readonly id: string; |
| 26 | 144 | readonly scopeId: ScopeId | string; |
| @@ -32,7 +150,48 @@ export interface PluginStorageEntry<T = unknown> { · 5 unmodified lines | ||
| 32 | 150 | readonly updatedAt: Date; |
| 33 | 151 | } |
| 34 | 152 | |
| 153 | +export interface PluginStorageCollectionFacade< | |
| 154 | + TDefinition extends PluginStorageCollectionDefinition = PluginStorageCollectionDefinition, | |
| 155 | +> { | |
| 156 | + readonly get: ( | |
| 157 | + input: PluginStorageCollectionKeyInput, | |
| 158 | + ) => Effect.Effect< | |
| 159 | + PluginStorageEntry<PluginStorageCollectionData<TDefinition>> | null, | |
| 160 | + StorageFailure | |
| 161 | + >; | |
| 162 | + readonly getAtScope: ( | |
| 163 | + input: PluginStorageCollectionScopedKeyInput, | |
| 164 | + ) => Effect.Effect< | |
| 165 | + PluginStorageEntry<PluginStorageCollectionData<TDefinition>> | null, | |
| 166 | + StorageFailure | |
| 167 | + >; | |
| 168 | + readonly list: ( | |
| 169 | + input?: PluginStorageCollectionListInput, | |
| 170 | + ) => Effect.Effect< | |
| 171 | + readonly PluginStorageEntry<PluginStorageCollectionData<TDefinition>>[], | |
| 172 | + StorageFailure | |
| 173 | + >; | |
| 174 | + readonly put: ( | |
| 175 | + input: PluginStorageCollectionPutInput<PluginStorageCollectionData<TDefinition>>, | |
| 176 | + ) => Effect.Effect<PluginStorageEntry<PluginStorageCollectionData<TDefinition>>, StorageFailure>; | |
| 177 | + readonly query: ( | |
| 178 | + input?: PluginStorageCollectionQueryInput<TDefinition>, | |
| 179 | + ) => Effect.Effect< | |
| 180 | + readonly PluginStorageEntry<PluginStorageCollectionData<TDefinition>>[], | |
| 181 | + StorageFailure | |
| 182 | + >; | |
| 183 | + readonly count: ( | |
| 184 | + input?: Omit<PluginStorageCollectionQueryInput<TDefinition>, "orderBy" | "limit" | "offset">, | |
| 185 | + ) => Effect.Effect<number, StorageFailure>; | |
| 186 | + readonly remove: ( | |
| 187 | + input: PluginStorageCollectionScopedKeyInput, | |
| 188 | + ) => Effect.Effect<void, StorageFailure>; | |
| 189 | +} | |
| 190 | + | |
| 35 | 191 | export interface PluginStorageFacade { |
| 192 | + readonly collection: <const TDefinition extends PluginStorageCollectionDefinition>( | |
| 193 | + definition: TDefinition, | |
| 194 | + ) => PluginStorageCollectionFacade<TDefinition>; | |
| 36 | 195 | readonly get: <T = unknown>( |
| 37 | 196 | input: PluginStorageKeyInput, |
| 38 | 197 | ) => Effect.Effect<PluginStorageEntry<T> | null, StorageFailure>; |
| @@ -42,7 +42,7 @@ import type { · 41 unmodified lines | ||
| 42 | 42 | SourceRemovalNotAllowedError, |
| 43 | 43 | } from "./errors"; |
| 44 | 44 | import type { OAuthService } from "./oauth"; |
| 45 | −import type { PluginStorageFacade } from "./plugin-storage"; | |
| 45 | +import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; | |
| 46 | 46 | import type { |
| 47 | 47 | CreateToolPolicyInput, |
| 48 | 48 | RemoveToolPolicyInput, |
| @@ -497,6 +497,12 @@ export interface PluginSpec< · 448 unmodified lines | ||
| 497 | 497 | * across plugins are structurally impossible. */ |
| 498 | 498 | readonly storage: (deps: StorageDeps) => TStore; |
| 499 | 499 | |
| 500 | + /** Host-owned plugin storage declarations. Plugins declare logical | |
| 501 | + * collections and indexed JSON fields here; data still lives in the | |
| 502 | + * executor's shared `plugin_storage` table instead of per-plugin | |
| 503 | + * adapter schemas. */ | |
| 504 | + readonly pluginStorage?: PluginStorageConfig; | |
| 505 | + | |
| 500 | 506 | /** JSON-serializable config the plugin wants its `./client` bundle to |
| 501 | 507 | * see. The Vite plugin reads this off each `executor.config.ts` spec |
| 502 | 508 | * at build time and bakes it into the virtual `plugins-client` |
| @@ -57,13 +57,33 @@ export { · 56 unmodified lines | ||
| 57 | 57 | } from "./credential-bindings"; |
| 58 | 58 | |
| 59 | 59 | export { |
| 60 | + definePluginStorageCollection, | |
| 60 | 61 | pluginStorageId, |
| 62 | + type PluginStorageCollectionDefinition, | |
| 63 | + type PluginStorageCollectionFacade, | |
| 64 | + type PluginStorageCollectionIndexedField, | |
| 65 | + type PluginStorageCollectionKeyInput, | |
| 66 | + type PluginStorageCollectionListInput, | |
| 67 | + type PluginStorageCollectionOrderBy, | |
| 68 | + type PluginStorageCollectionPutInput, | |
| 69 | + type PluginStorageCollectionQueryInput, | |
| 70 | + type PluginStorageCollectionScopedKeyInput, | |
| 71 | + type PluginStorageCollectionWhere, | |
| 72 | + type PluginStorageConfig, | |
| 61 | 73 | type PluginStorageEntry, |
| 62 | 74 | type PluginStorageFacade, |
| 75 | + type PluginStorageIndexField, | |
| 76 | + type PluginStorageIndexSpec, | |
| 63 | 77 | type PluginStorageKeyInput, |
| 64 | 78 | type PluginStorageListInput, |
| 65 | 79 | type PluginStoragePutInput, |
| 80 | + type PluginStorageRuntimeCollectionDefinition, | |
| 81 | + type PluginStorageRuntimeIndexSpec, | |
| 82 | + type PluginStorageSchema, | |
| 83 | + type PluginStorageSchemaType, | |
| 66 | 84 | type PluginStorageScopedKeyInput, |
| 85 | + type PluginStorageWhereFilter, | |
| 86 | + type PluginStorageWhereValue, | |
| 67 | 87 | } from "./plugin-storage"; |
| 68 | 88 | |
| 69 | 89 | export { SourceDetectionResult, type Source } from "./types"; |