@spinabot/brigade 1.0.1 → 1.0.2
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.
- package/convex/_generated/api.d.ts +85 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/admin.d.ts +57 -0
- package/convex/admin.ts +315 -0
- package/convex/auth.d.ts +159 -0
- package/convex/auth.ts +217 -0
- package/convex/blobs.d.ts +38 -0
- package/convex/blobs.ts +115 -0
- package/convex/channels.d.ts +150 -0
- package/convex/channels.ts +455 -0
- package/convex/config.d.ts +67 -0
- package/convex/config.ts +168 -0
- package/convex/cron.d.ts +237 -0
- package/convex/cron.ts +199 -0
- package/convex/execApprovals.d.ts +31 -0
- package/convex/execApprovals.ts +58 -0
- package/convex/extensions.d.ts +30 -0
- package/convex/extensions.ts +51 -0
- package/convex/health.d.ts +18 -0
- package/convex/health.ts +69 -0
- package/convex/instance.d.ts +34 -0
- package/convex/instance.ts +82 -0
- package/convex/logs.d.ts +178 -0
- package/convex/logs.ts +253 -0
- package/convex/memory.d.ts +354 -0
- package/convex/memory.ts +536 -0
- package/convex/messages.d.ts +124 -0
- package/convex/messages.ts +347 -0
- package/convex/org.d.ts +75 -0
- package/convex/org.ts +99 -0
- package/convex/schema.d.ts +1130 -0
- package/convex/schema.ts +847 -0
- package/convex/sessions.d.ts +100 -0
- package/convex/sessions.ts +105 -0
- package/convex/skills.d.ts +73 -0
- package/convex/skills.ts +102 -0
- package/convex/subagents.d.ts +214 -0
- package/convex/subagents.ts +99 -0
- package/convex/tsconfig.json +23 -0
- package/convex/whatsappAuth.d.ts +52 -0
- package/convex/whatsappAuth.ts +151 -0
- package/convex/workspace.d.ts +49 -0
- package/convex/workspace.ts +106 -0
- package/dist/buildstamp.json +1 -1
- package/package.json +7 -1
- package/scripts/convex-dev.mjs +321 -0
- package/scripts/convex-push.mjs +69 -0
- package/scripts/install-convex.mjs +123 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// convex/channels.ts — channelAccess + whatsappAuthFile + channelMediaBlob
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { mutation, query } from "./_generated/server.js";
|
|
4
|
+
|
|
5
|
+
const AccessKind = v.union(
|
|
6
|
+
v.literal("allow-from"),
|
|
7
|
+
v.literal("group-allow-from"),
|
|
8
|
+
v.literal("pairing"),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export const listAccess = query({
|
|
12
|
+
args: {
|
|
13
|
+
ownerId: v.string(),
|
|
14
|
+
channelId: v.string(),
|
|
15
|
+
accountId: v.string(),
|
|
16
|
+
kind: AccessKind,
|
|
17
|
+
},
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
return ctx.db
|
|
20
|
+
.query("channelAccess")
|
|
21
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
22
|
+
q
|
|
23
|
+
.eq("ownerId", args.ownerId)
|
|
24
|
+
.eq("channelId", args.channelId)
|
|
25
|
+
.eq("accountId", args.accountId)
|
|
26
|
+
.eq("kind", args.kind),
|
|
27
|
+
)
|
|
28
|
+
.collect();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Every access row for the owner — single-operator scale keeps this tiny.
|
|
33
|
+
* Boot hydration uses it to fill the in-process access cache in one query
|
|
34
|
+
* instead of guessing the channel/account layout from config. */
|
|
35
|
+
export const listAllAccess = query({
|
|
36
|
+
args: { ownerId: v.string() },
|
|
37
|
+
handler: async (ctx, args) => {
|
|
38
|
+
return ctx.db
|
|
39
|
+
.query("channelAccess")
|
|
40
|
+
.withIndex("by_owner_channel_account_kind", (q) => q.eq("ownerId", args.ownerId))
|
|
41
|
+
.collect();
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Replace the row set for one (channel, account, kind) in a single
|
|
46
|
+
* transaction — the convex-mode realisation of the filesystem's
|
|
47
|
+
* whole-file atomic write. Caller-supplied codes/timestamps are
|
|
48
|
+
* authoritative so locally-generated pairing codes survive verbatim. */
|
|
49
|
+
export const reconcileAccess = mutation({
|
|
50
|
+
args: {
|
|
51
|
+
ownerId: v.string(),
|
|
52
|
+
channelId: v.string(),
|
|
53
|
+
accountId: v.string(),
|
|
54
|
+
kind: AccessKind,
|
|
55
|
+
rows: v.array(
|
|
56
|
+
v.object({
|
|
57
|
+
senderId: v.bytes(),
|
|
58
|
+
senderName: v.optional(v.string()),
|
|
59
|
+
code: v.optional(v.bytes()),
|
|
60
|
+
createdAt: v.number(),
|
|
61
|
+
lastSeenAt: v.number(),
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
handler: async (ctx, args) => {
|
|
66
|
+
// Wholesale replace: delete the existing set, insert the wanted set —
|
|
67
|
+
// one transaction either way. Sealed senderId bytes carry a random
|
|
68
|
+
// nonce per seal, so byte-equality between an incoming row and a
|
|
69
|
+
// stored row is meaningless; matching for in-place patches would be
|
|
70
|
+
// wrong, and at single-operator scale (a handful of rows per list)
|
|
71
|
+
// replacement churn is irrelevant.
|
|
72
|
+
const existing = await ctx.db
|
|
73
|
+
.query("channelAccess")
|
|
74
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
75
|
+
q
|
|
76
|
+
.eq("ownerId", args.ownerId)
|
|
77
|
+
.eq("channelId", args.channelId)
|
|
78
|
+
.eq("accountId", args.accountId)
|
|
79
|
+
.eq("kind", args.kind),
|
|
80
|
+
)
|
|
81
|
+
.collect();
|
|
82
|
+
for (const row of existing) await ctx.db.delete(row._id);
|
|
83
|
+
for (const wanted of args.rows) {
|
|
84
|
+
await ctx.db.insert("channelAccess", {
|
|
85
|
+
ownerId: args.ownerId,
|
|
86
|
+
channelId: args.channelId,
|
|
87
|
+
accountId: args.accountId,
|
|
88
|
+
kind: args.kind,
|
|
89
|
+
senderId: wanted.senderId,
|
|
90
|
+
...(wanted.senderName !== undefined ? { senderName: wanted.senderName } : {}),
|
|
91
|
+
...(wanted.code !== undefined ? { code: wanted.code } : {}),
|
|
92
|
+
createdAt: wanted.createdAt,
|
|
93
|
+
lastSeenAt: wanted.lastSeenAt,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { count: args.rows.length };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function bytesEqual(a: ArrayBuffer, b: ArrayBuffer): boolean {
|
|
101
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
102
|
+
const av = new Uint8Array(a);
|
|
103
|
+
const bv = new Uint8Array(b);
|
|
104
|
+
for (let i = 0; i < av.length; i++) {
|
|
105
|
+
if (av[i] !== bv[i]) return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const upsertAccess = mutation({
|
|
111
|
+
args: {
|
|
112
|
+
ownerId: v.string(),
|
|
113
|
+
channelId: v.string(),
|
|
114
|
+
accountId: v.string(),
|
|
115
|
+
kind: AccessKind,
|
|
116
|
+
senderId: v.bytes(),
|
|
117
|
+
senderName: v.optional(v.string()),
|
|
118
|
+
code: v.optional(v.bytes()),
|
|
119
|
+
},
|
|
120
|
+
handler: async (ctx, args) => {
|
|
121
|
+
const all = await ctx.db
|
|
122
|
+
.query("channelAccess")
|
|
123
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
124
|
+
q
|
|
125
|
+
.eq("ownerId", args.ownerId)
|
|
126
|
+
.eq("channelId", args.channelId)
|
|
127
|
+
.eq("accountId", args.accountId)
|
|
128
|
+
.eq("kind", args.kind),
|
|
129
|
+
)
|
|
130
|
+
.collect();
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
for (const row of all) {
|
|
133
|
+
if (bytesEqual(row.senderId as ArrayBuffer, args.senderId)) {
|
|
134
|
+
await ctx.db.patch(row._id, { lastSeenAt: now });
|
|
135
|
+
return { changed: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await ctx.db.insert("channelAccess", {
|
|
139
|
+
ownerId: args.ownerId,
|
|
140
|
+
channelId: args.channelId,
|
|
141
|
+
accountId: args.accountId,
|
|
142
|
+
kind: args.kind,
|
|
143
|
+
senderId: args.senderId,
|
|
144
|
+
...(args.senderName !== undefined ? { senderName: args.senderName } : {}),
|
|
145
|
+
...(args.code !== undefined ? { code: args.code } : {}),
|
|
146
|
+
createdAt: now,
|
|
147
|
+
lastSeenAt: now,
|
|
148
|
+
});
|
|
149
|
+
return { changed: true };
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export const removeAccess = mutation({
|
|
154
|
+
args: {
|
|
155
|
+
ownerId: v.string(),
|
|
156
|
+
channelId: v.string(),
|
|
157
|
+
accountId: v.string(),
|
|
158
|
+
kind: AccessKind,
|
|
159
|
+
senderId: v.bytes(),
|
|
160
|
+
},
|
|
161
|
+
handler: async (ctx, args) => {
|
|
162
|
+
const all = await ctx.db
|
|
163
|
+
.query("channelAccess")
|
|
164
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
165
|
+
q
|
|
166
|
+
.eq("ownerId", args.ownerId)
|
|
167
|
+
.eq("channelId", args.channelId)
|
|
168
|
+
.eq("accountId", args.accountId)
|
|
169
|
+
.eq("kind", args.kind),
|
|
170
|
+
)
|
|
171
|
+
.collect();
|
|
172
|
+
let removed = 0;
|
|
173
|
+
for (const row of all) {
|
|
174
|
+
if (bytesEqual(row.senderId as ArrayBuffer, args.senderId)) {
|
|
175
|
+
await ctx.db.delete(row._id);
|
|
176
|
+
removed += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return removed > 0;
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const eraseAccount = mutation({
|
|
184
|
+
args: { ownerId: v.string(), channelId: v.string(), accountId: v.string() },
|
|
185
|
+
handler: async (ctx, args) => {
|
|
186
|
+
const rows = await ctx.db
|
|
187
|
+
.query("channelAccess")
|
|
188
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
189
|
+
q
|
|
190
|
+
.eq("ownerId", args.ownerId)
|
|
191
|
+
.eq("channelId", args.channelId)
|
|
192
|
+
.eq("accountId", args.accountId),
|
|
193
|
+
)
|
|
194
|
+
.collect();
|
|
195
|
+
for (const r of rows) await ctx.db.delete(r._id);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
200
|
+
const PAIRING_CODE_LENGTH = 8;
|
|
201
|
+
const PAIRING_TTL_MS = 60 * 60 * 1000; // 1h
|
|
202
|
+
const PAIRING_MAX_PENDING = 3;
|
|
203
|
+
|
|
204
|
+
function generatePairingCode(): string {
|
|
205
|
+
let out = "";
|
|
206
|
+
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
|
207
|
+
out += PAIRING_CODE_ALPHABET[Math.floor(Math.random() * PAIRING_CODE_ALPHABET.length)];
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bytesEqualPairing(a: ArrayBuffer, b: ArrayBuffer): boolean {
|
|
213
|
+
if (a.byteLength !== b.byteLength) return false;
|
|
214
|
+
const av = new Uint8Array(a);
|
|
215
|
+
const bv = new Uint8Array(b);
|
|
216
|
+
for (let i = 0; i < av.length; i++) {
|
|
217
|
+
if (av[i] !== bv[i]) return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const upsertPairingRequest = mutation({
|
|
223
|
+
args: {
|
|
224
|
+
ownerId: v.string(),
|
|
225
|
+
channelId: v.string(),
|
|
226
|
+
accountId: v.string(),
|
|
227
|
+
senderId: v.bytes(),
|
|
228
|
+
senderName: v.optional(v.string()),
|
|
229
|
+
},
|
|
230
|
+
handler: async (ctx, args) => {
|
|
231
|
+
// Prune expired pairings first.
|
|
232
|
+
const all = await ctx.db
|
|
233
|
+
.query("channelAccess")
|
|
234
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
235
|
+
q
|
|
236
|
+
.eq("ownerId", args.ownerId)
|
|
237
|
+
.eq("channelId", args.channelId)
|
|
238
|
+
.eq("accountId", args.accountId)
|
|
239
|
+
.eq("kind", "pairing"),
|
|
240
|
+
)
|
|
241
|
+
.collect();
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
const fresh = [];
|
|
244
|
+
for (const r of all) {
|
|
245
|
+
if (r.createdAt && now - r.createdAt < PAIRING_TTL_MS) {
|
|
246
|
+
fresh.push(r);
|
|
247
|
+
} else {
|
|
248
|
+
await ctx.db.delete(r._id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Existing pairing for this sender? Refresh lastSeenAt and return code.
|
|
252
|
+
for (const r of fresh) {
|
|
253
|
+
if (bytesEqualPairing(r.senderId as ArrayBuffer, args.senderId)) {
|
|
254
|
+
await ctx.db.patch(r._id, { lastSeenAt: now });
|
|
255
|
+
const code = new TextDecoder().decode((r.code as ArrayBuffer) ?? new ArrayBuffer(0));
|
|
256
|
+
return { code, isNew: false };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Cap pending — drop oldest if over the limit.
|
|
260
|
+
if (fresh.length >= PAIRING_MAX_PENDING) {
|
|
261
|
+
const sorted = [...fresh].sort((a, b) => (a.lastSeenAt ?? 0) - (b.lastSeenAt ?? 0));
|
|
262
|
+
const drop = sorted.slice(0, fresh.length - PAIRING_MAX_PENDING + 1);
|
|
263
|
+
for (const r of drop) await ctx.db.delete(r._id);
|
|
264
|
+
}
|
|
265
|
+
const code = generatePairingCode();
|
|
266
|
+
const codeBytes = new TextEncoder().encode(code).buffer;
|
|
267
|
+
await ctx.db.insert("channelAccess", {
|
|
268
|
+
ownerId: args.ownerId,
|
|
269
|
+
channelId: args.channelId,
|
|
270
|
+
accountId: args.accountId,
|
|
271
|
+
kind: "pairing",
|
|
272
|
+
senderId: args.senderId,
|
|
273
|
+
...(args.senderName !== undefined ? { senderName: args.senderName } : {}),
|
|
274
|
+
code: codeBytes as ArrayBuffer,
|
|
275
|
+
createdAt: now,
|
|
276
|
+
lastSeenAt: now,
|
|
277
|
+
});
|
|
278
|
+
return { code, isNew: true };
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
export const approvePairing = mutation({
|
|
283
|
+
args: {
|
|
284
|
+
ownerId: v.string(),
|
|
285
|
+
channelId: v.string(),
|
|
286
|
+
accountId: v.string(),
|
|
287
|
+
code: v.string(),
|
|
288
|
+
},
|
|
289
|
+
handler: async (ctx, args) => {
|
|
290
|
+
const all = await ctx.db
|
|
291
|
+
.query("channelAccess")
|
|
292
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
293
|
+
q
|
|
294
|
+
.eq("ownerId", args.ownerId)
|
|
295
|
+
.eq("channelId", args.channelId)
|
|
296
|
+
.eq("accountId", args.accountId)
|
|
297
|
+
.eq("kind", "pairing"),
|
|
298
|
+
)
|
|
299
|
+
.collect();
|
|
300
|
+
const wanted = args.code.toUpperCase().replace(/\s|-/g, "");
|
|
301
|
+
for (const r of all) {
|
|
302
|
+
const code = new TextDecoder().decode((r.code as ArrayBuffer) ?? new ArrayBuffer(0));
|
|
303
|
+
if (code === wanted) {
|
|
304
|
+
// Move sender into the allow-from list, then drop the pairing.
|
|
305
|
+
await ctx.db.insert("channelAccess", {
|
|
306
|
+
ownerId: args.ownerId,
|
|
307
|
+
channelId: args.channelId,
|
|
308
|
+
accountId: args.accountId,
|
|
309
|
+
kind: "allow-from",
|
|
310
|
+
senderId: r.senderId,
|
|
311
|
+
...(r.senderName !== undefined ? { senderName: r.senderName } : {}),
|
|
312
|
+
createdAt: Date.now(),
|
|
313
|
+
lastSeenAt: Date.now(),
|
|
314
|
+
});
|
|
315
|
+
await ctx.db.delete(r._id);
|
|
316
|
+
return {
|
|
317
|
+
code,
|
|
318
|
+
senderId: new TextDecoder().decode(r.senderId as ArrayBuffer),
|
|
319
|
+
senderName: r.senderName ?? null,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
export const revokePairing = mutation({
|
|
328
|
+
args: {
|
|
329
|
+
ownerId: v.string(),
|
|
330
|
+
channelId: v.string(),
|
|
331
|
+
accountId: v.string(),
|
|
332
|
+
code: v.string(),
|
|
333
|
+
},
|
|
334
|
+
handler: async (ctx, args) => {
|
|
335
|
+
const all = await ctx.db
|
|
336
|
+
.query("channelAccess")
|
|
337
|
+
.withIndex("by_owner_channel_account_kind", (q) =>
|
|
338
|
+
q
|
|
339
|
+
.eq("ownerId", args.ownerId)
|
|
340
|
+
.eq("channelId", args.channelId)
|
|
341
|
+
.eq("accountId", args.accountId)
|
|
342
|
+
.eq("kind", "pairing"),
|
|
343
|
+
)
|
|
344
|
+
.collect();
|
|
345
|
+
const wanted = args.code.toUpperCase().replace(/\s|-/g, "");
|
|
346
|
+
for (const r of all) {
|
|
347
|
+
const code = new TextDecoder().decode((r.code as ArrayBuffer) ?? new ArrayBuffer(0));
|
|
348
|
+
if (code === wanted) {
|
|
349
|
+
await ctx.db.delete(r._id);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ============================================================================
|
|
358
|
+
// Media blobs (channelMediaBlob + Convex File Storage)
|
|
359
|
+
// ============================================================================
|
|
360
|
+
|
|
361
|
+
export const generateMediaUploadUrl = mutation({
|
|
362
|
+
args: {},
|
|
363
|
+
handler: async (ctx) => {
|
|
364
|
+
return await ctx.storage.generateUploadUrl();
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
export const recordMediaBlob = mutation({
|
|
369
|
+
args: {
|
|
370
|
+
ownerId: v.string(),
|
|
371
|
+
channelId: v.string(),
|
|
372
|
+
accountId: v.string(),
|
|
373
|
+
messageId: v.string(),
|
|
374
|
+
index: v.number(),
|
|
375
|
+
mimeType: v.string(),
|
|
376
|
+
fileName: v.optional(v.string()),
|
|
377
|
+
storageId: v.id("_storage"),
|
|
378
|
+
bytes: v.number(),
|
|
379
|
+
},
|
|
380
|
+
handler: async (ctx, args) => {
|
|
381
|
+
await ctx.db.insert("channelMediaBlob", { ...args, createdAt: Date.now() });
|
|
382
|
+
return { ok: true };
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
export const getMediaBlobUrl = query({
|
|
387
|
+
args: {
|
|
388
|
+
ownerId: v.string(),
|
|
389
|
+
channelId: v.string(),
|
|
390
|
+
accountId: v.string(),
|
|
391
|
+
messageId: v.string(),
|
|
392
|
+
index: v.number(),
|
|
393
|
+
},
|
|
394
|
+
handler: async (ctx, args) => {
|
|
395
|
+
const row = await ctx.db
|
|
396
|
+
.query("channelMediaBlob")
|
|
397
|
+
.withIndex("by_owner_channel_account_msg", (q) =>
|
|
398
|
+
q
|
|
399
|
+
.eq("ownerId", args.ownerId)
|
|
400
|
+
.eq("channelId", args.channelId)
|
|
401
|
+
.eq("accountId", args.accountId)
|
|
402
|
+
.eq("messageId", args.messageId),
|
|
403
|
+
)
|
|
404
|
+
.collect();
|
|
405
|
+
const match = row.find((r) => r.index === args.index);
|
|
406
|
+
if (!match) return null;
|
|
407
|
+
const url = await ctx.storage.getUrl(match.storageId);
|
|
408
|
+
return url ? { url, mimeType: match.mimeType, bytes: match.bytes } : null;
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
export const writeAuthFile = mutation({
|
|
413
|
+
args: {
|
|
414
|
+
ownerId: v.string(),
|
|
415
|
+
accountId: v.string(),
|
|
416
|
+
fileKey: v.string(),
|
|
417
|
+
contentB64: v.bytes(),
|
|
418
|
+
},
|
|
419
|
+
handler: async (ctx, args) => {
|
|
420
|
+
const existing = await ctx.db
|
|
421
|
+
.query("whatsappAuthFile")
|
|
422
|
+
.withIndex("by_owner_account_file", (q) =>
|
|
423
|
+
q
|
|
424
|
+
.eq("ownerId", args.ownerId)
|
|
425
|
+
.eq("accountId", args.accountId)
|
|
426
|
+
.eq("fileKey", args.fileKey),
|
|
427
|
+
)
|
|
428
|
+
.first();
|
|
429
|
+
const payload = {
|
|
430
|
+
ownerId: args.ownerId,
|
|
431
|
+
accountId: args.accountId,
|
|
432
|
+
fileKey: args.fileKey,
|
|
433
|
+
contentB64: args.contentB64,
|
|
434
|
+
contentVersion: (existing?.contentVersion ?? 0) + 1,
|
|
435
|
+
updatedAt: Date.now(),
|
|
436
|
+
};
|
|
437
|
+
if (existing) await ctx.db.replace(existing._id, payload);
|
|
438
|
+
else await ctx.db.insert("whatsappAuthFile", payload);
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
export const readAuthFile = query({
|
|
443
|
+
args: { ownerId: v.string(), accountId: v.string(), fileKey: v.string() },
|
|
444
|
+
handler: async (ctx, args) => {
|
|
445
|
+
return ctx.db
|
|
446
|
+
.query("whatsappAuthFile")
|
|
447
|
+
.withIndex("by_owner_account_file", (q) =>
|
|
448
|
+
q
|
|
449
|
+
.eq("ownerId", args.ownerId)
|
|
450
|
+
.eq("accountId", args.accountId)
|
|
451
|
+
.eq("fileKey", args.fileKey),
|
|
452
|
+
)
|
|
453
|
+
.first();
|
|
454
|
+
},
|
|
455
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export declare const read: import("convex/server").RegisteredQuery<"public", {
|
|
2
|
+
instanceId: string;
|
|
3
|
+
}, Promise<{
|
|
4
|
+
_id: import("convex/values").GenericId<"brigadeConfig">;
|
|
5
|
+
_creationTime: number;
|
|
6
|
+
auth?: any;
|
|
7
|
+
channels?: any;
|
|
8
|
+
session?: any;
|
|
9
|
+
defaults?: any;
|
|
10
|
+
agents?: any;
|
|
11
|
+
gateway?: any;
|
|
12
|
+
skills?: any;
|
|
13
|
+
org?: any;
|
|
14
|
+
tools?: any;
|
|
15
|
+
plugins?: any;
|
|
16
|
+
bindings?: any;
|
|
17
|
+
wizard?: any;
|
|
18
|
+
meta?: any;
|
|
19
|
+
extra?: any;
|
|
20
|
+
encryptedGatewayAuthToken?: ArrayBuffer | undefined;
|
|
21
|
+
encryptedGatewayAuthPassword?: ArrayBuffer | undefined;
|
|
22
|
+
updatedByPid?: number | undefined;
|
|
23
|
+
updatedAtMs: number;
|
|
24
|
+
bytes: number;
|
|
25
|
+
instanceId: string;
|
|
26
|
+
schemaVersion: 2;
|
|
27
|
+
contentSha256: string;
|
|
28
|
+
} | null>>;
|
|
29
|
+
export declare const write: import("convex/server").RegisteredMutation<"public", {
|
|
30
|
+
auth?: any;
|
|
31
|
+
channels?: any;
|
|
32
|
+
session?: any;
|
|
33
|
+
defaults?: any;
|
|
34
|
+
agents?: any;
|
|
35
|
+
gateway?: any;
|
|
36
|
+
skills?: any;
|
|
37
|
+
org?: any;
|
|
38
|
+
tools?: any;
|
|
39
|
+
plugins?: any;
|
|
40
|
+
bindings?: any;
|
|
41
|
+
wizard?: any;
|
|
42
|
+
meta?: any;
|
|
43
|
+
extra?: any;
|
|
44
|
+
expectedSha256?: string | undefined;
|
|
45
|
+
bytes: number;
|
|
46
|
+
instanceId: string;
|
|
47
|
+
contentSha256: string;
|
|
48
|
+
}, Promise<{
|
|
49
|
+
rev: string;
|
|
50
|
+
updated: boolean;
|
|
51
|
+
}>>;
|
|
52
|
+
export declare const listBackups: import("convex/server").RegisteredQuery<"public", {
|
|
53
|
+
instanceId: string;
|
|
54
|
+
}, Promise<{
|
|
55
|
+
slot: number;
|
|
56
|
+
sha256: string;
|
|
57
|
+
mtimeMs: number;
|
|
58
|
+
bytes: number;
|
|
59
|
+
}[]>>;
|
|
60
|
+
export declare const getBackup: import("convex/server").RegisteredQuery<"public", {
|
|
61
|
+
instanceId: string;
|
|
62
|
+
slot: number;
|
|
63
|
+
}, Promise<{
|
|
64
|
+
payload: string;
|
|
65
|
+
sha256: string;
|
|
66
|
+
} | null>>;
|
|
67
|
+
//# sourceMappingURL=config.d.ts.map
|
package/convex/config.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// convex/config.ts
|
|
2
|
+
//
|
|
3
|
+
// Convex functions for the brigadeConfig table — one row per operator
|
|
4
|
+
// (keyed by instanceId). Mirrors LocalConfigStore's surface so the
|
|
5
|
+
// adapter can call these and look identical to filesystem callers.
|
|
6
|
+
//
|
|
7
|
+
// `read` returns the single config row (or null on first boot).
|
|
8
|
+
// `write` is an upsert with optimistic-concurrency support via the
|
|
9
|
+
// `expectedSha256` arg — when supplied, the mutation refuses to write
|
|
10
|
+
// if the on-disk content hash has drifted.
|
|
11
|
+
|
|
12
|
+
import { v } from "convex/values";
|
|
13
|
+
import { mutation, query } from "./_generated/server.js";
|
|
14
|
+
|
|
15
|
+
export const read = query({
|
|
16
|
+
args: { instanceId: v.string() },
|
|
17
|
+
handler: async (ctx, args) => {
|
|
18
|
+
const row = await ctx.db
|
|
19
|
+
.query("brigadeConfig")
|
|
20
|
+
.withIndex("by_instance", (q) => q.eq("instanceId", args.instanceId))
|
|
21
|
+
.first();
|
|
22
|
+
return row;
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const write = mutation({
|
|
27
|
+
args: {
|
|
28
|
+
instanceId: v.string(),
|
|
29
|
+
agents: v.optional(v.any()),
|
|
30
|
+
gateway: v.optional(v.any()),
|
|
31
|
+
session: v.optional(v.any()),
|
|
32
|
+
tools: v.optional(v.any()),
|
|
33
|
+
auth: v.optional(v.any()),
|
|
34
|
+
plugins: v.optional(v.any()),
|
|
35
|
+
skills: v.optional(v.any()),
|
|
36
|
+
channels: v.optional(v.any()),
|
|
37
|
+
bindings: v.optional(v.any()),
|
|
38
|
+
org: v.optional(v.any()),
|
|
39
|
+
wizard: v.optional(v.any()),
|
|
40
|
+
meta: v.optional(v.any()),
|
|
41
|
+
defaults: v.optional(v.any()),
|
|
42
|
+
extra: v.optional(v.any()),
|
|
43
|
+
contentSha256: v.string(),
|
|
44
|
+
bytes: v.number(),
|
|
45
|
+
expectedSha256: v.optional(v.string()),
|
|
46
|
+
},
|
|
47
|
+
handler: async (ctx, args) => {
|
|
48
|
+
const existing = await ctx.db
|
|
49
|
+
.query("brigadeConfig")
|
|
50
|
+
.withIndex("by_instance", (q) => q.eq("instanceId", args.instanceId))
|
|
51
|
+
.first();
|
|
52
|
+
if (
|
|
53
|
+
args.expectedSha256 !== undefined &&
|
|
54
|
+
existing &&
|
|
55
|
+
existing.contentSha256 !== args.expectedSha256
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`OCC conflict: expected sha256=${args.expectedSha256} but on-disk is ${existing.contentSha256}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const payload = {
|
|
62
|
+
instanceId: args.instanceId,
|
|
63
|
+
schemaVersion: 2 as const,
|
|
64
|
+
agents: args.agents,
|
|
65
|
+
gateway: args.gateway,
|
|
66
|
+
session: args.session,
|
|
67
|
+
tools: args.tools,
|
|
68
|
+
auth: args.auth,
|
|
69
|
+
plugins: args.plugins,
|
|
70
|
+
skills: args.skills,
|
|
71
|
+
channels: args.channels,
|
|
72
|
+
bindings: args.bindings,
|
|
73
|
+
org: args.org,
|
|
74
|
+
wizard: args.wizard,
|
|
75
|
+
meta: args.meta,
|
|
76
|
+
defaults: args.defaults,
|
|
77
|
+
extra: args.extra,
|
|
78
|
+
contentSha256: args.contentSha256,
|
|
79
|
+
bytes: args.bytes,
|
|
80
|
+
updatedAtMs: Date.now(),
|
|
81
|
+
};
|
|
82
|
+
if (existing) {
|
|
83
|
+
// Snapshot the PRIOR config into the backup ring before overwriting —
|
|
84
|
+
// the convex equivalent of io.ts rotateBackups' .bak chain. Only on a
|
|
85
|
+
// real content change (skip no-op rewrites so the ring isn't flooded
|
|
86
|
+
// with identical snapshots). Ring of BACKUP_COUNT, slot 0 = newest.
|
|
87
|
+
if (existing.contentSha256 !== args.contentSha256) {
|
|
88
|
+
await captureBackup(ctx, args.instanceId, existing);
|
|
89
|
+
}
|
|
90
|
+
await ctx.db.replace(existing._id, payload);
|
|
91
|
+
return { rev: args.contentSha256, updated: true };
|
|
92
|
+
}
|
|
93
|
+
await ctx.db.insert("brigadeConfig", payload);
|
|
94
|
+
return { rev: args.contentSha256, updated: false };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Keep the same depth as the filesystem .bak rotation (io.ts BACKUP_COUNT).
|
|
99
|
+
const BACKUP_COUNT = 5;
|
|
100
|
+
|
|
101
|
+
/** Rebuild the brigade.json shape from a stored brigadeConfig row (inverse of
|
|
102
|
+
* the `write` payload): named domain columns that are set + the `extra`
|
|
103
|
+
* catch-all (which also carries the legacy top-level `version`). */
|
|
104
|
+
function reconstructConfig(row: Record<string, unknown>): Record<string, unknown> {
|
|
105
|
+
const out: Record<string, unknown> = {};
|
|
106
|
+
for (const k of [
|
|
107
|
+
"agents", "gateway", "session", "tools", "auth", "plugins", "skills",
|
|
108
|
+
"channels", "bindings", "org", "wizard", "meta", "defaults",
|
|
109
|
+
]) {
|
|
110
|
+
if (row[k] !== undefined) out[k] = row[k];
|
|
111
|
+
}
|
|
112
|
+
const extra = row.extra as Record<string, unknown> | undefined;
|
|
113
|
+
if (extra && typeof extra === "object") {
|
|
114
|
+
for (const [k, v2] of Object.entries(extra)) if (out[k] === undefined) out[k] = v2;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Insert a backup at slot 0, shifting existing slots up and dropping anything
|
|
120
|
+
* beyond BACKUP_COUNT-1 — a ring identical in depth to the disk .bak chain. */
|
|
121
|
+
async function captureBackup(
|
|
122
|
+
ctx: { db: any },
|
|
123
|
+
instanceId: string,
|
|
124
|
+
priorRow: Record<string, unknown>,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const existing = await ctx.db
|
|
127
|
+
.query("brigadeConfigBackups")
|
|
128
|
+
.withIndex("by_instance_slot", (q: any) => q.eq("instanceId", instanceId))
|
|
129
|
+
.collect();
|
|
130
|
+
for (const b of existing) {
|
|
131
|
+
if (b.slot >= BACKUP_COUNT - 1) await ctx.db.delete(b._id);
|
|
132
|
+
else await ctx.db.patch(b._id, { slot: b.slot + 1 });
|
|
133
|
+
}
|
|
134
|
+
await ctx.db.insert("brigadeConfigBackups", {
|
|
135
|
+
instanceId,
|
|
136
|
+
slot: 0,
|
|
137
|
+
contentSha256: (priorRow.contentSha256 as string) ?? "",
|
|
138
|
+
payload: JSON.stringify(reconstructConfig(priorRow)),
|
|
139
|
+
bytes: (priorRow.bytes as number) ?? 0,
|
|
140
|
+
capturedAtMs: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const listBackups = query({
|
|
145
|
+
args: { instanceId: v.string() },
|
|
146
|
+
handler: async (ctx, args) => {
|
|
147
|
+
const rows = await ctx.db
|
|
148
|
+
.query("brigadeConfigBackups")
|
|
149
|
+
.withIndex("by_instance_slot", (q) => q.eq("instanceId", args.instanceId))
|
|
150
|
+
.collect();
|
|
151
|
+
return rows
|
|
152
|
+
.sort((a, b) => a.slot - b.slot)
|
|
153
|
+
.map((r) => ({ slot: r.slot, sha256: r.contentSha256, mtimeMs: r.capturedAtMs, bytes: r.bytes }));
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const getBackup = query({
|
|
158
|
+
args: { instanceId: v.string(), slot: v.number() },
|
|
159
|
+
handler: async (ctx, args) => {
|
|
160
|
+
const row = await ctx.db
|
|
161
|
+
.query("brigadeConfigBackups")
|
|
162
|
+
.withIndex("by_instance_slot", (q) =>
|
|
163
|
+
q.eq("instanceId", args.instanceId).eq("slot", args.slot),
|
|
164
|
+
)
|
|
165
|
+
.first();
|
|
166
|
+
return row ? { payload: row.payload, sha256: row.contentSha256 } : null;
|
|
167
|
+
},
|
|
168
|
+
});
|