@spinabot/brigade 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/convex/_generated/api.d.ts +85 -0
  2. package/convex/_generated/api.js +23 -0
  3. package/convex/_generated/dataModel.d.ts +60 -0
  4. package/convex/_generated/server.d.ts +143 -0
  5. package/convex/_generated/server.js +93 -0
  6. package/convex/admin.d.ts +57 -0
  7. package/convex/admin.ts +315 -0
  8. package/convex/auth.d.ts +159 -0
  9. package/convex/auth.ts +217 -0
  10. package/convex/blobs.d.ts +38 -0
  11. package/convex/blobs.ts +115 -0
  12. package/convex/channels.d.ts +150 -0
  13. package/convex/channels.ts +455 -0
  14. package/convex/config.d.ts +67 -0
  15. package/convex/config.ts +168 -0
  16. package/convex/cron.d.ts +237 -0
  17. package/convex/cron.ts +199 -0
  18. package/convex/execApprovals.d.ts +31 -0
  19. package/convex/execApprovals.ts +58 -0
  20. package/convex/extensions.d.ts +30 -0
  21. package/convex/extensions.ts +51 -0
  22. package/convex/health.d.ts +18 -0
  23. package/convex/health.ts +69 -0
  24. package/convex/instance.d.ts +34 -0
  25. package/convex/instance.ts +82 -0
  26. package/convex/logs.d.ts +178 -0
  27. package/convex/logs.ts +253 -0
  28. package/convex/memory.d.ts +354 -0
  29. package/convex/memory.ts +536 -0
  30. package/convex/messages.d.ts +124 -0
  31. package/convex/messages.ts +347 -0
  32. package/convex/org.d.ts +75 -0
  33. package/convex/org.ts +99 -0
  34. package/convex/schema.d.ts +1130 -0
  35. package/convex/schema.ts +847 -0
  36. package/convex/sessions.d.ts +100 -0
  37. package/convex/sessions.ts +105 -0
  38. package/convex/skills.d.ts +73 -0
  39. package/convex/skills.ts +102 -0
  40. package/convex/subagents.d.ts +214 -0
  41. package/convex/subagents.ts +99 -0
  42. package/convex/tsconfig.json +23 -0
  43. package/convex/whatsappAuth.d.ts +52 -0
  44. package/convex/whatsappAuth.ts +151 -0
  45. package/convex/workspace.d.ts +49 -0
  46. package/convex/workspace.ts +106 -0
  47. package/dist/buildstamp.json +1 -1
  48. package/dist/cli/commands/convex-cmd.d.ts +27 -0
  49. package/dist/cli/commands/convex-cmd.d.ts.map +1 -0
  50. package/dist/cli/commands/convex-cmd.js +162 -0
  51. package/dist/cli/commands/convex-cmd.js.map +1 -0
  52. package/dist/cli/program/build-program.d.ts.map +1 -1
  53. package/dist/cli/program/build-program.js +64 -0
  54. package/dist/cli/program/build-program.js.map +1 -1
  55. package/dist/config/paths.d.ts +3 -0
  56. package/dist/config/paths.d.ts.map +1 -1
  57. package/dist/config/paths.js +39 -0
  58. package/dist/config/paths.js.map +1 -1
  59. package/package.json +7 -1
  60. package/scripts/convex-dev.mjs +321 -0
  61. package/scripts/convex-push.mjs +69 -0
  62. package/scripts/install-convex.mjs +123 -0
@@ -0,0 +1,315 @@
1
+ // convex/admin.ts — instance-level inspect + factory reset.
2
+ //
3
+ // Single-operator deployments hold exactly one Brigade instance, so "reset
4
+ // the instance" means "delete every row in every Brigade table" (plus any
5
+ // File-Storage objects rows point at). Used by the onboarding wizard's
6
+ // "start fresh" choice and the `brigade store reset` CLI.
7
+ //
8
+ // Deletion runs SERVER-SIDE and SELF-SCHEDULES (`resetStart` → `resetWorker`):
9
+ // a long-lived instance can hold hundreds of thousands of rows (cron runs,
10
+ // session events) and a single Convex mutation has op/time/byte limits, so each
11
+ // worker deletes one small batch then reschedules itself until its table drains.
12
+ // All tables drain concurrently; the client just polls `resetStatus`. The older
13
+ // client-paced `resetPage` is kept for back-compat / tests.
14
+
15
+ import { v } from "convex/values";
16
+ import { internal } from "./_generated/api.js";
17
+ import { internalMutation, mutation, query } from "./_generated/server.js";
18
+ import type { MutationCtx } from "./_generated/server.js";
19
+
20
+ // Every table in convex/schema.ts. Kept as an explicit literal union so a
21
+ // future schema addition that forgets to extend this list fails loudly in
22
+ // review rather than silently surviving a "factory reset".
23
+ const RESETTABLE_TABLES = [
24
+ "brigadeConfig",
25
+ "brigadeConfigAudit",
26
+ "brigadeConfigBackups",
27
+ "configHealth",
28
+ "personaFiles",
29
+ "workspaceState",
30
+ "memoryFacts",
31
+ "memoryExtractCursors",
32
+ "memoryConsolidateState",
33
+ "memoryEvents",
34
+ "sessions",
35
+ "sessionTranscriptRecords",
36
+ "sessionInboxEvents",
37
+ "sessionEvents",
38
+ "subsystemLog",
39
+ "cronJobs",
40
+ "cronRuns",
41
+ "cronServiceState",
42
+ "channelAccess",
43
+ "whatsappAuthFile",
44
+ "channelMediaBlob",
45
+ "authProfiles",
46
+ "profileState",
47
+ "authFiles",
48
+ "systemMeta",
49
+ "whatsappAuthCreds",
50
+ "whatsappAuthKeys",
51
+ "execApprovals",
52
+ "skills",
53
+ "extensions",
54
+ "orgDeriveAudit",
55
+ "orgChartCache",
56
+ "subagentRuns",
57
+ "gatewayCoord",
58
+ "brigadeBlobs",
59
+ ] as const;
60
+
61
+ export type ResettableTable = (typeof RESETTABLE_TABLES)[number];
62
+
63
+ const TableName = v.union(
64
+ ...RESETTABLE_TABLES.map((t) => v.literal(t)),
65
+ );
66
+
67
+ // Tables whose rows can point at File-Storage objects — the object must be
68
+ // deleted BEFORE the row or it becomes an orphan (storage isn't ref-counted).
69
+ const STORAGE_SPILL_TABLES = new Set<string>([
70
+ "channelMediaBlob",
71
+ "whatsappAuthKeys",
72
+ "brigadeBlobs",
73
+ ]);
74
+
75
+ // High-volume session/log/run tables probed (presence only, never counted) by
76
+ // instanceSummary so the "found an existing Brigade" headline doesn't imply an
77
+ // empty backend when thousands of event/log rows are actually present.
78
+ const ACTIVITY_TABLES = [
79
+ "sessionEvents",
80
+ "cronRuns",
81
+ "subsystemLog",
82
+ "sessionInboxEvents",
83
+ "sessionTranscriptRecords",
84
+ "subagentRuns",
85
+ ] as const;
86
+
87
+ /** The list the reset client iterates — exported via query so the CLI and
88
+ * the server can never drift on which tables exist. */
89
+ export const listResettableTables = query({
90
+ args: {},
91
+ handler: async () => [...RESETTABLE_TABLES],
92
+ });
93
+
94
+ /** Headline summary for "found an existing Brigade in this backend". */
95
+ export const instanceSummary = query({
96
+ args: {},
97
+ handler: async (ctx) => {
98
+ const configRow = await ctx.db.query("brigadeConfig").first();
99
+ const memories = await ctx.db.query("memoryFacts").take(1001);
100
+ const sessions = await ctx.db.query("sessions").take(1001);
101
+ const cronJobs = await ctx.db.query("cronJobs").take(1001);
102
+ const personas = await ctx.db.query("personaFiles").take(1001);
103
+ const waCreds = await ctx.db.query("whatsappAuthCreds").take(1);
104
+ const fp = await ctx.db
105
+ .query("systemMeta")
106
+ .withIndex("by_key", (q) => q.eq("key", "encryptionFingerprint"))
107
+ .first();
108
+ // High-volume session/log/run tables hold the bulk of a long-lived
109
+ // instance, but their rows can be large (event payloads, transcript chunks)
110
+ // — counting them with .take(1001) could exceed the 16 MiB query read cap.
111
+ // So we only PROBE for presence (take(1)) to report that history exists,
112
+ // rather than imply "0" when thousands of rows are actually there.
113
+ let hasActivity = false;
114
+ for (const t of ACTIVITY_TABLES) {
115
+ if ((await ctx.db.query(t).take(1)).length > 0) {
116
+ hasActivity = true;
117
+ break;
118
+ }
119
+ }
120
+ const cap = (n: number): number => Math.min(n, 1000);
121
+ return {
122
+ hasData:
123
+ configRow !== null ||
124
+ memories.length > 0 ||
125
+ sessions.length > 0 ||
126
+ cronJobs.length > 0 ||
127
+ personas.length > 0,
128
+ createdAtMs: configRow?._creationTime ?? null,
129
+ counts: {
130
+ memories: cap(memories.length),
131
+ sessions: cap(sessions.length),
132
+ cronJobs: cap(cronJobs.length),
133
+ personas: cap(personas.length),
134
+ },
135
+ hasActivity,
136
+ whatsappLinked: waCreds.length > 0,
137
+ storedKeyFingerprint: (fp?.value as string | undefined) ?? null,
138
+ };
139
+ },
140
+ });
141
+
142
+ /** Rough byte size of a row, for read-budgeting only. The sealed ArrayBuffer
143
+ * columns (transcript chunks, media, auth, blobs) dominate; everything else
144
+ * is tiny. This drives WHEN we stop reading more pages — the actual read cost
145
+ * is charged by Convex on each `.take()`. */
146
+ function estimateRowBytes(row: Record<string, unknown>): number {
147
+ let n = 64; // per-row overhead
148
+ for (const value of Object.values(row)) {
149
+ if (value instanceof ArrayBuffer) n += value.byteLength;
150
+ else if (typeof value === "string") n += value.length;
151
+ else if (typeof value === "number" || typeof value === "boolean") n += 8;
152
+ else if (value && typeof value === "object") n += JSON.stringify(value).length;
153
+ }
154
+ return n;
155
+ }
156
+
157
+ /** Delete ONE bounded batch from a table (reaping File-Storage spills first).
158
+ * Shared by the legacy client-paced `resetPage` and the server-scheduled
159
+ * `resetWorker`. Returns `{ deleted, done }`; `done` is true ONLY when the
160
+ * table is fully drained — the caller loops/reschedules while `!done`.
161
+ *
162
+ * Two caps keep every batch comfortably under Convex's per-execution ceiling:
163
+ * a row cap (`maxRows`) and a byte cap (`READ_CEILING`). Convex KILLS a function
164
+ * that exceeds its op/time/byte budget, and that kill is not catchable inside
165
+ * the function — so the only robust defense is to keep each batch small and let
166
+ * the caller chain more. Large-row tables (transcript chunks, media, auth,
167
+ * blobs near the 1 MiB doc limit) trip the byte cap after a handful of rows;
168
+ * small-row tables clear up to `maxRows`. Deleted rows drop out of the next
169
+ * `.take()`, so we always read from the front with no cursor. */
170
+ async function drainOneBatch(
171
+ ctx: MutationCtx,
172
+ table: ResettableTable,
173
+ maxRows: number,
174
+ readCeiling = 4 * 1024 * 1024, // conservative default; legacy resetPage passes 6 MiB
175
+ ): Promise<{ deleted: number; done: boolean }> {
176
+ const INNER = 8; // small read window; deleted rows drop out of the next take()
177
+ let removed = 0;
178
+ let bytesRead = 0;
179
+ let drained = false;
180
+ let capped = false;
181
+ while (removed < maxRows && !capped) {
182
+ // Never read more than the batch still wants, so `removed` can't overshoot
183
+ // `maxRows` by up to INNER-1.
184
+ const want = Math.min(INNER, maxRows - removed);
185
+ const rows = await ctx.db.query(table).take(want);
186
+ for (const row of rows) {
187
+ bytesRead += estimateRowBytes(row as unknown as Record<string, unknown>);
188
+ if (STORAGE_SPILL_TABLES.has(table)) {
189
+ const storageId = (row as { storageId?: string }).storageId;
190
+ if (storageId) {
191
+ try {
192
+ await ctx.storage.delete(storageId as never);
193
+ } catch {
194
+ // Already gone — the row delete below still proceeds.
195
+ }
196
+ }
197
+ }
198
+ await ctx.db.delete(row._id);
199
+ removed += 1;
200
+ // Stop the MOMENT we cross the read budget — mid-pass, so a handful of
201
+ // ~1 MiB rows can't blow ~INNER MiB past the cap before the next check.
202
+ if (bytesRead >= readCeiling) {
203
+ capped = true;
204
+ break;
205
+ }
206
+ }
207
+ // A short read means the table is now empty — but only trust that when we
208
+ // did NOT stop early on the byte cap (a capped pass read a full `want`).
209
+ if (!capped && rows.length < want) {
210
+ drained = true;
211
+ break;
212
+ }
213
+ }
214
+ return { deleted: removed, done: drained };
215
+ }
216
+
217
+ /** Legacy client-paced single batch. Behaviour-identical to the original
218
+ * `resetPage` (200-row default, 6 MiB read ceiling). Kept for back-compat; the
219
+ * onboarding wizard and `brigade store reset` now use the server-scheduled path
220
+ * below. */
221
+ export const resetPage = mutation({
222
+ args: { table: TableName, limit: v.optional(v.number()) },
223
+ handler: async (ctx, args) => {
224
+ const maxRows = args.limit && args.limit > 0 ? Math.min(args.limit, 500) : 200;
225
+ return await drainOneBatch(ctx, args.table, maxRows, 6 * 1024 * 1024);
226
+ },
227
+ });
228
+
229
+ // ─────────────────────────────────────────────────────────────────────────────
230
+ // Server-side, self-scheduling factory reset — scales to any table size.
231
+ //
232
+ // `resetStart` seeds one progress row per table and schedules a `resetWorker`
233
+ // for each. Every worker deletes one small batch then reschedules ITSELF until
234
+ // its table is drained, so deletion runs entirely on the backend: no per-page
235
+ // client round-trips, no single mega-transaction to time out, and all tables
236
+ // drain concurrently. The client calls `resetStart` once then polls
237
+ // `resetStatus` until `done`.
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ const WORKER_BATCH = 100; // rows per scheduled transaction (small = self-host safe)
241
+ const SPILL_BATCH = 25; // spill tables do a storage.delete per row — go smaller
242
+
243
+ export const resetStart = mutation({
244
+ args: { runId: v.string() },
245
+ handler: async (ctx, args) => {
246
+ // Clear any prior progress rows so a re-run starts from a clean slate.
247
+ for (const row of await ctx.db.query("resetProgress").collect()) {
248
+ await ctx.db.delete(row._id);
249
+ }
250
+ const now = Date.now();
251
+ for (const table of RESETTABLE_TABLES) {
252
+ await ctx.db.insert("resetProgress", {
253
+ runId: args.runId,
254
+ table,
255
+ deleted: 0,
256
+ done: false,
257
+ updatedAt: now,
258
+ });
259
+ await ctx.scheduler.runAfter(0, internal.admin.resetWorker, {
260
+ runId: args.runId,
261
+ table,
262
+ batch: STORAGE_SPILL_TABLES.has(table) ? SPILL_BATCH : WORKER_BATCH,
263
+ });
264
+ }
265
+ return { runId: args.runId, tablesTotal: RESETTABLE_TABLES.length };
266
+ },
267
+ });
268
+
269
+ export const resetWorker = internalMutation({
270
+ args: { runId: v.string(), table: TableName, batch: v.number() },
271
+ handler: async (ctx, args) => {
272
+ const { deleted, done } = await drainOneBatch(ctx, args.table, args.batch);
273
+ const row = await ctx.db
274
+ .query("resetProgress")
275
+ .withIndex("by_run_table", (q) => q.eq("runId", args.runId).eq("table", args.table))
276
+ .first();
277
+ if (row) {
278
+ await ctx.db.patch(row._id, {
279
+ deleted: row.deleted + deleted,
280
+ done: done || deleted === 0,
281
+ updatedAt: Date.now(),
282
+ });
283
+ }
284
+ // Reschedule the SAME table until it reports drained. A drained or empty
285
+ // batch ends the chain — no infinite reschedule.
286
+ if (!done && deleted > 0) {
287
+ await ctx.scheduler.runAfter(0, internal.admin.resetWorker, {
288
+ runId: args.runId,
289
+ table: args.table,
290
+ batch: args.batch,
291
+ });
292
+ }
293
+ },
294
+ });
295
+
296
+ export const resetStatus = query({
297
+ args: { runId: v.string() },
298
+ handler: async (ctx, args) => {
299
+ const rows = await ctx.db
300
+ .query("resetProgress")
301
+ .withIndex("by_run", (q) => q.eq("runId", args.runId))
302
+ .collect();
303
+ if (rows.length === 0) return null;
304
+ const deletedTotal = rows.reduce((sum, r) => sum + r.deleted, 0);
305
+ const tablesDone = rows.filter((r) => r.done).length;
306
+ return {
307
+ done: tablesDone >= rows.length,
308
+ deletedTotal,
309
+ tablesTotal: rows.length,
310
+ tablesDone,
311
+ tables: rows.map((r) => ({ table: r.table, deleted: r.deleted, done: r.done })),
312
+ updatedAt: Math.max(...rows.map((r) => r.updatedAt)),
313
+ };
314
+ },
315
+ });
@@ -0,0 +1,159 @@
1
+ export declare const listProfiles: import("convex/server").RegisteredQuery<"public", {
2
+ agentId: string;
3
+ ownerId: string;
4
+ }, Promise<{
5
+ _id: import("convex/values").GenericId<"authProfiles">;
6
+ _creationTime: number;
7
+ alias?: string | undefined;
8
+ expires?: number | undefined;
9
+ metadata?: any;
10
+ keyEnc?: ArrayBuffer | undefined;
11
+ keyRef?: {
12
+ id: string;
13
+ source: string;
14
+ provider: string;
15
+ } | undefined;
16
+ tokenEnc?: ArrayBuffer | undefined;
17
+ tokenRef?: {
18
+ id: string;
19
+ source: string;
20
+ provider: string;
21
+ } | undefined;
22
+ accessEnc?: ArrayBuffer | undefined;
23
+ refreshEnc?: ArrayBuffer | undefined;
24
+ type: "api_key" | "oauth" | "token";
25
+ profileId: string;
26
+ agentId: string;
27
+ provider: string;
28
+ ownerId: string;
29
+ updatedAt: number;
30
+ }[]>>;
31
+ export declare const getProfile: import("convex/server").RegisteredQuery<"public", {
32
+ profileId: string;
33
+ agentId: string;
34
+ ownerId: string;
35
+ }, Promise<{
36
+ _id: import("convex/values").GenericId<"authProfiles">;
37
+ _creationTime: number;
38
+ alias?: string | undefined;
39
+ expires?: number | undefined;
40
+ metadata?: any;
41
+ keyEnc?: ArrayBuffer | undefined;
42
+ keyRef?: {
43
+ id: string;
44
+ source: string;
45
+ provider: string;
46
+ } | undefined;
47
+ tokenEnc?: ArrayBuffer | undefined;
48
+ tokenRef?: {
49
+ id: string;
50
+ source: string;
51
+ provider: string;
52
+ } | undefined;
53
+ accessEnc?: ArrayBuffer | undefined;
54
+ refreshEnc?: ArrayBuffer | undefined;
55
+ type: "api_key" | "oauth" | "token";
56
+ profileId: string;
57
+ agentId: string;
58
+ provider: string;
59
+ ownerId: string;
60
+ updatedAt: number;
61
+ } | null>>;
62
+ export declare const upsertProfile: import("convex/server").RegisteredMutation<"public", {
63
+ alias?: string | undefined;
64
+ expires?: number | undefined;
65
+ metadata?: any;
66
+ keyEnc?: ArrayBuffer | undefined;
67
+ keyRef?: {
68
+ id: string;
69
+ source: string;
70
+ provider: string;
71
+ } | undefined;
72
+ tokenEnc?: ArrayBuffer | undefined;
73
+ tokenRef?: {
74
+ id: string;
75
+ source: string;
76
+ provider: string;
77
+ } | undefined;
78
+ accessEnc?: ArrayBuffer | undefined;
79
+ refreshEnc?: ArrayBuffer | undefined;
80
+ type: "api_key" | "oauth" | "token";
81
+ profileId: string;
82
+ agentId: string;
83
+ provider: string;
84
+ ownerId: string;
85
+ }, Promise<{
86
+ profileId: string;
87
+ updated: boolean;
88
+ }>>;
89
+ export declare const deleteProfile: import("convex/server").RegisteredMutation<"public", {
90
+ profileId: string;
91
+ agentId: string;
92
+ ownerId: string;
93
+ }, Promise<{
94
+ deleted: boolean;
95
+ }>>;
96
+ export declare const loadState: import("convex/server").RegisteredQuery<"public", {
97
+ agentId: string;
98
+ ownerId: string;
99
+ }, Promise<{
100
+ _id: import("convex/values").GenericId<"profileState">;
101
+ _creationTime: number;
102
+ disabledUntil?: number | undefined;
103
+ cooldownUntil?: number | undefined;
104
+ cooldownModel?: string | undefined;
105
+ errorCount?: number | undefined;
106
+ lastUsed?: number | undefined;
107
+ cooldownReason?: string | undefined;
108
+ disabledReason?: string | undefined;
109
+ failureCounts?: any;
110
+ lastFailureAt?: number | undefined;
111
+ explicitOrder?: number | undefined;
112
+ profileId: string;
113
+ agentId: string;
114
+ provider: string;
115
+ ownerId: string;
116
+ isLastGood: boolean;
117
+ }[]>>;
118
+ export declare const readAuthFile: import("convex/server").RegisteredQuery<"public", {
119
+ agentId: string;
120
+ kind: "auth-state" | "profile-state" | "models";
121
+ ownerId: string;
122
+ }, Promise<{
123
+ _id: import("convex/values").GenericId<"authFiles">;
124
+ _creationTime: number;
125
+ agentId: string;
126
+ payload: ArrayBuffer;
127
+ kind: "auth-state" | "profile-state" | "models";
128
+ ownerId: string;
129
+ updatedAt: number;
130
+ } | null>>;
131
+ export declare const writeAuthFile: import("convex/server").RegisteredMutation<"public", {
132
+ agentId: string;
133
+ payload: ArrayBuffer;
134
+ kind: "auth-state" | "profile-state" | "models";
135
+ ownerId: string;
136
+ }, Promise<{
137
+ updated: boolean;
138
+ }>>;
139
+ export declare const upsertState: import("convex/server").RegisteredMutation<"public", {
140
+ disabledUntil?: number | undefined;
141
+ cooldownUntil?: number | undefined;
142
+ cooldownModel?: string | undefined;
143
+ errorCount?: number | undefined;
144
+ lastUsed?: number | undefined;
145
+ cooldownReason?: string | undefined;
146
+ disabledReason?: string | undefined;
147
+ failureCounts?: any;
148
+ lastFailureAt?: number | undefined;
149
+ explicitOrder?: number | undefined;
150
+ profileId: string;
151
+ agentId: string;
152
+ provider: string;
153
+ ownerId: string;
154
+ isLastGood: boolean;
155
+ }, Promise<{
156
+ profileId: string;
157
+ updated: boolean;
158
+ }>>;
159
+ //# sourceMappingURL=auth.d.ts.map
package/convex/auth.ts ADDED
@@ -0,0 +1,217 @@
1
+ // convex/auth.ts
2
+ //
3
+ // Convex functions for the authProfiles + profileState tables. Each
4
+ // operator gets one logical "agent" namespace per agentId; the agentId is
5
+ // part of every row's primary key.
6
+ //
7
+ // Profiles carry encrypted secrets (`keyEnc` / `tokenEnc` / etc.) — the
8
+ // adapter (ConvexAuthStore) handles encrypt-on-write / decrypt-on-read
9
+ // via the per-owner DEK so primitive code only ever sees plaintext.
10
+
11
+ import { v } from "convex/values";
12
+ import { mutation, query } from "./_generated/server.js";
13
+
14
+ const ProfileType = v.union(
15
+ v.literal("api_key"),
16
+ v.literal("oauth"),
17
+ v.literal("token"),
18
+ );
19
+
20
+ const SecretRef = v.object({
21
+ source: v.string(),
22
+ provider: v.string(),
23
+ id: v.string(),
24
+ });
25
+
26
+ // ============================================================================
27
+ // authProfiles
28
+ // ============================================================================
29
+
30
+ export const listProfiles = query({
31
+ args: { ownerId: v.string(), agentId: v.string() },
32
+ handler: async (ctx, args) => {
33
+ return ctx.db
34
+ .query("authProfiles")
35
+ .withIndex("by_owner_agent", (q) =>
36
+ q.eq("ownerId", args.ownerId).eq("agentId", args.agentId),
37
+ )
38
+ .collect();
39
+ },
40
+ });
41
+
42
+ export const getProfile = query({
43
+ args: {
44
+ ownerId: v.string(),
45
+ agentId: v.string(),
46
+ profileId: v.string(),
47
+ },
48
+ handler: async (ctx, args) => {
49
+ return ctx.db
50
+ .query("authProfiles")
51
+ .withIndex("by_owner_agent_profileId", (q) =>
52
+ q
53
+ .eq("ownerId", args.ownerId)
54
+ .eq("agentId", args.agentId)
55
+ .eq("profileId", args.profileId),
56
+ )
57
+ .first();
58
+ },
59
+ });
60
+
61
+ export const upsertProfile = mutation({
62
+ args: {
63
+ ownerId: v.string(),
64
+ agentId: v.string(),
65
+ profileId: v.string(),
66
+ provider: v.string(),
67
+ alias: v.optional(v.string()),
68
+ type: ProfileType,
69
+ keyEnc: v.optional(v.bytes()),
70
+ keyRef: v.optional(SecretRef),
71
+ tokenEnc: v.optional(v.bytes()),
72
+ tokenRef: v.optional(SecretRef),
73
+ accessEnc: v.optional(v.bytes()),
74
+ refreshEnc: v.optional(v.bytes()),
75
+ expires: v.optional(v.number()),
76
+ metadata: v.optional(v.any()),
77
+ },
78
+ handler: async (ctx, args) => {
79
+ const existing = await ctx.db
80
+ .query("authProfiles")
81
+ .withIndex("by_owner_agent_profileId", (q) =>
82
+ q
83
+ .eq("ownerId", args.ownerId)
84
+ .eq("agentId", args.agentId)
85
+ .eq("profileId", args.profileId),
86
+ )
87
+ .first();
88
+ const payload = { ...args, updatedAt: Date.now() };
89
+ if (existing) {
90
+ await ctx.db.replace(existing._id, payload);
91
+ return { profileId: args.profileId, updated: true };
92
+ }
93
+ await ctx.db.insert("authProfiles", payload);
94
+ return { profileId: args.profileId, updated: false };
95
+ },
96
+ });
97
+
98
+ export const deleteProfile = mutation({
99
+ args: {
100
+ ownerId: v.string(),
101
+ agentId: v.string(),
102
+ profileId: v.string(),
103
+ },
104
+ handler: async (ctx, args) => {
105
+ const existing = await ctx.db
106
+ .query("authProfiles")
107
+ .withIndex("by_owner_agent_profileId", (q) =>
108
+ q
109
+ .eq("ownerId", args.ownerId)
110
+ .eq("agentId", args.agentId)
111
+ .eq("profileId", args.profileId),
112
+ )
113
+ .first();
114
+ if (!existing) return { deleted: false };
115
+ await ctx.db.delete(existing._id);
116
+ return { deleted: true };
117
+ },
118
+ });
119
+
120
+ // ============================================================================
121
+ // profileState (per-profile cooldown / last-good / failure counters)
122
+ // ============================================================================
123
+
124
+ export const loadState = query({
125
+ args: { ownerId: v.string(), agentId: v.string() },
126
+ handler: async (ctx, args) => {
127
+ return ctx.db
128
+ .query("profileState")
129
+ .withIndex("by_owner_agent_profileId", (q) =>
130
+ q.eq("ownerId", args.ownerId).eq("agentId", args.agentId),
131
+ )
132
+ .collect();
133
+ },
134
+ });
135
+
136
+ // ============================================================================
137
+ // authFiles (whole-file state blobs — auth-state.json / profile-state.json)
138
+ // ============================================================================
139
+
140
+ const AuthFileKind = v.union(
141
+ v.literal("auth-state"),
142
+ v.literal("profile-state"),
143
+ v.literal("models"),
144
+ );
145
+
146
+ export const readAuthFile = query({
147
+ args: { ownerId: v.string(), agentId: v.string(), kind: AuthFileKind },
148
+ handler: async (ctx, args) => {
149
+ return ctx.db
150
+ .query("authFiles")
151
+ .withIndex("by_owner_agent_kind", (q) =>
152
+ q.eq("ownerId", args.ownerId).eq("agentId", args.agentId).eq("kind", args.kind),
153
+ )
154
+ .first();
155
+ },
156
+ });
157
+
158
+ export const writeAuthFile = mutation({
159
+ args: {
160
+ ownerId: v.string(),
161
+ agentId: v.string(),
162
+ kind: AuthFileKind,
163
+ payload: v.bytes(),
164
+ },
165
+ handler: async (ctx, args) => {
166
+ const existing = await ctx.db
167
+ .query("authFiles")
168
+ .withIndex("by_owner_agent_kind", (q) =>
169
+ q.eq("ownerId", args.ownerId).eq("agentId", args.agentId).eq("kind", args.kind),
170
+ )
171
+ .first();
172
+ const row = { ...args, updatedAt: Date.now() };
173
+ if (existing) {
174
+ await ctx.db.replace(existing._id, row);
175
+ return { updated: true };
176
+ }
177
+ await ctx.db.insert("authFiles", row);
178
+ return { updated: false };
179
+ },
180
+ });
181
+
182
+ export const upsertState = mutation({
183
+ args: {
184
+ ownerId: v.string(),
185
+ agentId: v.string(),
186
+ profileId: v.string(),
187
+ provider: v.string(),
188
+ lastUsed: v.optional(v.number()),
189
+ cooldownUntil: v.optional(v.number()),
190
+ cooldownReason: v.optional(v.string()),
191
+ cooldownModel: v.optional(v.string()),
192
+ disabledUntil: v.optional(v.number()),
193
+ disabledReason: v.optional(v.string()),
194
+ errorCount: v.optional(v.number()),
195
+ failureCounts: v.optional(v.any()),
196
+ lastFailureAt: v.optional(v.number()),
197
+ isLastGood: v.boolean(),
198
+ explicitOrder: v.optional(v.number()),
199
+ },
200
+ handler: async (ctx, args) => {
201
+ const existing = await ctx.db
202
+ .query("profileState")
203
+ .withIndex("by_owner_agent_profileId", (q) =>
204
+ q
205
+ .eq("ownerId", args.ownerId)
206
+ .eq("agentId", args.agentId)
207
+ .eq("profileId", args.profileId),
208
+ )
209
+ .first();
210
+ if (existing) {
211
+ await ctx.db.replace(existing._id, args);
212
+ return { profileId: args.profileId, updated: true };
213
+ }
214
+ await ctx.db.insert("profileState", args);
215
+ return { profileId: args.profileId, updated: false };
216
+ },
217
+ });