@cat-factory/node-server 0.95.2 → 0.97.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 (69) hide show
  1. package/dist/container.d.ts.map +1 -1
  2. package/dist/container.js +26 -5
  3. package/dist/container.js.map +1 -1
  4. package/dist/db/schema.d.ts.map +1 -1
  5. package/dist/db/schema.js +3 -0
  6. package/dist/db/schema.js.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/machineEventRelay.d.ts +21 -0
  12. package/dist/machineEventRelay.d.ts.map +1 -0
  13. package/dist/machineEventRelay.js +34 -0
  14. package/dist/machineEventRelay.js.map +1 -0
  15. package/dist/propagator.d.ts +1 -1
  16. package/dist/propagator.d.ts.map +1 -1
  17. package/dist/propagator.js +6 -1
  18. package/dist/propagator.js.map +1 -1
  19. package/dist/repositories/drizzle/_shared.d.ts +2 -0
  20. package/dist/repositories/drizzle/_shared.d.ts.map +1 -0
  21. package/dist/repositories/drizzle/_shared.js +11 -0
  22. package/dist/repositories/drizzle/_shared.js.map +1 -0
  23. package/dist/repositories/drizzle/accounts.d.ts +62 -0
  24. package/dist/repositories/drizzle/accounts.d.ts.map +1 -0
  25. package/dist/repositories/drizzle/accounts.js +438 -0
  26. package/dist/repositories/drizzle/accounts.js.map +1 -0
  27. package/dist/repositories/drizzle/board.d.ts +74 -0
  28. package/dist/repositories/drizzle/board.d.ts.map +1 -0
  29. package/dist/repositories/drizzle/board.js +489 -0
  30. package/dist/repositories/drizzle/board.js.map +1 -0
  31. package/dist/repositories/drizzle/connections.d.ts +84 -0
  32. package/dist/repositories/drizzle/connections.d.ts.map +1 -0
  33. package/dist/repositories/drizzle/connections.js +391 -0
  34. package/dist/repositories/drizzle/connections.js.map +1 -0
  35. package/dist/repositories/drizzle/execution.d.ts +58 -0
  36. package/dist/repositories/drizzle/execution.d.ts.map +1 -0
  37. package/dist/repositories/drizzle/execution.js +585 -0
  38. package/dist/repositories/drizzle/execution.js.map +1 -0
  39. package/dist/repositories/drizzle/initiatives.d.ts +54 -0
  40. package/dist/repositories/drizzle/initiatives.d.ts.map +1 -0
  41. package/dist/repositories/drizzle/initiatives.js +343 -0
  42. package/dist/repositories/drizzle/initiatives.js.map +1 -0
  43. package/dist/repositories/drizzle/kaizen.d.ts +34 -0
  44. package/dist/repositories/drizzle/kaizen.d.ts.map +1 -0
  45. package/dist/repositories/drizzle/kaizen.js +192 -0
  46. package/dist/repositories/drizzle/kaizen.js.map +1 -0
  47. package/dist/repositories/drizzle/reviews.d.ts +75 -0
  48. package/dist/repositories/drizzle/reviews.d.ts.map +1 -0
  49. package/dist/repositories/drizzle/reviews.js +449 -0
  50. package/dist/repositories/drizzle/reviews.js.map +1 -0
  51. package/dist/repositories/drizzle/sandbox.d.ts +61 -0
  52. package/dist/repositories/drizzle/sandbox.d.ts.map +1 -0
  53. package/dist/repositories/drizzle/sandbox.js +370 -0
  54. package/dist/repositories/drizzle/sandbox.js.map +1 -0
  55. package/dist/repositories/drizzle/settings.d.ts +76 -0
  56. package/dist/repositories/drizzle/settings.d.ts.map +1 -0
  57. package/dist/repositories/drizzle/settings.js +377 -0
  58. package/dist/repositories/drizzle/settings.js.map +1 -0
  59. package/dist/repositories/drizzle/telemetry.d.ts +60 -0
  60. package/dist/repositories/drizzle/telemetry.d.ts.map +1 -0
  61. package/dist/repositories/drizzle/telemetry.js +559 -0
  62. package/dist/repositories/drizzle/telemetry.js.map +1 -0
  63. package/dist/repositories/drizzle.d.ts +6 -113
  64. package/dist/repositories/drizzle.d.ts.map +1 -1
  65. package/dist/repositories/drizzle.js +20 -4118
  66. package/dist/repositories/drizzle.js.map +1 -1
  67. package/drizzle/20260716082429_big_polaris/migration.sql +1 -0
  68. package/drizzle/20260716082429_big_polaris/snapshot.json +15675 -0
  69. package/package.json +18 -18
@@ -1,4121 +1,17 @@
1
- import { LLM_WARNING_FINISH_REASONS, WORKSPACE_SCOPED_TABLES } from '@cat-factory/kernel';
2
- import { agentRunKindSchema, decodeInitiativeRow, isWebSearchProvider, subscriptionVendorSchema, } from '@cat-factory/contracts';
3
- import { decodeEnum, tryDecodeRows, blockInsertValues, blockPatchToColumns, parseIssueIntakeColumn, serializeIssueIntakeColumn, rowToBlock, rowToExecution, executionToDetail, rowToPipeline, rowToSandboxExperiment, rowToSandboxFixture, rowToSandboxGrade, rowToSandboxPromptVersion, rowToSandboxRun, rowToWorkspace, } from '@cat-factory/server';
4
- import { and, asc, count, desc, eq, gte, inArray, isNull, lt, ne, notInArray, or, sql, } from 'drizzle-orm';
5
- import { accountInvitations, passwordResetTokens, accountSettings, localSettings, accounts, agentContextSnapshots, agentSearchQueries, agentRuns, blocks, consensusSessions, incidentEnrichmentConnections, observabilityConnections, packageRegistryConnections, emailConnections, llmCallMetrics, provisioningLog, sharedStacks, memberships, riskPolicies, releaseHealthConfigs, subscriptionQuotaCycles, testSecrets, pipelineScheduleRuns, pipelineSchedules, pipelines, requirementReviews, docInterviewSessions, kaizenGradings, kaizenVerifiedCombos, clarityReviews, binaryArtifacts, brainstormSessions, initiatives, sandboxPromptVersions, sandboxFixtures, sandboxExperiments, sandboxRuns, sandboxGrades, services, tokenUsage, trackerSettings, modelPresets, userIdentities, users, userSettings, workspaceFragmentDefaults, workspaceServices, workspaceSettings, workspaces, } from '../db/schema.js';
6
- // Drizzle/Postgres implementations of the core kernel repository ports. The
7
- // row<->domain mapping is the SAME shared mapping the Cloudflare D1 repos use
8
- // (@cat-factory/server), so behaviour matches across stores; this layer only owns
9
- // the Drizzle queries. This is the single persistence used in dev, test and prod.
10
- class DrizzleWorkspaceRepository {
11
- db;
12
- constructor(db) {
13
- this.db = db;
14
- }
15
- async listVisible(scope) {
16
- if (scope === null) {
17
- const rows = await this.db.select().from(workspaces).orderBy(desc(workspaces.created_at));
18
- return rows.map(rowToWorkspace);
19
- }
20
- const legacy = and(isNull(workspaces.account_id), eq(workspaces.owner_user_id, scope.ownerUserId));
21
- const where = scope.accountIds.length > 0
22
- ? or(inArray(workspaces.account_id, scope.accountIds), legacy)
23
- : legacy;
24
- const rows = await this.db
25
- .select()
26
- .from(workspaces)
27
- .where(where)
28
- .orderBy(desc(workspaces.created_at));
29
- return rows.map(rowToWorkspace);
30
- }
31
- async get(id) {
32
- const [row] = await this.db.select().from(workspaces).where(eq(workspaces.id, id));
33
- return row ? rowToWorkspace(row) : null;
34
- }
35
- async ownerOf(id) {
36
- const [row] = await this.db
37
- .select({ owner: workspaces.owner_user_id })
38
- .from(workspaces)
39
- .where(eq(workspaces.id, id));
40
- return row ? row.owner : undefined;
41
- }
42
- async accountOf(id) {
43
- const [row] = await this.db
44
- .select({ account: workspaces.account_id })
45
- .from(workspaces)
46
- .where(eq(workspaces.id, id));
47
- return row ? row.account : undefined;
48
- }
49
- async create(workspace, ownerUserId, accountId) {
50
- await this.db.insert(workspaces).values({
51
- id: workspace.id,
52
- name: workspace.name,
53
- description: workspace.description,
54
- created_at: workspace.createdAt,
55
- owner_user_id: ownerUserId,
56
- account_id: accountId,
57
- });
58
- }
59
- async rename(id, name) {
60
- await this.db.update(workspaces).set({ name }).where(eq(workspaces.id, id));
61
- }
62
- async setDescription(id, description) {
63
- await this.db.update(workspaces).set({ description }).where(eq(workspaces.id, id));
64
- }
65
- async delete(id, rehome = []) {
66
- await this.db.transaction(async (tx) => {
67
- // Re-home shared services FIRST: move each service's blocks + run history to a surviving
68
- // mounting board by re-stamping their `workspace_id`. Blocks are keyed by `service_id`, so
69
- // after the move the service's frame no longer lives in THIS workspace — the reclaim below
70
- // then skips it, leaving the service, its subtree and every OTHER board's mount intact. A
71
- // shared service therefore outlives its home board's deletion. Mirror any change in the
72
- // Cloudflare facade's D1WorkspaceRepository.delete.
73
- for (const { serviceId, toWorkspaceId } of rehome) {
74
- await tx
75
- .update(agentRuns)
76
- .set({ workspace_id: toWorkspaceId })
77
- .where(inArray(agentRuns.block_id, tx.select({ id: blocks.id }).from(blocks).where(eq(blocks.service_id, serviceId))));
78
- await tx
79
- .update(blocks)
80
- .set({ workspace_id: toWorkspaceId })
81
- .where(eq(blocks.service_id, serviceId));
82
- }
83
- // Reclaim the account-owned services this workspace HOMES (+ every board's mount of them)
84
- // BEFORE the blocks they reference are dropped. A deleted board that leaves its services
85
- // behind is not a cosmetic leak: `services` is account-scoped and looked up by
86
- // (installation_id, repo_github_id), so a dangling service (its frame block gone) keeps the
87
- // SAME repo from being re-added on any other board in the account. Mirror any change in the
88
- // Cloudflare facade's D1WorkspaceRepository.delete.
89
- const homed = await tx
90
- .select({ id: services.id })
91
- .from(services)
92
- .innerJoin(blocks, eq(services.frame_block_id, blocks.id))
93
- .where(eq(blocks.workspace_id, id));
94
- const serviceIds = homed.map((r) => r.id);
95
- if (serviceIds.length) {
96
- await tx.delete(workspaceServices).where(inArray(workspaceServices.service_id, serviceIds));
97
- await tx.delete(services).where(inArray(services.id, serviceIds));
98
- }
99
- // This workspace's OWN mounts of services homed elsewhere (shared services it mounted).
100
- await tx.delete(workspaceServices).where(eq(workspaceServices.workspace_id, id));
101
- // Bulk reclaim of every plain workspace-scoped table (incl. blocks/agent_runs/pipelines/
102
- // environments) from the shared kernel list — keeps this cascade in lockstep with the
103
- // Cloudflare facade and stops a new workspace-scoped table silently orphaning. The schema
104
- // declares no FKs between these tables, so order is free; they only need to run AFTER the
105
- // `services` reclaim above (which reads `blocks`) and BEFORE the root `workspaces` row.
106
- for (const table of WORKSPACE_SCOPED_TABLES) {
107
- await tx.execute(sql `DELETE FROM ${sql.identifier(table)} WHERE workspace_id = ${id}`);
108
- }
109
- await tx.delete(workspaces).where(eq(workspaces.id, id));
110
- });
111
- }
112
- }
113
- class DrizzleBlockRepository {
114
- db;
115
- constructor(db) {
116
- this.db = db;
117
- }
118
- // List reads order by `seq` (insertion order) for parity with the Cloudflare facade's
119
- // `ORDER BY rowid` — Postgres heap order is otherwise non-deterministic.
120
- async listByWorkspace(workspaceId) {
121
- const rows = await this.db
122
- .select()
123
- .from(blocks)
124
- .where(eq(blocks.workspace_id, workspaceId))
125
- .orderBy(blocks.seq);
126
- // Snapshot-facing list read: drop a corrupt block rather than failing the whole board load.
127
- return tryDecodeRows(rows, rowToBlock, (r) => ({ table: 'blocks', id: r.id }));
128
- }
129
- async listByService(serviceId) {
130
- const rows = await this.db
131
- .select()
132
- .from(blocks)
133
- .where(eq(blocks.service_id, serviceId))
134
- .orderBy(blocks.seq);
135
- return tryDecodeRows(rows, rowToBlock, (r) => ({ table: 'blocks', id: r.id }));
136
- }
137
- async listByServices(serviceIds) {
138
- if (serviceIds.length === 0)
139
- return [];
140
- const out = [];
141
- // Chunk the IN list to stay well under the bind-parameter limit. Ordering is
142
- // per-chunk, matching the D1 twin's per-chunk `ORDER BY rowid`.
143
- for (let i = 0; i < serviceIds.length; i += 500) {
144
- const rows = await this.db
145
- .select()
146
- .from(blocks)
147
- .where(inArray(blocks.service_id, serviceIds.slice(i, i + 500)))
148
- .orderBy(blocks.seq);
149
- out.push(...tryDecodeRows(rows, rowToBlock, (r) => ({ table: 'blocks', id: r.id })));
150
- }
151
- return out;
152
- }
153
- async get(workspaceId, id) {
154
- const [row] = await this.db
155
- .select()
156
- .from(blocks)
157
- .where(and(eq(blocks.workspace_id, workspaceId), eq(blocks.id, id)));
158
- return row ? rowToBlock(row) : null;
159
- }
160
- async findById(blockId) {
161
- const [row] = await this.db.select().from(blocks).where(eq(blocks.id, blockId)).limit(1);
162
- if (!row)
163
- return null;
164
- return {
165
- workspaceId: row.workspace_id,
166
- serviceId: row.service_id ?? null,
167
- block: rowToBlock(row),
168
- };
169
- }
170
- async findByIds(blockIds) {
171
- if (blockIds.length === 0)
172
- return [];
173
- const out = [];
174
- // Chunk the IN list to stay well under the bind-parameter limit.
175
- for (let i = 0; i < blockIds.length; i += 500) {
176
- const rows = await this.db
177
- .select()
178
- .from(blocks)
179
- .where(inArray(blocks.id, blockIds.slice(i, i + 500)));
180
- out.push(...rows.map((row) => ({
181
- workspaceId: row.workspace_id,
182
- serviceId: row.service_id ?? null,
183
- block: rowToBlock(row),
184
- })));
185
- }
186
- return out;
187
- }
188
- async insert(workspaceId, block, serviceId) {
189
- await this.db.insert(blocks).values({
190
- workspace_id: workspaceId,
191
- service_id: serviceId ?? null,
192
- ...blockInsertValues(block),
193
- });
194
- }
195
- async update(workspaceId, id, patch) {
196
- const set = blockPatchToColumns(patch);
197
- if (Object.keys(set).length === 0)
198
- return;
199
- await this.db
200
- .update(blocks)
201
- .set(set)
202
- .where(and(eq(blocks.workspace_id, workspaceId), eq(blocks.id, id)));
203
- }
204
- async setService(workspaceId, ids, serviceId) {
205
- if (ids.length === 0)
206
- return;
207
- await this.db
208
- .update(blocks)
209
- .set({ service_id: serviceId })
210
- .where(and(eq(blocks.workspace_id, workspaceId), inArray(blocks.id, ids)));
211
- }
212
- async deleteMany(workspaceId, ids) {
213
- if (ids.length === 0)
214
- return;
215
- await this.db
216
- .delete(blocks)
217
- .where(and(eq(blocks.workspace_id, workspaceId), inArray(blocks.id, ids)));
218
- }
219
- async countActiveInternal(workspaceId) {
220
- const [row] = await this.db
221
- .select({ n: count() })
222
- .from(blocks)
223
- .where(and(eq(blocks.workspace_id, workspaceId), eq(blocks.internal, 1), eq(blocks.status, 'in_progress')));
224
- return row?.n ?? 0;
225
- }
226
- }
227
- class DrizzlePipelineRepository {
228
- db;
229
- constructor(db) {
230
- this.db = db;
231
- }
232
- async listByWorkspace(workspaceId) {
233
- const rows = await this.db
234
- .select()
235
- .from(pipelines)
236
- .where(eq(pipelines.workspace_id, workspaceId))
237
- // Order by the monotonic insert `seq` so the catalog comes back in the curated
238
- // `seedPipelines()` order it was inserted in (Postgres gives no row order without
239
- // ORDER BY) — deterministic snapshots, a stable default `pipelines[0]`, and parity
240
- // with the Cloudflare facade's `ORDER BY rowid`.
241
- .orderBy(pipelines.seq);
242
- return rows.map(rowToPipeline);
243
- }
244
- async get(workspaceId, id) {
245
- const [row] = await this.db
246
- .select()
247
- .from(pipelines)
248
- .where(and(eq(pipelines.workspace_id, workspaceId), eq(pipelines.id, id)));
249
- return row ? rowToPipeline(row) : null;
250
- }
251
- async insert(workspaceId, pipeline) {
252
- await this.db.insert(pipelines).values({
253
- workspace_id: workspaceId,
254
- id: pipeline.id,
255
- name: pipeline.name,
256
- agent_kinds: JSON.stringify(pipeline.agentKinds),
257
- gates: pipeline.gates ? JSON.stringify(pipeline.gates) : null,
258
- thresholds: pipeline.thresholds ? JSON.stringify(pipeline.thresholds) : null,
259
- enabled: pipeline.enabled ? JSON.stringify(pipeline.enabled) : null,
260
- consensus: pipeline.consensus ? JSON.stringify(pipeline.consensus) : null,
261
- gating: pipeline.gating ? JSON.stringify(pipeline.gating) : null,
262
- follow_ups: pipeline.followUps ? JSON.stringify(pipeline.followUps) : null,
263
- tester_quality: pipeline.testerQuality ? JSON.stringify(pipeline.testerQuality) : null,
264
- step_options: pipeline.stepOptions ? JSON.stringify(pipeline.stepOptions) : null,
265
- labels: pipeline.labels ? JSON.stringify(pipeline.labels) : null,
266
- archived: pipeline.archived ? 1 : null,
267
- builtin: pipeline.builtin ? 1 : null,
268
- version: pipeline.version ?? null,
269
- public: pipeline.public ? 1 : null,
270
- availability: pipeline.availability ?? null,
271
- });
272
- }
273
- async update(workspaceId, pipeline) {
274
- // UPDATE in place preserves the row's `seq`, so an edited pipeline keeps its place
275
- // in the catalog order. `builtin` is immutable, so it is not rewritten. `version` IS
276
- // rewritten so a reseed bumps the stored copy to the current catalog version.
277
- await this.db
278
- .update(pipelines)
279
- .set({
280
- name: pipeline.name,
281
- agent_kinds: JSON.stringify(pipeline.agentKinds),
282
- gates: pipeline.gates ? JSON.stringify(pipeline.gates) : null,
283
- thresholds: pipeline.thresholds ? JSON.stringify(pipeline.thresholds) : null,
284
- enabled: pipeline.enabled ? JSON.stringify(pipeline.enabled) : null,
285
- consensus: pipeline.consensus ? JSON.stringify(pipeline.consensus) : null,
286
- gating: pipeline.gating ? JSON.stringify(pipeline.gating) : null,
287
- follow_ups: pipeline.followUps ? JSON.stringify(pipeline.followUps) : null,
288
- tester_quality: pipeline.testerQuality ? JSON.stringify(pipeline.testerQuality) : null,
289
- step_options: pipeline.stepOptions ? JSON.stringify(pipeline.stepOptions) : null,
290
- labels: pipeline.labels ? JSON.stringify(pipeline.labels) : null,
291
- archived: pipeline.archived ? 1 : null,
292
- version: pipeline.version ?? null,
293
- public: pipeline.public ? 1 : null,
294
- availability: pipeline.availability ?? null,
295
- })
296
- .where(and(eq(pipelines.workspace_id, workspaceId), eq(pipelines.id, pipeline.id)));
297
- }
298
- async delete(workspaceId, id) {
299
- await this.db
300
- .delete(pipelines)
301
- .where(and(eq(pipelines.workspace_id, workspaceId), eq(pipelines.id, id)));
302
- }
303
- }
304
- /** Execution runs live as `kind='execution'` rows of the unified agent_runs table. */
305
- class DrizzleExecutionRepository {
306
- db;
307
- clock;
308
- constructor(db, clock) {
309
- this.db = db;
310
- this.clock = clock;
311
- }
312
- isExecution = eq(agentRuns.kind, 'execution');
313
- async listByWorkspace(workspaceId) {
314
- const rows = await this.db
315
- .select()
316
- .from(agentRuns)
317
- .where(and(eq(agentRuns.workspace_id, workspaceId), this.isExecution))
318
- .orderBy(agentRuns.created_at);
319
- // Snapshot-facing list read: drop a corrupt run rather than failing the whole board load.
320
- return tryDecodeRows(rows, (r) => rowToExecution(r), (r) => ({ table: 'agent_runs', id: r.id }));
321
- }
322
- async listLive(workspaceId) {
323
- // Lean live-run projection: block_id + status + id only, NEVER the heavy `detail` column.
324
- // Served by idx_agent_runs_ws_kind_status (workspace_id, kind, status). Mirrors the D1 repo.
325
- // Unordered: both consumers (dispatch guard's block-id Set, resumePaused's id loop) are
326
- // order-agnostic.
327
- const rows = await this.db
328
- .select({ id: agentRuns.id, blockId: agentRuns.block_id, status: agentRuns.status })
329
- .from(agentRuns)
330
- .where(and(eq(agentRuns.workspace_id, workspaceId), this.isExecution, inArray(agentRuns.status, ['running', 'blocked', 'paused'])));
331
- return rows.map((r) => ({
332
- id: r.id,
333
- blockId: r.blockId ?? '',
334
- status: r.status,
335
- }));
336
- }
337
- async listByService(serviceId) {
338
- const rows = await this.db
339
- .select()
340
- .from(agentRuns)
341
- .where(and(eq(agentRuns.service_id, serviceId), this.isExecution))
342
- .orderBy(agentRuns.created_at);
343
- return tryDecodeRows(rows, (r) => rowToExecution(r), (r) => ({ table: 'agent_runs', id: r.id }));
344
- }
345
- async listByServices(serviceIds) {
346
- if (serviceIds.length === 0)
347
- return [];
348
- const out = [];
349
- // Chunk the IN list to stay well under the bind-parameter limit.
350
- for (let i = 0; i < serviceIds.length; i += 500) {
351
- const rows = await this.db
352
- .select()
353
- .from(agentRuns)
354
- .where(and(inArray(agentRuns.service_id, serviceIds.slice(i, i + 500)), this.isExecution))
355
- .orderBy(agentRuns.created_at);
356
- out.push(...tryDecodeRows(rows, (r) => rowToExecution(r), (r) => ({ table: 'agent_runs', id: r.id })));
357
- }
358
- return out;
359
- }
360
- async get(workspaceId, id) {
361
- const [row] = await this.db
362
- .select()
363
- .from(agentRuns)
364
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, id), this.isExecution));
365
- return row ? rowToExecution(row) : null;
366
- }
367
- async getByBlock(workspaceId, blockId) {
368
- const [row] = await this.db
369
- .select()
370
- .from(agentRuns)
371
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.block_id, blockId), this.isExecution));
372
- return row ? rowToExecution(row) : null;
373
- }
374
- async upsert(workspaceId, execution) {
375
- const now = this.clock.now();
376
- const detail = executionToDetail(execution);
377
- // Stamp `service_id` from the run's block (subquery) so a shared service's runs surface on
378
- // every board that mounts it via `listByService`; refreshed on every write so it follows a
379
- // reparent that re-homes the block. Mirrors the D1 repo.
380
- const serviceIdSub = sql `(SELECT ${blocks.service_id} FROM ${blocks} WHERE ${blocks.workspace_id} = ${workspaceId} AND ${blocks.id} = ${execution.blockId})`;
381
- // `rev` is bumped on every write (and read back onto the instance) so a concurrent
382
- // compareAndSwap can detect the row moved. A fresh insert starts at 0.
383
- const rows = await this.db
384
- .insert(agentRuns)
385
- .values({
386
- workspace_id: workspaceId,
387
- id: execution.id,
388
- kind: 'execution',
389
- block_id: execution.blockId,
390
- status: execution.status,
391
- detail,
392
- created_at: now,
393
- updated_at: now,
394
- workflow_instance_id: execution.id,
395
- service_id: serviceIdSub,
396
- rev: 0,
397
- })
398
- // error/failure/workflow_instance_id are left out of the update so they survive
399
- // normal step writes (see markFailed) — mirrors the D1 repo.
400
- .onConflictDoUpdate({
401
- target: [agentRuns.workspace_id, agentRuns.id],
402
- set: {
403
- block_id: execution.blockId,
404
- status: execution.status,
405
- detail,
406
- updated_at: now,
407
- service_id: serviceIdSub,
408
- rev: sql `${agentRuns.rev} + 1`,
409
- },
410
- })
411
- .returning({ rev: agentRuns.rev });
412
- if (rows[0])
413
- execution.rev = rows[0].rev;
414
- }
415
- async insertLive(workspaceId, execution, opts) {
416
- // One live run per block, enforced atomically by the partial unique index
417
- // `uniq_live_execution_per_block` on (workspace_id, block_id) over live execution rows. The
418
- // cleanup and the insert run inside ONE transaction so a losing concurrent insert can never
419
- // wipe the winner: the DELETE only ever removes the block's TERMINAL rows and the caller's
420
- // own `replaceId` (the run it is knowingly superseding) — NEVER another writer's fresh live
421
- // row — and the index then rejects a second live insert via DO NOTHING (empty returning).
422
- // Callers therefore MUST NOT `deleteByBlock` first. The conflict target mirrors the D1 repo
423
- // and the index predicate exactly; the insert columns mirror upsert (service_id subquery,
424
- // rev 0).
425
- const now = this.clock.now();
426
- const detail = executionToDetail(execution);
427
- const serviceIdSub = sql `(SELECT ${blocks.service_id} FROM ${blocks} WHERE ${blocks.workspace_id} = ${workspaceId} AND ${blocks.id} = ${execution.blockId})`;
428
- const terminalOrReplaced = opts?.replaceId
429
- ? or(notInArray(agentRuns.status, ['running', 'blocked', 'paused']), eq(agentRuns.id, opts.replaceId))
430
- : notInArray(agentRuns.status, ['running', 'blocked', 'paused']);
431
- const rows = await this.db.transaction(async (tx) => {
432
- await tx
433
- .delete(agentRuns)
434
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.block_id, execution.blockId), eq(agentRuns.kind, 'execution'), terminalOrReplaced));
435
- return tx
436
- .insert(agentRuns)
437
- .values({
438
- workspace_id: workspaceId,
439
- id: execution.id,
440
- kind: 'execution',
441
- block_id: execution.blockId,
442
- status: execution.status,
443
- detail,
444
- created_at: now,
445
- updated_at: now,
446
- workflow_instance_id: execution.id,
447
- service_id: serviceIdSub,
448
- rev: 0,
449
- })
450
- .onConflictDoNothing({
451
- target: [agentRuns.workspace_id, agentRuns.block_id],
452
- // For DO NOTHING, `where` is the conflict target's partial-index predicate (the
453
- // DO-UPDATE `targetWhere`); it must mirror uniq_live_execution_per_block exactly.
454
- where: sql `${agentRuns.kind} = 'execution' AND ${agentRuns.status} IN ('running', 'blocked', 'paused')`,
455
- })
456
- .returning({ rev: agentRuns.rev });
457
- });
458
- if (!rows[0])
459
- return false;
460
- execution.rev = rows[0].rev;
461
- return true;
462
- }
463
- async compareAndSwap(workspaceId, execution) {
464
- // Conditional update guarded on the rev last read onto this instance; only writes
465
- // when the stored row is unchanged. No insert — the run must already exist.
466
- const expected = execution.rev ?? 0;
467
- const now = this.clock.now();
468
- const detail = executionToDetail(execution);
469
- const serviceIdSub = sql `(SELECT ${blocks.service_id} FROM ${blocks} WHERE ${blocks.workspace_id} = ${workspaceId} AND ${blocks.id} = ${execution.blockId})`;
470
- const rows = await this.db
471
- .update(agentRuns)
472
- .set({
473
- block_id: execution.blockId,
474
- status: execution.status,
475
- detail,
476
- updated_at: now,
477
- service_id: serviceIdSub,
478
- rev: sql `${agentRuns.rev} + 1`,
479
- })
480
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, execution.id), this.isExecution, eq(agentRuns.rev, expected)))
481
- .returning({ rev: agentRuns.rev });
482
- if (!rows[0])
483
- return false;
484
- execution.rev = rows[0].rev;
485
- return true;
486
- }
487
- async deleteByBlock(workspaceId, blockId) {
488
- await this.db
489
- .delete(agentRuns)
490
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.block_id, blockId), this.isExecution));
491
- }
492
- async listStale(olderThanEpochMs) {
493
- const rows = await this.db
494
- .select({ workspaceId: agentRuns.workspace_id, id: agentRuns.id })
495
- .from(agentRuns)
496
- .where(and(this.isExecution, eq(agentRuns.status, 'running'), lt(agentRuns.updated_at, olderThanEpochMs)))
497
- .orderBy(agentRuns.updated_at);
498
- return rows;
499
- }
500
- async markFailed(workspaceId, id, failure) {
501
- // Guard against clobbering a row that already reached a terminal state: a `stopRun`
502
- // racing a run that just merged (`done`) or already failed must not overwrite it. This
503
- // is the authoritative first-write-wins / no-re-fail-a-merged-run check — `failRun`'s
504
- // in-memory guard reads a snapshot that can be stale by the time this write lands
505
- // (race-audit 2.3). Mirrors the D1 `AND status NOT IN ('done','failed')`.
506
- //
507
- // BUMP `rev` on the terminal write so it participates in the driver's optimistic
508
- // concurrency: a `casPersist` from an in-flight driver iteration that loaded the run
509
- // BEFORE this `stopRun`/`failRun` still holds the pre-fail `rev`, so bumping it here makes
510
- // that stale write miss its `rev = ?` guard → `RunContendedError` → re-drive → the reload
511
- // sees `failed` and no-ops. Without the bump `markFailed` left `rev` untouched, so a stale
512
- // `casPersist` writing a non-terminal status (`pollGate` pending, dispatch, …) would MATCH
513
- // the unchanged `rev` and RESURRECT the stopped run as `running` (race-audit 2.3, the
514
- // driver-clobbers-terminal direction — the dual of the SQL status guard above). Mirrors the
515
- // D1 `rev = rev + 1`.
516
- await this.db
517
- .update(agentRuns)
518
- .set({
519
- status: 'failed',
520
- error: failure.message,
521
- failure: JSON.stringify(failure),
522
- updated_at: this.clock.now(),
523
- rev: sql `${agentRuns.rev} + 1`,
524
- })
525
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, id), this.isExecution, notInArray(agentRuns.status, ['done', 'failed'])));
526
- }
527
- }
528
- class DrizzleAgentRunRepository {
529
- db;
530
- constructor(db) {
531
- this.db = db;
532
- }
533
- async getRef(workspaceId, id) {
534
- const [row] = await this.db
535
- .select({ kind: agentRuns.kind })
536
- .from(agentRuns)
537
- .where(and(eq(agentRuns.workspace_id, workspaceId), eq(agentRuns.id, id)));
538
- return row
539
- ? {
540
- workspaceId,
541
- id,
542
- kind: decodeEnum(agentRunKindSchema, row.kind, {
543
- table: 'agent_runs',
544
- column: 'kind',
545
- id,
546
- }),
547
- }
548
- : null;
549
- }
550
- async listStale(olderThanEpochMs) {
551
- const rows = await this.db
552
- .select({
553
- workspaceId: agentRuns.workspace_id,
554
- id: agentRuns.id,
555
- kind: agentRuns.kind,
556
- updatedAt: agentRuns.updated_at,
557
- })
558
- .from(agentRuns)
559
- .where(and(eq(agentRuns.status, 'running'), lt(agentRuns.updated_at, olderThanEpochMs)))
560
- .orderBy(agentRuns.updated_at);
561
- return rows.map((r) => ({
562
- workspaceId: r.workspaceId,
563
- id: r.id,
564
- updatedAt: r.updatedAt,
565
- kind: decodeEnum(agentRunKindSchema, r.kind, {
566
- table: 'agent_runs',
567
- column: 'kind',
568
- id: r.id,
569
- }),
570
- }));
571
- }
572
- async listPausedExecutions() {
573
- const rows = await this.db
574
- .select({ workspaceId: agentRuns.workspace_id, id: agentRuns.id })
575
- .from(agentRuns)
576
- .where(and(eq(agentRuns.kind, 'execution'), eq(agentRuns.status, 'paused')))
577
- .orderBy(agentRuns.updated_at);
578
- return rows.map((r) => ({ workspaceId: r.workspaceId, id: r.id, kind: 'execution' }));
579
- }
580
- async liveRunIds(ids) {
581
- if (ids.length === 0)
582
- return [];
583
- const live = [];
584
- // Chunk the IN list (batch, not a point-read per id) so a large set stays one query each.
585
- for (let i = 0; i < ids.length; i += 500) {
586
- const rows = await this.db
587
- .select({ id: agentRuns.id })
588
- .from(agentRuns)
589
- .where(and(inArray(agentRuns.status, ['running', 'blocked', 'paused', 'pending']), inArray(agentRuns.id, ids.slice(i, i + 500))));
590
- for (const r of rows)
591
- live.push(r.id);
592
- }
593
- return live;
594
- }
595
- }
596
- function rowToAccount(row) {
597
- return {
598
- id: row.id,
599
- type: row.type === 'org' ? 'org' : 'personal',
600
- name: row.name,
601
- githubAccountLogin: row.github_account_login,
602
- ownerUserId: row.owner_user_id,
603
- createdAt: row.created_at,
604
- ...(row.default_cloud_provider
605
- ? { defaultCloudProvider: row.default_cloud_provider }
606
- : {}),
607
- ...(row.spend_monthly_limit != null ? { spendMonthlyLimit: row.spend_monthly_limit } : {}),
608
- };
609
- }
610
- class DrizzleAccountRepository {
611
- db;
612
- constructor(db) {
613
- this.db = db;
614
- }
615
- async get(id) {
616
- const [row] = await this.db.select().from(accounts).where(eq(accounts.id, id));
617
- return row ? rowToAccount(row) : null;
618
- }
619
- async listByIds(ids) {
620
- if (ids.length === 0)
621
- return [];
622
- const out = [];
623
- // Chunk the IN list to stay well under the bind-parameter limit.
624
- for (let i = 0; i < ids.length; i += 500) {
625
- const rows = await this.db
626
- .select()
627
- .from(accounts)
628
- .where(inArray(accounts.id, ids.slice(i, i + 500)));
629
- for (const row of rows)
630
- out.push(rowToAccount(row));
631
- }
632
- return out;
633
- }
634
- async create(account) {
635
- await this.db.insert(accounts).values({
636
- id: account.id,
637
- type: account.type,
638
- name: account.name,
639
- github_account_login: account.githubAccountLogin,
640
- owner_user_id: account.ownerUserId,
641
- created_at: account.createdAt,
642
- default_cloud_provider: account.defaultCloudProvider ?? null,
643
- spend_monthly_limit: account.spendMonthlyLimit ?? null,
644
- });
645
- }
646
- async ensurePersonal(account) {
647
- // Atomic get-or-create: `ON CONFLICT DO NOTHING` no-ops when a personal account already
648
- // exists for this owner (the partial unique index `idx_accounts_personal` arbitrates), so
649
- // concurrent first-sign-in callers converge on the one surviving row instead of racing to
650
- // a duplicate-key error. Re-select to return whichever row won.
651
- await this.db
652
- .insert(accounts)
653
- .values({
654
- id: account.id,
655
- type: account.type,
656
- name: account.name,
657
- github_account_login: account.githubAccountLogin,
658
- owner_user_id: account.ownerUserId,
659
- created_at: account.createdAt,
660
- default_cloud_provider: account.defaultCloudProvider ?? null,
661
- spend_monthly_limit: account.spendMonthlyLimit ?? null,
662
- })
663
- .onConflictDoNothing();
664
- const row = await this.findPersonalByUser(account.ownerUserId ?? '');
665
- if (!row) {
666
- throw new Error(`ensurePersonal: personal account missing after insert for ${account.ownerUserId}`);
667
- }
668
- return row;
669
- }
670
- async rename(id, name) {
671
- await this.db.update(accounts).set({ name }).where(eq(accounts.id, id));
672
- }
673
- async updateSettings(id, patch) {
674
- const set = {};
675
- if ('defaultCloudProvider' in patch) {
676
- set.default_cloud_provider = patch.defaultCloudProvider ?? null;
677
- }
678
- if ('spendMonthlyLimit' in patch) {
679
- set.spend_monthly_limit = patch.spendMonthlyLimit ?? null;
680
- }
681
- if (Object.keys(set).length === 0)
682
- return;
683
- await this.db.update(accounts).set(set).where(eq(accounts.id, id));
684
- }
685
- async findPersonalByUser(userId) {
686
- const [row] = await this.db
687
- .select()
688
- .from(accounts)
689
- .where(and(eq(accounts.type, 'personal'), eq(accounts.owner_user_id, userId)));
690
- return row ? rowToAccount(row) : null;
691
- }
692
- }
693
- /** Parse the CSV `roles` column into a non-empty role set (defaults to developer). */
694
- function parseRoles(csv) {
695
- const roles = (csv ?? '')
696
- .split(',')
697
- .map((r) => r.trim())
698
- .filter((r) => r === 'admin' || r === 'developer' || r === 'product');
699
- return roles.length > 0 ? [...new Set(roles)] : ['developer'];
700
- }
701
- function rowToMembership(row) {
702
- return {
703
- accountId: row.account_id,
704
- userId: row.user_id,
705
- roles: parseRoles(row.roles),
706
- createdAt: row.created_at,
707
- };
708
- }
709
- class DrizzleMembershipRepository {
710
- db;
711
- constructor(db) {
712
- this.db = db;
713
- }
714
- async listByUser(userId) {
715
- const rows = await this.db
716
- .select()
717
- .from(memberships)
718
- .where(eq(memberships.user_id, userId))
719
- .orderBy(memberships.created_at);
720
- return rows.map(rowToMembership);
721
- }
722
- async listByAccount(accountId) {
723
- const rows = await this.db
724
- .select()
725
- .from(memberships)
726
- .where(eq(memberships.account_id, accountId))
727
- .orderBy(memberships.created_at);
728
- return rows.map(rowToMembership);
729
- }
730
- async get(accountId, userId) {
731
- const [row] = await this.db
732
- .select()
733
- .from(memberships)
734
- .where(and(eq(memberships.account_id, accountId), eq(memberships.user_id, userId)));
735
- return row ? rowToMembership(row) : null;
736
- }
737
- async upsert(membership) {
738
- await this.db
739
- .insert(memberships)
740
- .values({
741
- account_id: membership.accountId,
742
- user_id: membership.userId,
743
- roles: membership.roles.join(','),
744
- created_at: membership.createdAt,
745
- })
746
- .onConflictDoUpdate({
747
- target: [memberships.account_id, memberships.user_id],
748
- set: { roles: membership.roles.join(',') },
749
- });
750
- }
751
- async remove(accountId, userId) {
752
- await this.db
753
- .delete(memberships)
754
- .where(and(eq(memberships.account_id, accountId), eq(memberships.user_id, userId)));
755
- }
756
- }
757
- function rowToUser(row) {
758
- return {
759
- id: row.id,
760
- name: row.name,
761
- email: row.email,
762
- avatarUrl: row.avatar_url,
763
- createdAt: row.created_at,
764
- };
765
- }
766
- function rowToIdentity(row) {
767
- return {
768
- userId: row.user_id,
769
- provider: row.provider,
770
- subject: row.subject,
771
- secret: row.secret,
772
- metadata: row.metadata,
773
- createdAt: row.created_at,
774
- };
775
- }
776
- class DrizzleUserRepository {
777
- db;
778
- constructor(db) {
779
- this.db = db;
780
- }
781
- async get(id) {
782
- const [row] = await this.db.select().from(users).where(eq(users.id, id));
783
- return row ? rowToUser(row) : null;
784
- }
785
- async create(user) {
786
- await this.db.insert(users).values({
787
- id: user.id,
788
- name: user.name,
789
- email: user.email,
790
- avatar_url: user.avatarUrl,
791
- created_at: user.createdAt,
792
- });
793
- }
794
- async update(id, patch) {
795
- const set = {};
796
- if ('name' in patch)
797
- set.name = patch.name;
798
- if ('email' in patch)
799
- set.email = patch.email;
800
- if ('avatarUrl' in patch)
801
- set.avatar_url = patch.avatarUrl;
802
- if (Object.keys(set).length === 0)
803
- return;
804
- await this.db.update(users).set(set).where(eq(users.id, id));
805
- }
806
- async findByIdentity(provider, subject) {
807
- const [row] = await this.db
808
- .select()
809
- .from(users)
810
- .innerJoin(userIdentities, eq(userIdentities.user_id, users.id))
811
- .where(and(eq(userIdentities.provider, provider), eq(userIdentities.subject, subject)));
812
- return row ? rowToUser(row.users) : null;
813
- }
814
- async findByEmail(email) {
815
- const [row] = await this.db
816
- .select()
817
- .from(users)
818
- .where(eq(users.email, email.toLowerCase().trim()));
819
- return row ? rowToUser(row) : null;
820
- }
821
- async listByIds(ids) {
822
- if (ids.length === 0)
823
- return [];
824
- const rows = await this.db.select().from(users).where(inArray(users.id, ids));
825
- return rows.map(rowToUser);
826
- }
827
- async getIdentity(provider, subject) {
828
- const [row] = await this.db
829
- .select()
830
- .from(userIdentities)
831
- .where(and(eq(userIdentities.provider, provider), eq(userIdentities.subject, subject)));
832
- return row ? rowToIdentity(row) : null;
833
- }
834
- async linkIdentity(identity) {
835
- await this.db
836
- .insert(userIdentities)
837
- .values({
838
- user_id: identity.userId,
839
- provider: identity.provider,
840
- subject: identity.subject,
841
- secret: identity.secret,
842
- metadata: identity.metadata,
843
- created_at: identity.createdAt,
844
- })
845
- .onConflictDoUpdate({
846
- target: [userIdentities.provider, userIdentities.subject],
847
- set: { user_id: identity.userId, secret: identity.secret, metadata: identity.metadata },
848
- });
849
- }
850
- async listIdentities(userId) {
851
- const rows = await this.db
852
- .select()
853
- .from(userIdentities)
854
- .where(eq(userIdentities.user_id, userId));
855
- return rows.map(rowToIdentity);
856
- }
857
- }
858
- function rowToInvitation(row) {
859
- return {
860
- id: row.id,
861
- accountId: row.account_id,
862
- email: row.email,
863
- roles: parseRoles(row.roles),
864
- tokenHash: row.token_hash,
865
- invitedBy: row.invited_by,
866
- status: row.status,
867
- expiresAt: row.expires_at,
868
- createdAt: row.created_at,
869
- };
870
- }
871
- class DrizzleAccountInvitationRepository {
872
- db;
873
- constructor(db) {
874
- this.db = db;
875
- }
876
- async create(record) {
877
- await this.db.insert(accountInvitations).values({
878
- id: record.id,
879
- account_id: record.accountId,
880
- email: record.email,
881
- roles: record.roles.join(','),
882
- token_hash: record.tokenHash,
883
- invited_by: record.invitedBy,
884
- status: record.status,
885
- expires_at: record.expiresAt,
886
- created_at: record.createdAt,
887
- });
888
- }
889
- async get(id) {
890
- const [row] = await this.db
891
- .select()
892
- .from(accountInvitations)
893
- .where(eq(accountInvitations.id, id));
894
- return row ? rowToInvitation(row) : null;
895
- }
896
- async findByTokenHash(tokenHash) {
897
- const [row] = await this.db
898
- .select()
899
- .from(accountInvitations)
900
- .where(eq(accountInvitations.token_hash, tokenHash));
901
- return row ? rowToInvitation(row) : null;
902
- }
903
- async listByAccount(accountId) {
904
- const rows = await this.db
905
- .select()
906
- .from(accountInvitations)
907
- .where(eq(accountInvitations.account_id, accountId))
908
- .orderBy(desc(accountInvitations.created_at));
909
- return rows.map(rowToInvitation);
910
- }
911
- async setStatus(id, status) {
912
- await this.db.update(accountInvitations).set({ status }).where(eq(accountInvitations.id, id));
913
- }
914
- }
915
- function rowToPasswordResetToken(row) {
916
- return {
917
- id: row.id,
918
- userId: row.user_id,
919
- tokenHash: row.token_hash,
920
- status: row.status,
921
- expiresAt: row.expires_at,
922
- createdAt: row.created_at,
923
- };
924
- }
925
- class DrizzlePasswordResetTokenRepository {
926
- db;
927
- constructor(db) {
928
- this.db = db;
929
- }
930
- async create(record) {
931
- await this.db.insert(passwordResetTokens).values({
932
- id: record.id,
933
- user_id: record.userId,
934
- token_hash: record.tokenHash,
935
- status: record.status,
936
- expires_at: record.expiresAt,
937
- created_at: record.createdAt,
938
- });
939
- }
940
- async findByTokenHash(tokenHash) {
941
- const [row] = await this.db
942
- .select()
943
- .from(passwordResetTokens)
944
- .where(eq(passwordResetTokens.token_hash, tokenHash));
945
- return row ? rowToPasswordResetToken(row) : null;
946
- }
947
- async listPendingByUser(userId) {
948
- const rows = await this.db
949
- .select()
950
- .from(passwordResetTokens)
951
- .where(and(eq(passwordResetTokens.user_id, userId), eq(passwordResetTokens.status, 'pending')))
952
- .orderBy(desc(passwordResetTokens.created_at));
953
- return rows.map(rowToPasswordResetToken);
954
- }
955
- async setStatus(id, status) {
956
- await this.db.update(passwordResetTokens).set({ status }).where(eq(passwordResetTokens.id, id));
957
- }
958
- async consume(id) {
959
- // Conditional on `status='pending'` so concurrent redemptions can't both win.
960
- const result = await this.db
961
- .update(passwordResetTokens)
962
- .set({ status: 'used' })
963
- .where(and(eq(passwordResetTokens.id, id), eq(passwordResetTokens.status, 'pending')));
964
- return (result.rowCount ?? 0) > 0;
965
- }
966
- async deleteExpired(before) {
967
- const result = await this.db
968
- .delete(passwordResetTokens)
969
- .where(lt(passwordResetTokens.expires_at, before));
970
- return result.rowCount ?? 0;
971
- }
972
- }
973
- function rowToEmailConnection(row) {
974
- return {
975
- accountId: row.account_id,
976
- provider: row.provider,
977
- fromAddress: row.from_address,
978
- apiKeyCipher: row.api_key_cipher,
979
- createdAt: row.created_at,
980
- updatedAt: row.updated_at,
981
- deletedAt: row.deleted_at,
982
- };
983
- }
984
- class DrizzleEmailConnectionRepository {
985
- db;
986
- constructor(db) {
987
- this.db = db;
988
- }
989
- async getByAccount(accountId) {
990
- const [row] = await this.db
991
- .select()
992
- .from(emailConnections)
993
- .where(and(eq(emailConnections.account_id, accountId), isNull(emailConnections.deleted_at)));
994
- return row ? rowToEmailConnection(row) : null;
995
- }
996
- async upsert(record) {
997
- await this.db
998
- .insert(emailConnections)
999
- .values({
1000
- account_id: record.accountId,
1001
- provider: record.provider,
1002
- from_address: record.fromAddress,
1003
- api_key_cipher: record.apiKeyCipher,
1004
- created_at: record.createdAt,
1005
- updated_at: record.updatedAt,
1006
- deleted_at: record.deletedAt,
1007
- })
1008
- .onConflictDoUpdate({
1009
- target: emailConnections.account_id,
1010
- set: {
1011
- provider: record.provider,
1012
- from_address: record.fromAddress,
1013
- api_key_cipher: record.apiKeyCipher,
1014
- updated_at: record.updatedAt,
1015
- deleted_at: record.deletedAt,
1016
- },
1017
- });
1018
- }
1019
- async softDelete(accountId, at) {
1020
- await this.db
1021
- .update(emailConnections)
1022
- .set({ deleted_at: at, updated_at: at })
1023
- .where(eq(emailConnections.account_id, accountId));
1024
- }
1025
- }
1026
- class DrizzleTokenUsageRepository {
1027
- db;
1028
- constructor(db) {
1029
- this.db = db;
1030
- }
1031
- async record(usage) {
1032
- await this.db.insert(tokenUsage).values({
1033
- id: usage.id,
1034
- workspace_id: usage.workspaceId,
1035
- account_id: usage.accountId,
1036
- user_id: usage.userId,
1037
- execution_id: usage.executionId,
1038
- agent_kind: usage.agentKind,
1039
- provider: usage.provider,
1040
- model: usage.model,
1041
- input_tokens: usage.inputTokens,
1042
- output_tokens: usage.outputTokens,
1043
- cost_estimate: usage.costEstimate,
1044
- billing: usage.billing,
1045
- vendor: usage.vendor,
1046
- created_at: usage.createdAt,
1047
- });
1048
- }
1049
- async usageBreakdownForWorkspace(workspaceId, epochMs) {
1050
- // One GROUP BY over the workspace's current period — both billing kinds (the report
1051
- // shows total usage). Never a per-model loop. sum() of int columns is bigint; cast +
1052
- // coerce like the totals rollups. Ordered heaviest-first in SQL, mirroring the D1 repo.
1053
- const rows = await this.db
1054
- .select({
1055
- billing: tokenUsage.billing,
1056
- vendor: tokenUsage.vendor,
1057
- provider: tokenUsage.provider,
1058
- model: tokenUsage.model,
1059
- input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1060
- output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1061
- cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1062
- calls: sql `count(*)::bigint`,
1063
- })
1064
- .from(tokenUsage)
1065
- .where(and(eq(tokenUsage.workspace_id, workspaceId), gte(tokenUsage.created_at, epochMs)))
1066
- .groupBy(tokenUsage.billing, tokenUsage.vendor, tokenUsage.provider, tokenUsage.model)
1067
- .orderBy(sql `(coalesce(sum(${tokenUsage.input_tokens}), 0) + coalesce(sum(${tokenUsage.output_tokens}), 0)) desc`);
1068
- return rows.map((r) => ({
1069
- billing: (r.billing === 'subscription' ? 'subscription' : 'metered'),
1070
- vendor: r.vendor,
1071
- provider: r.provider,
1072
- model: r.model,
1073
- inputTokens: Number(r.input ?? 0),
1074
- outputTokens: Number(r.output ?? 0),
1075
- costEstimate: r.cost ?? 0,
1076
- calls: Number(r.calls ?? 0),
1077
- }));
1078
- }
1079
- async totalsSince(epochMs) {
1080
- // sum() of int columns is bigint in Postgres — cast to bigint (NOT int4, which
1081
- // overflows past ~2.1B tokens) and coerce: node-postgres returns bigint as a
1082
- // string to avoid precision loss, and token totals stay well within Number's
1083
- // safe-integer range. Matches the 64-bit sum the D1/SQLite store returns.
1084
- const [row] = await this.db
1085
- .select({
1086
- input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1087
- output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1088
- cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1089
- })
1090
- .from(tokenUsage)
1091
- .where(and(gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1092
- return {
1093
- inputTokens: Number(row?.input ?? 0),
1094
- outputTokens: Number(row?.output ?? 0),
1095
- costEstimate: row?.cost ?? 0,
1096
- };
1097
- }
1098
- async totalsSinceForWorkspace(workspaceId, epochMs) {
1099
- const [row] = await this.db
1100
- .select({
1101
- input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1102
- output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1103
- cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1104
- })
1105
- .from(tokenUsage)
1106
- .where(and(eq(tokenUsage.workspace_id, workspaceId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1107
- return {
1108
- inputTokens: Number(row?.input ?? 0),
1109
- outputTokens: Number(row?.output ?? 0),
1110
- costEstimate: row?.cost ?? 0,
1111
- };
1112
- }
1113
- async totalsSinceForAccount(accountId, epochMs) {
1114
- const [row] = await this.db
1115
- .select({
1116
- input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1117
- output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1118
- cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1119
- })
1120
- .from(tokenUsage)
1121
- .where(and(eq(tokenUsage.account_id, accountId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1122
- return {
1123
- inputTokens: Number(row?.input ?? 0),
1124
- outputTokens: Number(row?.output ?? 0),
1125
- costEstimate: row?.cost ?? 0,
1126
- };
1127
- }
1128
- async totalsSinceForUser(userId, epochMs) {
1129
- const [row] = await this.db
1130
- .select({
1131
- input: sql `coalesce(sum(${tokenUsage.input_tokens}), 0)::bigint`,
1132
- output: sql `coalesce(sum(${tokenUsage.output_tokens}), 0)::bigint`,
1133
- cost: sql `coalesce(sum(${tokenUsage.cost_estimate}), 0)::float8`,
1134
- })
1135
- .from(tokenUsage)
1136
- .where(and(eq(tokenUsage.user_id, userId), gte(tokenUsage.created_at, epochMs), eq(tokenUsage.billing, 'metered')));
1137
- return {
1138
- inputTokens: Number(row?.input ?? 0),
1139
- outputTokens: Number(row?.output ?? 0),
1140
- costEstimate: row?.cost ?? 0,
1141
- };
1142
- }
1143
- async deleteOlderThan(epochMs) {
1144
- const deleted = await this.db
1145
- .delete(tokenUsage)
1146
- .where(lt(tokenUsage.created_at, epochMs))
1147
- .returning({ id: tokenUsage.id });
1148
- return deleted.length;
1149
- }
1150
- }
1151
- class DrizzleUserSettingsRepository {
1152
- db;
1153
- constructor(db) {
1154
- this.db = db;
1155
- }
1156
- async get(userId) {
1157
- const [row] = await this.db.select().from(userSettings).where(eq(userSettings.user_id, userId));
1158
- return row ? { spendMonthlyLimit: row.spend_monthly_limit } : null;
1159
- }
1160
- async upsert(userId, settings) {
1161
- await this.db
1162
- .insert(userSettings)
1163
- .values({
1164
- user_id: userId,
1165
- spend_monthly_limit: settings.spendMonthlyLimit ?? null,
1166
- updated_at: Date.now(),
1167
- })
1168
- .onConflictDoUpdate({
1169
- target: userSettings.user_id,
1170
- set: { spend_monthly_limit: settings.spendMonthlyLimit ?? null, updated_at: Date.now() },
1171
- });
1172
- }
1173
- }
1174
- function rowToLlmMetric(row) {
1175
- return {
1176
- id: row.id,
1177
- workspaceId: row.workspace_id,
1178
- executionId: row.execution_id,
1179
- agentKind: row.agent_kind,
1180
- provider: row.provider,
1181
- model: row.model,
1182
- createdAt: row.created_at,
1183
- streaming: row.streaming === 1,
1184
- messageCount: row.message_count,
1185
- toolCount: row.tool_count,
1186
- requestMaxTokens: row.request_max_tokens,
1187
- promptTokens: row.prompt_tokens,
1188
- cachedPromptTokens: row.cached_prompt_tokens,
1189
- completionTokens: row.completion_tokens,
1190
- totalTokens: row.total_tokens,
1191
- finishReason: row.finish_reason,
1192
- upstreamMs: row.upstream_ms,
1193
- overheadMs: row.overhead_ms,
1194
- totalMs: row.total_ms,
1195
- ok: row.ok === 1,
1196
- httpStatus: row.http_status,
1197
- errorMessage: row.error_message,
1198
- promptText: row.prompt_text,
1199
- promptPrefixCount: row.prompt_prefix_count,
1200
- promptHash: row.prompt_hash,
1201
- responseText: row.response_text,
1202
- reasoningText: row.reasoning_text,
1203
- };
1204
- }
1205
- class DrizzleLlmCallMetricRepository {
1206
- db;
1207
- constructor(db) {
1208
- this.db = db;
1209
- }
1210
- async record(metric) {
1211
- await this.db.insert(llmCallMetrics).values({
1212
- id: metric.id,
1213
- workspace_id: metric.workspaceId,
1214
- execution_id: metric.executionId,
1215
- agent_kind: metric.agentKind,
1216
- provider: metric.provider,
1217
- model: metric.model,
1218
- created_at: metric.createdAt,
1219
- streaming: metric.streaming ? 1 : 0,
1220
- message_count: metric.messageCount,
1221
- tool_count: metric.toolCount,
1222
- request_max_tokens: metric.requestMaxTokens,
1223
- prompt_tokens: metric.promptTokens,
1224
- cached_prompt_tokens: metric.cachedPromptTokens,
1225
- completion_tokens: metric.completionTokens,
1226
- total_tokens: metric.totalTokens,
1227
- finish_reason: metric.finishReason,
1228
- upstream_ms: metric.upstreamMs,
1229
- overhead_ms: metric.overheadMs,
1230
- total_ms: metric.totalMs,
1231
- ok: metric.ok ? 1 : 0,
1232
- http_status: metric.httpStatus,
1233
- error_message: metric.errorMessage,
1234
- prompt_text: metric.promptText,
1235
- prompt_prefix_count: metric.promptPrefixCount,
1236
- prompt_hash: metric.promptHash,
1237
- response_text: metric.responseText,
1238
- reasoning_text: metric.reasoningText,
1239
- });
1240
- }
1241
- async latestChainTip(workspaceId, executionId, agentKind) {
1242
- // The newest call for the conversation; one indexed row, no text columns.
1243
- const rows = await this.db
1244
- .select({
1245
- messageCount: llmCallMetrics.message_count,
1246
- promptHash: llmCallMetrics.prompt_hash,
1247
- })
1248
- .from(llmCallMetrics)
1249
- .where(and(eq(llmCallMetrics.workspace_id, workspaceId), eq(llmCallMetrics.execution_id, executionId), eq(llmCallMetrics.agent_kind, agentKind)))
1250
- // message_count breaks a same-millisecond createdAt tie in chain order (it grows
1251
- // monotonically as the conversation appends); id is the last resort.
1252
- .orderBy(desc(llmCallMetrics.created_at), desc(llmCallMetrics.message_count), desc(llmCallMetrics.id))
1253
- .limit(1);
1254
- const row = rows[0];
1255
- return row ? { messageCount: row.messageCount, promptHash: row.promptHash } : null;
1256
- }
1257
- async listByExecution(workspaceId, executionId, limit, agentKind) {
1258
- const base = this.db
1259
- .select()
1260
- .from(llmCallMetrics)
1261
- .where(and(eq(llmCallMetrics.workspace_id, workspaceId), eq(llmCallMetrics.execution_id, executionId), ...(agentKind == null ? [] : [eq(llmCallMetrics.agent_kind, agentKind)])))
1262
- .orderBy(desc(llmCallMetrics.created_at), desc(llmCallMetrics.id));
1263
- const rows = await (limit == null ? base : base.limit(limit));
1264
- return rows.map(rowToLlmMetric);
1265
- }
1266
- async summarizeByExecution(workspaceId, executionId) {
1267
- // Aggregate-only: selects no prompt/response text, so it stays cheap on every
1268
- // execution emit (it backs the live board rollups). int sums fit Number's safe
1269
- // range here (per-run call counts/tokens are small), so a plain ::bigint cast
1270
- // matching the SQLite 64-bit sum is unnecessary — totals are coerced below.
1271
- const reasons = [...LLM_WARNING_FINISH_REASONS];
1272
- const rows = await this.db
1273
- .select({
1274
- agentKind: llmCallMetrics.agent_kind,
1275
- calls: sql `count(*)::int`,
1276
- promptTokens: sql `coalesce(sum(${llmCallMetrics.prompt_tokens}), 0)::int`,
1277
- cachedPromptTokens: sql `coalesce(sum(${llmCallMetrics.cached_prompt_tokens}), 0)::int`,
1278
- completionTokens: sql `coalesce(sum(${llmCallMetrics.completion_tokens}), 0)::int`,
1279
- peakCompletionTokens: sql `coalesce(max(${llmCallMetrics.completion_tokens}), 0)::int`,
1280
- maxOutputTokens: sql `max(${llmCallMetrics.request_max_tokens})`,
1281
- truncatedCalls: sql `coalesce(sum(case when ${llmCallMetrics.finish_reason} = 'length' then 1 else 0 end), 0)::int`,
1282
- upstreamMs: sql `coalesce(sum(${llmCallMetrics.upstream_ms}), 0)::int`,
1283
- overheadMs: sql `coalesce(sum(${llmCallMetrics.overhead_ms}), 0)::int`,
1284
- errors: sql `coalesce(sum(case when ${llmCallMetrics.ok} = 0 then 1 else 0 end), 0)::int`,
1285
- // `inArray` builds the IN-list membership: idiomatic, type-checked, and tied to
1286
- // the shared constant. (A raw `${...finish_reason} in ${reasons}` renders the same
1287
- // `in ($1, $2)` on this drizzle version; inArray just documents intent and can't
1288
- // silently mis-bind the array.)
1289
- warnings: sql `coalesce(sum(case when ${llmCallMetrics.ok} = 1 and ${inArray(llmCallMetrics.finish_reason, reasons)} then 1 else 0 end), 0)::int`,
1290
- })
1291
- .from(llmCallMetrics)
1292
- .where(and(eq(llmCallMetrics.workspace_id, workspaceId), eq(llmCallMetrics.execution_id, executionId)))
1293
- .groupBy(llmCallMetrics.agent_kind);
1294
- return rows.map((r) => ({
1295
- agentKind: r.agentKind,
1296
- calls: Number(r.calls),
1297
- promptTokens: Number(r.promptTokens),
1298
- cachedPromptTokens: Number(r.cachedPromptTokens),
1299
- completionTokens: Number(r.completionTokens),
1300
- peakCompletionTokens: Number(r.peakCompletionTokens),
1301
- maxOutputTokens: r.maxOutputTokens == null ? null : Number(r.maxOutputTokens),
1302
- truncatedCalls: Number(r.truncatedCalls),
1303
- upstreamMs: Number(r.upstreamMs),
1304
- overheadMs: Number(r.overheadMs),
1305
- errors: Number(r.errors),
1306
- warnings: Number(r.warnings),
1307
- }));
1308
- }
1309
- async deleteOlderThan(epochMs) {
1310
- const deleted = await this.db
1311
- .delete(llmCallMetrics)
1312
- .where(lt(llmCallMetrics.created_at, epochMs))
1313
- .returning({ id: llmCallMetrics.id });
1314
- return deleted.length;
1315
- }
1316
- }
1317
- function rowToAgentContextSnapshot(row) {
1318
- return {
1319
- id: row.id,
1320
- workspaceId: row.workspace_id,
1321
- executionId: row.execution_id,
1322
- agentKind: row.agent_kind,
1323
- stepIndex: row.step_index,
1324
- createdAt: row.created_at,
1325
- model: row.model,
1326
- harness: row.harness,
1327
- systemPrompt: row.system_prompt,
1328
- userPrompt: row.user_prompt,
1329
- fragments: parseJsonArray(row.fragments),
1330
- contextFiles: parseJsonArray(row.context_files),
1331
- extras: parseAgentContextExtras(row.extras),
1332
- };
1333
- }
1334
- /** Parse the extras JSON object column, degrading a malformed value to {}. */
1335
- function parseAgentContextExtras(text) {
1336
- try {
1337
- const parsed = JSON.parse(text);
1338
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
1339
- ? parsed
1340
- : {};
1341
- }
1342
- catch {
1343
- return {};
1344
- }
1345
- }
1346
- class DrizzleAgentContextSnapshotRepository {
1347
- db;
1348
- constructor(db) {
1349
- this.db = db;
1350
- }
1351
- async record(snapshot) {
1352
- await this.db.insert(agentContextSnapshots).values({
1353
- id: snapshot.id,
1354
- workspace_id: snapshot.workspaceId,
1355
- execution_id: snapshot.executionId,
1356
- agent_kind: snapshot.agentKind,
1357
- step_index: snapshot.stepIndex,
1358
- created_at: snapshot.createdAt,
1359
- model: snapshot.model,
1360
- harness: snapshot.harness,
1361
- system_prompt: snapshot.systemPrompt,
1362
- user_prompt: snapshot.userPrompt,
1363
- fragments: JSON.stringify(snapshot.fragments),
1364
- context_files: JSON.stringify(snapshot.contextFiles),
1365
- extras: JSON.stringify(snapshot.extras),
1366
- });
1367
- }
1368
- async listByExecution(workspaceId, executionId) {
1369
- const rows = await this.db
1370
- .select()
1371
- .from(agentContextSnapshots)
1372
- .where(and(eq(agentContextSnapshots.workspace_id, workspaceId), eq(agentContextSnapshots.execution_id, executionId)))
1373
- .orderBy(desc(agentContextSnapshots.created_at), desc(agentContextSnapshots.id));
1374
- return rows.map(rowToAgentContextSnapshot);
1375
- }
1376
- async deleteOlderThan(epochMs) {
1377
- const deleted = await this.db
1378
- .delete(agentContextSnapshots)
1379
- .where(lt(agentContextSnapshots.created_at, epochMs))
1380
- .returning({ id: agentContextSnapshots.id });
1381
- return deleted.length;
1382
- }
1383
- }
1384
- function rowToAgentSearchQuery(row) {
1385
- return {
1386
- id: row.id,
1387
- workspaceId: row.workspace_id,
1388
- executionId: row.execution_id,
1389
- agentKind: row.agent_kind,
1390
- // The stored provider column is free-text; narrow it back to the wire union.
1391
- provider: isWebSearchProvider(row.provider) ? row.provider : null,
1392
- query: row.query,
1393
- resultCount: row.result_count,
1394
- createdAt: row.created_at,
1395
- };
1396
- }
1397
- class DrizzleAgentSearchQueryRepository {
1398
- db;
1399
- constructor(db) {
1400
- this.db = db;
1401
- }
1402
- async record(query) {
1403
- await this.db.insert(agentSearchQueries).values({
1404
- id: query.id,
1405
- workspace_id: query.workspaceId,
1406
- execution_id: query.executionId,
1407
- agent_kind: query.agentKind,
1408
- provider: query.provider,
1409
- query: query.query,
1410
- result_count: query.resultCount,
1411
- created_at: query.createdAt,
1412
- });
1413
- }
1414
- async listByExecution(workspaceId, executionId) {
1415
- const rows = await this.db
1416
- .select()
1417
- .from(agentSearchQueries)
1418
- .where(and(eq(agentSearchQueries.workspace_id, workspaceId), eq(agentSearchQueries.execution_id, executionId)))
1419
- .orderBy(desc(agentSearchQueries.created_at), desc(agentSearchQueries.id));
1420
- return rows.map(rowToAgentSearchQuery);
1421
- }
1422
- async deleteOlderThan(epochMs) {
1423
- const deleted = await this.db
1424
- .delete(agentSearchQueries)
1425
- .where(lt(agentSearchQueries.created_at, epochMs))
1426
- .returning({ id: agentSearchQueries.id });
1427
- return deleted.length;
1428
- }
1429
- }
1430
- function rowToBinaryArtifact(row) {
1431
- return {
1432
- id: row.id,
1433
- workspaceId: row.workspace_id,
1434
- executionId: row.execution_id,
1435
- blockId: row.block_id,
1436
- kind: row.kind,
1437
- view: row.view,
1438
- contentType: row.content_type,
1439
- byteSize: row.byte_size,
1440
- hash: row.hash,
1441
- storage: row.storage,
1442
- storageKey: row.storage_key,
1443
- createdAt: row.created_at,
1444
- };
1445
- }
1446
- /** Drizzle/Postgres metadata store for binary artifacts (mirror of D1 migration 0017). */
1447
- class DrizzleBinaryArtifactMetadataStore {
1448
- db;
1449
- constructor(db) {
1450
- this.db = db;
1451
- }
1452
- async insert(record) {
1453
- await this.db.insert(binaryArtifacts).values({
1454
- workspace_id: record.workspaceId,
1455
- id: record.id,
1456
- execution_id: record.executionId,
1457
- block_id: record.blockId,
1458
- kind: record.kind,
1459
- view: record.view,
1460
- content_type: record.contentType,
1461
- byte_size: record.byteSize,
1462
- hash: record.hash,
1463
- storage: record.storage,
1464
- storage_key: record.storageKey,
1465
- created_at: record.createdAt,
1466
- });
1467
- }
1468
- async get(workspaceId, id) {
1469
- const rows = await this.db
1470
- .select()
1471
- .from(binaryArtifacts)
1472
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), eq(binaryArtifacts.id, id)))
1473
- .limit(1);
1474
- return rows[0] ? rowToBinaryArtifact(rows[0]) : null;
1475
- }
1476
- async listByExecution(workspaceId, executionId) {
1477
- const rows = await this.db
1478
- .select()
1479
- .from(binaryArtifacts)
1480
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), eq(binaryArtifacts.execution_id, executionId)))
1481
- .orderBy(asc(binaryArtifacts.created_at), asc(binaryArtifacts.id));
1482
- return rows.map(rowToBinaryArtifact);
1483
- }
1484
- async countByExecution(workspaceId, executionId) {
1485
- const rows = await this.db
1486
- .select({ n: count() })
1487
- .from(binaryArtifacts)
1488
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), eq(binaryArtifacts.execution_id, executionId)));
1489
- return rows[0]?.n ?? 0;
1490
- }
1491
- async listByBlock(workspaceId, blockId) {
1492
- const rows = await this.db
1493
- .select()
1494
- .from(binaryArtifacts)
1495
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), eq(binaryArtifacts.block_id, blockId)))
1496
- .orderBy(asc(binaryArtifacts.created_at), asc(binaryArtifacts.id));
1497
- return rows.map(rowToBinaryArtifact);
1498
- }
1499
- async delete(workspaceId, id) {
1500
- await this.db
1501
- .delete(binaryArtifacts)
1502
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), eq(binaryArtifacts.id, id)));
1503
- }
1504
- async listOlderThan(workspaceId, olderThan) {
1505
- const rows = await this.db
1506
- .select()
1507
- .from(binaryArtifacts)
1508
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), lt(binaryArtifacts.created_at, olderThan)));
1509
- return rows.map(rowToBinaryArtifact);
1510
- }
1511
- async deleteOlderThan(workspaceId, olderThan) {
1512
- const deleted = await this.db
1513
- .delete(binaryArtifacts)
1514
- .where(and(eq(binaryArtifacts.workspace_id, workspaceId), lt(binaryArtifacts.created_at, olderThan)))
1515
- .returning({ id: binaryArtifacts.id });
1516
- return deleted.length;
1517
- }
1518
- async listByWorkspace(workspaceId) {
1519
- const rows = await this.db
1520
- .select()
1521
- .from(binaryArtifacts)
1522
- .where(eq(binaryArtifacts.workspace_id, workspaceId));
1523
- return rows.map(rowToBinaryArtifact);
1524
- }
1525
- async deleteByWorkspace(workspaceId) {
1526
- const deleted = await this.db
1527
- .delete(binaryArtifacts)
1528
- .where(eq(binaryArtifacts.workspace_id, workspaceId))
1529
- .returning({ id: binaryArtifacts.id });
1530
- return deleted.length;
1531
- }
1532
- }
1533
- function rowToProvisioningLog(row) {
1534
- return {
1535
- id: row.id,
1536
- workspaceId: row.workspace_id,
1537
- subsystem: row.subsystem,
1538
- operation: row.operation,
1539
- targetId: row.target_id,
1540
- providerId: row.provider_id,
1541
- blockId: row.block_id,
1542
- executionId: row.execution_id,
1543
- outcome: row.outcome,
1544
- error: row.error,
1545
- detail: row.detail,
1546
- createdAt: row.created_at,
1547
- };
1548
- }
1549
- /** Drizzle/Postgres provisioning-log sink, in its own `provisioning` schema. */
1550
- class DrizzleProvisioningLogRepository {
1551
- db;
1552
- constructor(db) {
1553
- this.db = db;
1554
- }
1555
- async append(record) {
1556
- await this.db.insert(provisioningLog).values({
1557
- id: record.id,
1558
- workspace_id: record.workspaceId,
1559
- subsystem: record.subsystem,
1560
- operation: record.operation,
1561
- target_id: record.targetId,
1562
- provider_id: record.providerId,
1563
- block_id: record.blockId,
1564
- execution_id: record.executionId,
1565
- outcome: record.outcome,
1566
- error: record.error,
1567
- detail: record.detail,
1568
- created_at: record.createdAt,
1569
- });
1570
- }
1571
- async list(workspaceId, query = {}) {
1572
- const conditions = [eq(provisioningLog.workspace_id, workspaceId)];
1573
- if (query.subsystem)
1574
- conditions.push(eq(provisioningLog.subsystem, query.subsystem));
1575
- if (query.executionId)
1576
- conditions.push(eq(provisioningLog.execution_id, query.executionId));
1577
- if (query.targetId)
1578
- conditions.push(eq(provisioningLog.target_id, query.targetId));
1579
- if (query.before != null)
1580
- conditions.push(lt(provisioningLog.created_at, query.before));
1581
- const base = this.db
1582
- .select()
1583
- .from(provisioningLog)
1584
- .where(and(...conditions))
1585
- .orderBy(desc(provisioningLog.created_at), desc(provisioningLog.id));
1586
- const rows = await (query.limit == null ? base : base.limit(query.limit));
1587
- return rows.map(rowToProvisioningLog);
1588
- }
1589
- async deleteOlderThan(epochMs) {
1590
- const deleted = await this.db
1591
- .delete(provisioningLog)
1592
- .where(lt(provisioningLog.created_at, epochMs))
1593
- .returning({ id: provisioningLog.id });
1594
- return deleted.length;
1595
- }
1596
- }
1597
- function rowToModelPreset(row) {
1598
- let overrides = {};
1599
- try {
1600
- const parsed = JSON.parse(row.overrides);
1601
- if (parsed && typeof parsed === 'object')
1602
- overrides = parsed;
1603
- }
1604
- catch {
1605
- // A malformed JSON column degrades to no overrides (base model applies to all).
1606
- }
1607
- return {
1608
- id: row.id,
1609
- name: row.name,
1610
- baseModelId: row.base_model_id,
1611
- overrides,
1612
- isDefault: row.is_default === 1,
1613
- ...(row.version != null ? { version: row.version } : {}),
1614
- createdAt: row.created_at,
1615
- };
1616
- }
1617
- /**
1618
- * Per-workspace model presets over Postgres (the Drizzle mirror of the Worker's
1619
- * `D1ModelPresetRepository`, migration 0006). A preset is one `base_model_id` applied
1620
- * to every agent kind plus per-kind `overrides` (a JSON column). Enforces the
1621
- * single-default invariant: promoting a preset to default demotes every other in the
1622
- * workspace before the upsert. The default preset cannot be removed. Behaviourally
1623
- * identical to the D1 repo so the cross-runtime conformance suite asserts the same
1624
- * preset resolution.
1625
- */
1626
- class DrizzleModelPresetRepository {
1627
- db;
1628
- constructor(db) {
1629
- this.db = db;
1630
- }
1631
- async get(workspaceId, id) {
1632
- const rows = await this.db
1633
- .select()
1634
- .from(modelPresets)
1635
- .where(and(eq(modelPresets.workspace_id, workspaceId), eq(modelPresets.id, id)))
1636
- .limit(1);
1637
- return rows[0] ? rowToModelPreset(rows[0]) : null;
1638
- }
1639
- async list(workspaceId) {
1640
- const rows = await this.db
1641
- .select()
1642
- .from(modelPresets)
1643
- .where(eq(modelPresets.workspace_id, workspaceId))
1644
- .orderBy(modelPresets.created_at);
1645
- return rows.map(rowToModelPreset);
1646
- }
1647
- async getDefault(workspaceId) {
1648
- const rows = await this.db
1649
- .select()
1650
- .from(modelPresets)
1651
- .where(and(eq(modelPresets.workspace_id, workspaceId), eq(modelPresets.is_default, 1)))
1652
- .orderBy(modelPresets.created_at)
1653
- .limit(1);
1654
- return rows[0] ? rowToModelPreset(rows[0]) : null;
1655
- }
1656
- async upsert(workspaceId, preset) {
1657
- const values = {
1658
- workspace_id: workspaceId,
1659
- id: preset.id,
1660
- name: preset.name,
1661
- base_model_id: preset.baseModelId,
1662
- overrides: JSON.stringify(preset.overrides),
1663
- is_default: preset.isDefault ? 1 : 0,
1664
- version: preset.version ?? null,
1665
- created_at: preset.createdAt,
1666
- };
1667
- // Demote + upsert run in one transaction so the single-default invariant can never
1668
- // be observed broken (zero or two defaults) by a concurrent reader or partial failure.
1669
- await this.db.transaction(async (tx) => {
1670
- if (preset.isDefault) {
1671
- await tx
1672
- .update(modelPresets)
1673
- .set({ is_default: 0 })
1674
- .where(and(eq(modelPresets.workspace_id, workspaceId), sql `${modelPresets.id} <> ${preset.id}`));
1675
- }
1676
- await tx
1677
- .insert(modelPresets)
1678
- .values(values)
1679
- .onConflictDoUpdate({
1680
- target: [modelPresets.workspace_id, modelPresets.id],
1681
- set: {
1682
- name: values.name,
1683
- base_model_id: values.base_model_id,
1684
- overrides: values.overrides,
1685
- is_default: values.is_default,
1686
- version: values.version,
1687
- },
1688
- });
1689
- });
1690
- }
1691
- async remove(workspaceId, id) {
1692
- await this.db
1693
- .delete(modelPresets)
1694
- .where(and(eq(modelPresets.workspace_id, workspaceId), eq(modelPresets.id, id), eq(modelPresets.is_default, 0)));
1695
- }
1696
- }
1697
- /**
1698
- * A workspace's default service-fragment selection — one row per workspace in
1699
- * `workspace_fragment_defaults`, the fragment ids stored as a JSON array (mirror of the
1700
- * D1 `D1ServiceFragmentDefaultsRepository`). `set` upserts the whole list.
1701
- */
1702
- class DrizzleServiceFragmentDefaultsRepository {
1703
- db;
1704
- constructor(db) {
1705
- this.db = db;
1706
- }
1707
- async get(workspaceId) {
1708
- const [row] = await this.db
1709
- .select({ fragmentIds: workspaceFragmentDefaults.fragment_ids })
1710
- .from(workspaceFragmentDefaults)
1711
- .where(eq(workspaceFragmentDefaults.workspace_id, workspaceId));
1712
- return row ? JSON.parse(row.fragmentIds) : [];
1713
- }
1714
- async set(workspaceId, fragmentIds) {
1715
- await this.db
1716
- .insert(workspaceFragmentDefaults)
1717
- .values({
1718
- workspace_id: workspaceId,
1719
- fragment_ids: JSON.stringify(fragmentIds),
1720
- updated_at: Date.now(),
1721
- })
1722
- .onConflictDoUpdate({
1723
- target: workspaceFragmentDefaults.workspace_id,
1724
- set: { fragment_ids: JSON.stringify(fragmentIds), updated_at: Date.now() },
1725
- });
1726
- }
1727
- }
1728
- function rowToSchedule(row) {
1729
- const recurrence = {
1730
- intervalHours: row.interval_hours,
1731
- weekdays: safeWeekdays(row.weekdays),
1732
- windowStartHour: row.window_start_hour,
1733
- windowEndHour: row.window_end_hour,
1734
- timezone: row.timezone,
1735
- };
1736
- const issueIntake = parseIssueIntakeColumn(row.issue_intake);
1737
- return {
1738
- id: row.id,
1739
- serviceId: row.service_id,
1740
- blockId: row.block_id,
1741
- frameId: row.frame_id,
1742
- pipelineId: row.pipeline_id,
1743
- template: row.template,
1744
- name: row.name,
1745
- recurrence,
1746
- onDemand: row.on_demand === 1,
1747
- ...(issueIntake ? { issueIntake } : {}),
1748
- enabled: row.enabled === 1,
1749
- lastRunAt: row.last_run_at,
1750
- nextRunAt: row.next_run_at,
1751
- createdAt: row.created_at,
1752
- };
1753
- }
1754
- function safeWeekdays(value) {
1755
- try {
1756
- const parsed = JSON.parse(value);
1757
- return Array.isArray(parsed) ? parsed.filter((n) => typeof n === 'number') : [];
1758
- }
1759
- catch {
1760
- return [];
1761
- }
1762
- }
1763
- function rowToRun(row) {
1764
- return {
1765
- id: row.id,
1766
- scheduleId: row.schedule_id,
1767
- executionId: row.execution_id,
1768
- status: row.status,
1769
- startedAt: row.started_at,
1770
- finishedAt: row.finished_at,
1771
- outcome: row.outcome,
1772
- };
1773
- }
1774
- class DrizzlePipelineScheduleRepository {
1775
- db;
1776
- constructor(db) {
1777
- this.db = db;
1778
- }
1779
- values(workspaceId, schedule) {
1780
- const r = schedule.recurrence;
1781
- return {
1782
- workspace_id: workspaceId,
1783
- id: schedule.id,
1784
- service_id: schedule.serviceId,
1785
- block_id: schedule.blockId,
1786
- frame_id: schedule.frameId,
1787
- pipeline_id: schedule.pipelineId,
1788
- template: schedule.template,
1789
- name: schedule.name,
1790
- interval_hours: r.intervalHours,
1791
- weekdays: JSON.stringify(r.weekdays),
1792
- window_start_hour: r.windowStartHour,
1793
- window_end_hour: r.windowEndHour,
1794
- timezone: r.timezone,
1795
- enabled: schedule.enabled ? 1 : 0,
1796
- on_demand: schedule.onDemand ? 1 : 0,
1797
- issue_intake: serializeIssueIntakeColumn(schedule.issueIntake),
1798
- last_run_at: schedule.lastRunAt,
1799
- next_run_at: schedule.nextRunAt,
1800
- created_at: schedule.createdAt,
1801
- };
1802
- }
1803
- async get(workspaceId, id) {
1804
- const [row] = await this.db
1805
- .select()
1806
- .from(pipelineSchedules)
1807
- .where(and(eq(pipelineSchedules.workspace_id, workspaceId), eq(pipelineSchedules.id, id)));
1808
- return row ? rowToSchedule(row) : null;
1809
- }
1810
- async getByBlock(workspaceId, blockId) {
1811
- const [row] = await this.db
1812
- .select()
1813
- .from(pipelineSchedules)
1814
- .where(and(eq(pipelineSchedules.workspace_id, workspaceId), eq(pipelineSchedules.block_id, blockId)));
1815
- return row ? rowToSchedule(row) : null;
1816
- }
1817
- async list(workspaceId) {
1818
- const rows = await this.db
1819
- .select()
1820
- .from(pipelineSchedules)
1821
- .where(eq(pipelineSchedules.workspace_id, workspaceId))
1822
- .orderBy(pipelineSchedules.created_at);
1823
- return rows.map(rowToSchedule);
1824
- }
1825
- async listByService(serviceId) {
1826
- const rows = await this.db
1827
- .select()
1828
- .from(pipelineSchedules)
1829
- .where(eq(pipelineSchedules.service_id, serviceId))
1830
- .orderBy(pipelineSchedules.created_at);
1831
- return rows.map(rowToSchedule);
1832
- }
1833
- async listByServices(serviceIds) {
1834
- if (serviceIds.length === 0)
1835
- return [];
1836
- const out = [];
1837
- // Chunk the IN list to stay well under the bind-parameter limit.
1838
- for (let i = 0; i < serviceIds.length; i += 500) {
1839
- const rows = await this.db
1840
- .select()
1841
- .from(pipelineSchedules)
1842
- .where(inArray(pipelineSchedules.service_id, serviceIds.slice(i, i + 500)))
1843
- .orderBy(pipelineSchedules.created_at);
1844
- for (const row of rows)
1845
- out.push(rowToSchedule(row));
1846
- }
1847
- return out;
1848
- }
1849
- async listDue(asOf) {
1850
- const rows = await this.db
1851
- .select()
1852
- .from(pipelineSchedules)
1853
- .where(and(eq(pipelineSchedules.enabled, 1), eq(pipelineSchedules.on_demand, 0), lt(pipelineSchedules.next_run_at, asOf + 1)))
1854
- .orderBy(pipelineSchedules.next_run_at);
1855
- return rows.map((row) => ({ workspaceId: row.workspace_id, schedule: rowToSchedule(row) }));
1856
- }
1857
- async upsert(workspaceId, schedule) {
1858
- const values = this.values(workspaceId, schedule);
1859
- await this.db
1860
- .insert(pipelineSchedules)
1861
- .values(values)
1862
- .onConflictDoUpdate({
1863
- target: [pipelineSchedules.workspace_id, pipelineSchedules.id],
1864
- set: {
1865
- service_id: values.service_id,
1866
- block_id: values.block_id,
1867
- frame_id: values.frame_id,
1868
- pipeline_id: values.pipeline_id,
1869
- template: values.template,
1870
- name: values.name,
1871
- interval_hours: values.interval_hours,
1872
- weekdays: values.weekdays,
1873
- window_start_hour: values.window_start_hour,
1874
- window_end_hour: values.window_end_hour,
1875
- timezone: values.timezone,
1876
- enabled: values.enabled,
1877
- on_demand: values.on_demand,
1878
- issue_intake: values.issue_intake,
1879
- last_run_at: values.last_run_at,
1880
- next_run_at: values.next_run_at,
1881
- },
1882
- });
1883
- }
1884
- async remove(workspaceId, id) {
1885
- await this.db
1886
- .delete(pipelineSchedules)
1887
- .where(and(eq(pipelineSchedules.workspace_id, workspaceId), eq(pipelineSchedules.id, id)));
1888
- }
1889
- async insertRun(workspaceId, run) {
1890
- await this.db.insert(pipelineScheduleRuns).values({
1891
- workspace_id: workspaceId,
1892
- id: run.id,
1893
- schedule_id: run.scheduleId,
1894
- execution_id: run.executionId,
1895
- status: run.status,
1896
- started_at: run.startedAt,
1897
- finished_at: run.finishedAt,
1898
- outcome: run.outcome,
1899
- });
1900
- }
1901
- async updateRun(workspaceId, runId, patch) {
1902
- const set = {};
1903
- if (patch.status !== undefined)
1904
- set.status = patch.status;
1905
- if (patch.finishedAt !== undefined)
1906
- set.finished_at = patch.finishedAt;
1907
- if (patch.outcome !== undefined)
1908
- set.outcome = patch.outcome;
1909
- if (patch.executionId !== undefined)
1910
- set.execution_id = patch.executionId;
1911
- if (Object.keys(set).length === 0)
1912
- return;
1913
- await this.db
1914
- .update(pipelineScheduleRuns)
1915
- .set(set)
1916
- .where(and(eq(pipelineScheduleRuns.workspace_id, workspaceId), eq(pipelineScheduleRuns.id, runId)));
1917
- }
1918
- async listRuns(workspaceId, scheduleId) {
1919
- const rows = await this.db
1920
- .select()
1921
- .from(pipelineScheduleRuns)
1922
- .where(and(eq(pipelineScheduleRuns.workspace_id, workspaceId), eq(pipelineScheduleRuns.schedule_id, scheduleId)))
1923
- .orderBy(desc(pipelineScheduleRuns.started_at));
1924
- return rows.map(rowToRun);
1925
- }
1926
- async pruneRunsBefore(before) {
1927
- const rows = await this.db
1928
- .delete(pipelineScheduleRuns)
1929
- .where(lt(pipelineScheduleRuns.started_at, before))
1930
- .returning({ id: pipelineScheduleRuns.id });
1931
- return rows.length;
1932
- }
1933
- }
1934
- class DrizzleTrackerSettingsRepository {
1935
- db;
1936
- constructor(db) {
1937
- this.db = db;
1938
- }
1939
- async get(workspaceId) {
1940
- const [row] = await this.db
1941
- .select()
1942
- .from(trackerSettings)
1943
- .where(eq(trackerSettings.workspace_id, workspaceId));
1944
- if (!row)
1945
- return null;
1946
- return {
1947
- tracker: row.tracker ?? null,
1948
- jiraProjectKey: row.jira_project_key,
1949
- linearTeamId: row.linear_team_id,
1950
- writebackCommentOnPrOpen: row.writeback_comment_on_pr_open === 1,
1951
- writebackResolveOnMerge: row.writeback_resolve_on_merge === 1,
1952
- updatedAt: row.updated_at,
1953
- };
1954
- }
1955
- async put(workspaceId, settings) {
1956
- await this.db
1957
- .insert(trackerSettings)
1958
- .values({
1959
- workspace_id: workspaceId,
1960
- tracker: settings.tracker,
1961
- jira_project_key: settings.jiraProjectKey,
1962
- linear_team_id: settings.linearTeamId,
1963
- writeback_comment_on_pr_open: settings.writebackCommentOnPrOpen ? 1 : 0,
1964
- writeback_resolve_on_merge: settings.writebackResolveOnMerge ? 1 : 0,
1965
- updated_at: settings.updatedAt,
1966
- })
1967
- .onConflictDoUpdate({
1968
- target: trackerSettings.workspace_id,
1969
- set: {
1970
- tracker: settings.tracker,
1971
- jira_project_key: settings.jiraProjectKey,
1972
- linear_team_id: settings.linearTeamId,
1973
- writeback_comment_on_pr_open: settings.writebackCommentOnPrOpen ? 1 : 0,
1974
- writeback_resolve_on_merge: settings.writebackResolveOnMerge ? 1 : 0,
1975
- updated_at: settings.updatedAt,
1976
- },
1977
- });
1978
- }
1979
- }
1980
- function rowToService(row) {
1981
- return {
1982
- id: row.id,
1983
- accountId: row.account_id,
1984
- frameBlockId: row.frame_block_id,
1985
- installationId: row.installation_id,
1986
- repoGithubId: row.repo_github_id,
1987
- directory: row.directory,
1988
- createdAt: row.created_at,
1989
- };
1990
- }
1991
- /** Account-owned services (migration 0030). The canonical, shareable board unit. */
1992
- export class DrizzleServiceRepository {
1993
- db;
1994
- constructor(db) {
1995
- this.db = db;
1996
- }
1997
- async get(id) {
1998
- const [row] = await this.db.select().from(services).where(eq(services.id, id));
1999
- return row ? rowToService(row) : null;
2000
- }
2001
- async getByFrameBlock(frameBlockId) {
2002
- const [row] = await this.db
2003
- .select()
2004
- .from(services)
2005
- .where(eq(services.frame_block_id, frameBlockId));
2006
- return row ? rowToService(row) : null;
2007
- }
2008
- async listByFrameBlocks(frameBlockIds) {
2009
- if (frameBlockIds.length === 0)
2010
- return [];
2011
- const out = [];
2012
- // Chunk the IN list to stay well under the bind-parameter limit.
2013
- for (let i = 0; i < frameBlockIds.length; i += 500) {
2014
- const rows = await this.db
2015
- .select()
2016
- .from(services)
2017
- .where(inArray(services.frame_block_id, frameBlockIds.slice(i, i + 500)));
2018
- for (const row of rows)
2019
- out.push(rowToService(row));
2020
- }
2021
- return out;
2022
- }
2023
- async listByAccount(accountId) {
2024
- // NULL-safe match so the legacy/unscoped org (accountId null) lists cleanly.
2025
- const rows = await this.db
2026
- .select()
2027
- .from(services)
2028
- .where(sql `${services.account_id} IS NOT DISTINCT FROM ${accountId}`)
2029
- .orderBy(services.created_at);
2030
- return rows.map(rowToService);
2031
- }
2032
- async listByIds(ids) {
2033
- if (ids.length === 0)
2034
- return [];
2035
- const out = [];
2036
- // Chunk the IN list to stay well under the bind-parameter limit.
2037
- for (let i = 0; i < ids.length; i += 500) {
2038
- const rows = await this.db
2039
- .select()
2040
- .from(services)
2041
- .where(inArray(services.id, ids.slice(i, i + 500)));
2042
- for (const row of rows)
2043
- out.push(rowToService(row));
2044
- }
2045
- return out;
2046
- }
2047
- async getByRepo(installationId, repoGithubId) {
2048
- const [row] = await this.db
2049
- .select()
2050
- .from(services)
2051
- .where(and(eq(services.installation_id, installationId), eq(services.repo_github_id, repoGithubId)));
2052
- return row ? rowToService(row) : null;
2053
- }
2054
- async insert(service) {
2055
- await this.db.insert(services).values({
2056
- id: service.id,
2057
- account_id: service.accountId,
2058
- frame_block_id: service.frameBlockId,
2059
- installation_id: service.installationId,
2060
- repo_github_id: service.repoGithubId,
2061
- directory: service.directory ?? null,
2062
- created_at: service.createdAt,
2063
- });
2064
- }
2065
- async update(id, patch) {
2066
- const set = {};
2067
- if ('accountId' in patch)
2068
- set.account_id = patch.accountId ?? null;
2069
- if ('installationId' in patch)
2070
- set.installation_id = patch.installationId ?? null;
2071
- if ('repoGithubId' in patch)
2072
- set.repo_github_id = patch.repoGithubId ?? null;
2073
- if ('directory' in patch)
2074
- set.directory = patch.directory ?? null;
2075
- if (Object.keys(set).length === 0)
2076
- return;
2077
- await this.db.update(services).set(set).where(eq(services.id, id));
2078
- }
2079
- async delete(id) {
2080
- await this.db.delete(services).where(eq(services.id, id));
2081
- }
2082
- async deleteMany(ids) {
2083
- if (ids.length === 0)
2084
- return;
2085
- // Chunk the IN list to stay well under the bind-parameter limit.
2086
- for (let i = 0; i < ids.length; i += 500) {
2087
- await this.db.delete(services).where(inArray(services.id, ids.slice(i, i + 500)));
2088
- }
2089
- }
2090
- }
2091
- function rowToMount(row) {
2092
- return {
2093
- workspaceId: row.workspace_id,
2094
- serviceId: row.service_id,
2095
- position: { x: row.pos_x, y: row.pos_y },
2096
- size: row.width !== null && row.height !== null ? { w: row.width, h: row.height } : null,
2097
- createdAt: row.created_at,
2098
- };
2099
- }
2100
- /** A service mounted onto a workspace board + its per-workspace layout (migration 0030). */
2101
- class DrizzleWorkspaceMountRepository {
2102
- db;
2103
- constructor(db) {
2104
- this.db = db;
2105
- }
2106
- async listByWorkspace(workspaceId) {
2107
- const rows = await this.db
2108
- .select()
2109
- .from(workspaceServices)
2110
- .where(eq(workspaceServices.workspace_id, workspaceId))
2111
- .orderBy(workspaceServices.created_at);
2112
- return rows.map(rowToMount);
2113
- }
2114
- async listByService(serviceId) {
2115
- const rows = await this.db
2116
- .select()
2117
- .from(workspaceServices)
2118
- .where(eq(workspaceServices.service_id, serviceId))
2119
- .orderBy(workspaceServices.created_at);
2120
- return rows.map(rowToMount);
2121
- }
2122
- async listByServiceIds(serviceIds) {
2123
- if (serviceIds.length === 0)
2124
- return [];
2125
- const rows = await this.db
2126
- .select()
2127
- .from(workspaceServices)
2128
- .where(inArray(workspaceServices.service_id, serviceIds))
2129
- .orderBy(workspaceServices.created_at);
2130
- return rows.map(rowToMount);
2131
- }
2132
- async listWorkspaceIdsMountingBlock(originWorkspaceId, blockId) {
2133
- // One join: the service owning the block → the workspaces that mount it. A block with no
2134
- // service makes the subquery NULL, which matches no rows (`service_id = NULL`) → empty.
2135
- const rows = await this.db
2136
- .select({ workspaceId: workspaceServices.workspace_id })
2137
- .from(workspaceServices)
2138
- .where(sql `${workspaceServices.service_id} = (SELECT ${blocks.service_id} FROM ${blocks} WHERE ${blocks.workspace_id} = ${originWorkspaceId} AND ${blocks.id} = ${blockId})`);
2139
- return rows.map((r) => r.workspaceId);
2140
- }
2141
- async countByServiceIds(serviceIds) {
2142
- if (serviceIds.length === 0)
2143
- return {};
2144
- const rows = await this.db
2145
- .select({ serviceId: workspaceServices.service_id, n: sql `count(*)` })
2146
- .from(workspaceServices)
2147
- .where(inArray(workspaceServices.service_id, serviceIds))
2148
- .groupBy(workspaceServices.service_id);
2149
- const counts = {};
2150
- for (const row of rows)
2151
- counts[row.serviceId] = Number(row.n);
2152
- return counts;
2153
- }
2154
- async get(workspaceId, serviceId) {
2155
- const [row] = await this.db
2156
- .select()
2157
- .from(workspaceServices)
2158
- .where(and(eq(workspaceServices.workspace_id, workspaceId), eq(workspaceServices.service_id, serviceId)));
2159
- return row ? rowToMount(row) : null;
2160
- }
2161
- async upsert(mount) {
2162
- await this.db
2163
- .insert(workspaceServices)
2164
- .values({
2165
- workspace_id: mount.workspaceId,
2166
- service_id: mount.serviceId,
2167
- pos_x: mount.position.x,
2168
- pos_y: mount.position.y,
2169
- width: mount.size?.w ?? null,
2170
- height: mount.size?.h ?? null,
2171
- created_at: mount.createdAt,
2172
- })
2173
- .onConflictDoUpdate({
2174
- target: [workspaceServices.workspace_id, workspaceServices.service_id],
2175
- set: {
2176
- pos_x: mount.position.x,
2177
- pos_y: mount.position.y,
2178
- width: mount.size?.w ?? null,
2179
- height: mount.size?.h ?? null,
2180
- },
2181
- });
2182
- }
2183
- async update(workspaceId, serviceId, patch) {
2184
- const set = {};
2185
- if (patch.position) {
2186
- set.pos_x = patch.position.x;
2187
- set.pos_y = patch.position.y;
2188
- }
2189
- if ('size' in patch) {
2190
- set.width = patch.size?.w ?? null;
2191
- set.height = patch.size?.h ?? null;
2192
- }
2193
- if (Object.keys(set).length === 0)
2194
- return;
2195
- await this.db
2196
- .update(workspaceServices)
2197
- .set(set)
2198
- .where(and(eq(workspaceServices.workspace_id, workspaceId), eq(workspaceServices.service_id, serviceId)));
2199
- }
2200
- async remove(workspaceId, serviceId) {
2201
- await this.db
2202
- .delete(workspaceServices)
2203
- .where(and(eq(workspaceServices.workspace_id, workspaceId), eq(workspaceServices.service_id, serviceId)));
2204
- }
2205
- async removeByServices(serviceIds) {
2206
- if (serviceIds.length === 0)
2207
- return;
2208
- // Chunk the IN list to stay well under the bind-parameter limit.
2209
- for (let i = 0; i < serviceIds.length; i += 500) {
2210
- await this.db
2211
- .delete(workspaceServices)
2212
- .where(inArray(workspaceServices.service_id, serviceIds.slice(i, i + 500)));
2213
- }
2214
- }
2215
- }
2216
- function rowToRequirementReview(row) {
2217
- return {
2218
- id: row.id,
2219
- blockId: row.block_id,
2220
- status: row.status,
2221
- items: parseJsonArray(row.items),
2222
- model: row.model,
2223
- incorporatedRequirements: row.incorporated_requirements,
2224
- iteration: row.iteration,
2225
- maxIterations: row.max_iterations,
2226
- recommendations: parseJsonArray(row.recommendations ?? '[]'),
2227
- createdAt: row.created_at,
2228
- updatedAt: row.updated_at,
2229
- };
2230
- }
2231
- /**
2232
- * Requirements reviews over Postgres (the Drizzle mirror of the Worker's
2233
- * `D1RequirementReviewRepository`, migration 0021). The reviewed items live as a JSON
2234
- * array in `items`; the service keeps at most one live review per block (it deletes
2235
- * the block's prior review before inserting a fresh one), so `getByBlock` returns the
2236
- * latest. Behaviourally identical to the D1 repo so the cross-runtime conformance
2237
- * suite asserts the same requirements-rework substitution against both stores.
2238
- */
2239
- export class DrizzleRequirementReviewRepository {
2240
- db;
2241
- constructor(db) {
2242
- this.db = db;
2243
- }
2244
- async getByBlock(workspaceId, blockId) {
2245
- const rows = await this.db
2246
- .select()
2247
- .from(requirementReviews)
2248
- .where(and(eq(requirementReviews.workspace_id, workspaceId), eq(requirementReviews.block_id, blockId)))
2249
- .orderBy(desc(requirementReviews.created_at))
2250
- .limit(1);
2251
- return rows[0] ? rowToRequirementReview(rows[0]) : null;
2252
- }
2253
- async get(workspaceId, id) {
2254
- const rows = await this.db
2255
- .select()
2256
- .from(requirementReviews)
2257
- .where(and(eq(requirementReviews.workspace_id, workspaceId), eq(requirementReviews.id, id)))
2258
- .limit(1);
2259
- return rows[0] ? rowToRequirementReview(rows[0]) : null;
2260
- }
2261
- async upsert(workspaceId, review) {
2262
- const values = {
2263
- workspace_id: workspaceId,
2264
- id: review.id,
2265
- block_id: review.blockId,
2266
- status: review.status,
2267
- items: JSON.stringify(review.items),
2268
- model: review.model,
2269
- incorporated_requirements: review.incorporatedRequirements,
2270
- iteration: review.iteration ?? 1,
2271
- max_iterations: review.maxIterations ?? 1,
2272
- recommendations: JSON.stringify(review.recommendations ?? []),
2273
- created_at: review.createdAt,
2274
- updated_at: review.updatedAt,
2275
- };
2276
- await this.db
2277
- .insert(requirementReviews)
2278
- .values(values)
2279
- .onConflictDoUpdate({
2280
- target: [requirementReviews.workspace_id, requirementReviews.id],
2281
- set: {
2282
- block_id: values.block_id,
2283
- status: values.status,
2284
- items: values.items,
2285
- model: values.model,
2286
- incorporated_requirements: values.incorporated_requirements,
2287
- iteration: values.iteration,
2288
- max_iterations: values.max_iterations,
2289
- recommendations: values.recommendations,
2290
- updated_at: values.updated_at,
2291
- },
2292
- });
2293
- }
2294
- async deleteByBlock(workspaceId, blockId) {
2295
- await this.db
2296
- .delete(requirementReviews)
2297
- .where(and(eq(requirementReviews.workspace_id, workspaceId), eq(requirementReviews.block_id, blockId)));
2298
- }
2299
- }
2300
- function rowToDocInterviewSession(row) {
2301
- return {
2302
- id: row.id,
2303
- blockId: row.block_id,
2304
- status: row.status,
2305
- round: row.round,
2306
- maxRounds: row.max_rounds,
2307
- qa: parseJsonArray(row.qa),
2308
- brief: row.brief,
2309
- model: row.model,
2310
- createdAt: row.created_at,
2311
- updatedAt: row.updated_at,
2312
- };
2313
- }
2314
- /**
2315
- * Interactive document-interview sessions over Postgres (the Drizzle mirror of the Worker's
2316
- * `D1DocInterviewRepository`, migration 0040). The Q&A live as a JSON array in `qa`; the service
2317
- * keeps at most one live session per block, so `getByBlock` returns the latest. Behaviourally
2318
- * identical to the D1 repo so the cross-runtime conformance suite asserts the same interview
2319
- * brief substitution against both stores.
2320
- */
2321
- export class DrizzleDocInterviewRepository {
2322
- db;
2323
- constructor(db) {
2324
- this.db = db;
2325
- }
2326
- async getByBlock(workspaceId, blockId) {
2327
- const rows = await this.db
2328
- .select()
2329
- .from(docInterviewSessions)
2330
- .where(and(eq(docInterviewSessions.workspace_id, workspaceId), eq(docInterviewSessions.block_id, blockId)))
2331
- .orderBy(desc(docInterviewSessions.created_at))
2332
- .limit(1);
2333
- return rows[0] ? rowToDocInterviewSession(rows[0]) : null;
2334
- }
2335
- async get(workspaceId, id) {
2336
- const rows = await this.db
2337
- .select()
2338
- .from(docInterviewSessions)
2339
- .where(and(eq(docInterviewSessions.workspace_id, workspaceId), eq(docInterviewSessions.id, id)))
2340
- .limit(1);
2341
- return rows[0] ? rowToDocInterviewSession(rows[0]) : null;
2342
- }
2343
- async upsert(workspaceId, session) {
2344
- const values = {
2345
- workspace_id: workspaceId,
2346
- id: session.id,
2347
- block_id: session.blockId,
2348
- status: session.status,
2349
- round: session.round,
2350
- max_rounds: session.maxRounds,
2351
- qa: JSON.stringify(session.qa ?? []),
2352
- brief: session.brief,
2353
- model: session.model,
2354
- created_at: session.createdAt,
2355
- updated_at: session.updatedAt,
2356
- };
2357
- await this.db
2358
- .insert(docInterviewSessions)
2359
- .values(values)
2360
- .onConflictDoUpdate({
2361
- target: [docInterviewSessions.workspace_id, docInterviewSessions.id],
2362
- set: {
2363
- block_id: values.block_id,
2364
- status: values.status,
2365
- round: values.round,
2366
- max_rounds: values.max_rounds,
2367
- qa: values.qa,
2368
- brief: values.brief,
2369
- model: values.model,
2370
- updated_at: values.updated_at,
2371
- },
2372
- });
2373
- }
2374
- async deleteByBlock(workspaceId, blockId) {
2375
- await this.db
2376
- .delete(docInterviewSessions)
2377
- .where(and(eq(docInterviewSessions.workspace_id, workspaceId), eq(docInterviewSessions.block_id, blockId)));
2378
- }
2379
- }
2380
- function rowToKaizenGrading(row) {
2381
- return {
2382
- id: row.id,
2383
- executionId: row.execution_id,
2384
- blockId: row.block_id,
2385
- stepIndex: row.step_index,
2386
- agentKind: row.agent_kind,
2387
- model: row.model,
2388
- promptVersion: row.prompt_version,
2389
- comboKey: row.combo_key,
2390
- status: row.status,
2391
- grade: row.grade,
2392
- summary: row.summary,
2393
- recommendations: parseJsonArray(row.recommendations ?? '[]'),
2394
- graderModel: row.grader_model,
2395
- error: row.error,
2396
- createdAt: row.created_at,
2397
- updatedAt: row.updated_at,
2398
- };
2399
- }
2400
- /**
2401
- * Kaizen gradings over Postgres (the Drizzle mirror of the Worker's
2402
- * `D1KaizenGradingRepository`, migration 0015). One row per (run, step); recommendations
2403
- * are a JSON array. The unique (execution_id, step_index) index keeps scheduling
2404
- * idempotent across durable re-drives.
2405
- */
2406
- class DrizzleKaizenGradingRepository {
2407
- db;
2408
- constructor(db) {
2409
- this.db = db;
2410
- }
2411
- async upsert(workspaceId, grading) {
2412
- const values = {
2413
- workspace_id: workspaceId,
2414
- id: grading.id,
2415
- execution_id: grading.executionId,
2416
- block_id: grading.blockId,
2417
- step_index: grading.stepIndex,
2418
- agent_kind: grading.agentKind,
2419
- model: grading.model,
2420
- prompt_version: grading.promptVersion,
2421
- combo_key: grading.comboKey,
2422
- status: grading.status,
2423
- grade: grading.grade,
2424
- summary: grading.summary,
2425
- recommendations: JSON.stringify(grading.recommendations),
2426
- grader_model: grading.graderModel,
2427
- error: grading.error,
2428
- created_at: grading.createdAt,
2429
- updated_at: grading.updatedAt,
2430
- };
2431
- await this.db
2432
- .insert(kaizenGradings)
2433
- .values(values)
2434
- .onConflictDoUpdate({
2435
- target: [kaizenGradings.workspace_id, kaizenGradings.id],
2436
- set: {
2437
- status: values.status,
2438
- grade: values.grade,
2439
- summary: values.summary,
2440
- recommendations: values.recommendations,
2441
- grader_model: values.grader_model,
2442
- error: values.error,
2443
- updated_at: values.updated_at,
2444
- },
2445
- });
2446
- }
2447
- async get(workspaceId, id) {
2448
- const rows = await this.db
2449
- .select()
2450
- .from(kaizenGradings)
2451
- .where(and(eq(kaizenGradings.workspace_id, workspaceId), eq(kaizenGradings.id, id)))
2452
- .limit(1);
2453
- return rows[0] ? rowToKaizenGrading(rows[0]) : null;
2454
- }
2455
- async getByStep(workspaceId, executionId, stepIndex) {
2456
- const rows = await this.db
2457
- .select()
2458
- .from(kaizenGradings)
2459
- .where(and(eq(kaizenGradings.workspace_id, workspaceId), eq(kaizenGradings.execution_id, executionId), eq(kaizenGradings.step_index, stepIndex)))
2460
- .limit(1);
2461
- return rows[0] ? rowToKaizenGrading(rows[0]) : null;
2462
- }
2463
- async listByExecution(workspaceId, executionId) {
2464
- const rows = await this.db
2465
- .select()
2466
- .from(kaizenGradings)
2467
- .where(and(eq(kaizenGradings.workspace_id, workspaceId), eq(kaizenGradings.execution_id, executionId)))
2468
- .orderBy(kaizenGradings.step_index);
2469
- return rows.map(rowToKaizenGrading);
2470
- }
2471
- async listByWorkspace(workspaceId, limit = 200) {
2472
- const rows = await this.db
2473
- .select()
2474
- .from(kaizenGradings)
2475
- .where(eq(kaizenGradings.workspace_id, workspaceId))
2476
- .orderBy(desc(kaizenGradings.created_at))
2477
- .limit(limit);
2478
- return rows.map(rowToKaizenGrading);
2479
- }
2480
- async listPending(staleBefore, limit) {
2481
- const rows = await this.db
2482
- .select()
2483
- .from(kaizenGradings)
2484
- .where(or(eq(kaizenGradings.status, 'scheduled'), and(eq(kaizenGradings.status, 'running'), lt(kaizenGradings.updated_at, staleBefore))))
2485
- .orderBy(kaizenGradings.updated_at)
2486
- .limit(limit);
2487
- return rows.map((row) => ({ workspaceId: row.workspace_id, grading: rowToKaizenGrading(row) }));
2488
- }
2489
- async claim(workspaceId, id, staleBefore, now) {
2490
- // Conditional flip to `running`: succeeds only if the row is still claimable (the same
2491
- // predicate listPending selects on), so concurrent sweep passes can't both win it.
2492
- const claimed = await this.db
2493
- .update(kaizenGradings)
2494
- .set({ status: 'running', updated_at: now })
2495
- .where(and(eq(kaizenGradings.workspace_id, workspaceId), eq(kaizenGradings.id, id), or(eq(kaizenGradings.status, 'scheduled'), and(eq(kaizenGradings.status, 'running'), lt(kaizenGradings.updated_at, staleBefore)))))
2496
- .returning({ id: kaizenGradings.id });
2497
- return claimed.length > 0;
2498
- }
2499
- }
2500
- function rowToKaizenVerifiedCombo(row) {
2501
- return {
2502
- comboKey: row.combo_key,
2503
- agentKind: row.agent_kind,
2504
- model: row.model,
2505
- promptVersion: row.prompt_version,
2506
- consecutiveHighGrades: row.consecutive_high_grades,
2507
- verified: row.verified === 1,
2508
- verifiedAt: row.verified_at,
2509
- updatedAt: row.updated_at,
2510
- };
2511
- }
2512
- /**
2513
- * Kaizen verified-combo progress over Postgres (the Drizzle mirror of the Worker's
2514
- * `D1KaizenVerifiedComboRepository`, migration 0015).
2515
- */
2516
- class DrizzleKaizenVerifiedComboRepository {
2517
- db;
2518
- constructor(db) {
2519
- this.db = db;
2520
- }
2521
- async getByKey(workspaceId, comboKey) {
2522
- const rows = await this.db
2523
- .select()
2524
- .from(kaizenVerifiedCombos)
2525
- .where(and(eq(kaizenVerifiedCombos.workspace_id, workspaceId), eq(kaizenVerifiedCombos.combo_key, comboKey)))
2526
- .limit(1);
2527
- return rows[0] ? rowToKaizenVerifiedCombo(rows[0]) : null;
2528
- }
2529
- async upsert(workspaceId, combo) {
2530
- const values = {
2531
- workspace_id: workspaceId,
2532
- combo_key: combo.comboKey,
2533
- agent_kind: combo.agentKind,
2534
- model: combo.model,
2535
- prompt_version: combo.promptVersion,
2536
- consecutive_high_grades: combo.consecutiveHighGrades,
2537
- verified: combo.verified ? 1 : 0,
2538
- verified_at: combo.verifiedAt,
2539
- updated_at: combo.updatedAt,
2540
- };
2541
- await this.db
2542
- .insert(kaizenVerifiedCombos)
2543
- .values(values)
2544
- .onConflictDoUpdate({
2545
- target: [kaizenVerifiedCombos.workspace_id, kaizenVerifiedCombos.combo_key],
2546
- set: {
2547
- consecutive_high_grades: values.consecutive_high_grades,
2548
- verified: values.verified,
2549
- verified_at: values.verified_at,
2550
- updated_at: values.updated_at,
2551
- },
2552
- });
2553
- }
2554
- async listByWorkspace(workspaceId) {
2555
- const rows = await this.db
2556
- .select()
2557
- .from(kaizenVerifiedCombos)
2558
- .where(eq(kaizenVerifiedCombos.workspace_id, workspaceId))
2559
- .orderBy(desc(kaizenVerifiedCombos.updated_at));
2560
- return rows.map(rowToKaizenVerifiedCombo);
2561
- }
2562
- }
2563
- function parseJsonArray(raw) {
2564
- try {
2565
- const parsed = JSON.parse(raw);
2566
- return Array.isArray(parsed) ? parsed : [];
2567
- }
2568
- catch {
2569
- return [];
2570
- }
2571
- }
2572
- function rowToConsensusSession(row) {
2573
- return {
2574
- id: row.id,
2575
- blockId: row.block_id,
2576
- executionId: row.execution_id,
2577
- stepIndex: row.step_index,
2578
- agentKind: row.agent_kind,
2579
- strategy: row.strategy,
2580
- status: row.status,
2581
- participants: parseJsonArray(row.participants),
2582
- rounds: parseJsonArray(row.rounds),
2583
- synthesis: row.synthesis,
2584
- confidence: row.confidence,
2585
- dissent: parseJsonArray(row.dissent),
2586
- error: row.error,
2587
- createdAt: row.created_at,
2588
- updatedAt: row.updated_at,
2589
- };
2590
- }
2591
- function rowToClarityReview(row) {
2592
- let items = [];
2593
- try {
2594
- const parsed = JSON.parse(row.items);
2595
- if (Array.isArray(parsed))
2596
- items = parsed;
2597
- }
2598
- catch {
2599
- items = [];
2600
- }
2601
- return {
2602
- id: row.id,
2603
- blockId: row.block_id,
2604
- status: row.status,
2605
- items,
2606
- model: row.model,
2607
- clarifiedReport: row.clarified_report,
2608
- iteration: row.iteration,
2609
- maxIterations: row.max_iterations,
2610
- createdAt: row.created_at,
2611
- updatedAt: row.updated_at,
2612
- };
2613
- }
2614
- /**
2615
- * Consensus session transcripts (`consensus_sessions`), the Drizzle/Postgres mirror of
2616
- * {@link D1ConsensusSessionRepository}. One row per (execution, step); the
2617
- * participants/rounds/dissent live as JSON columns, upserted as the process streams.
2618
- */
2619
- class DrizzleConsensusSessionRepository {
2620
- db;
2621
- constructor(db) {
2622
- this.db = db;
2623
- }
2624
- async get(workspaceId, id) {
2625
- const rows = await this.db
2626
- .select()
2627
- .from(consensusSessions)
2628
- .where(and(eq(consensusSessions.workspace_id, workspaceId), eq(consensusSessions.id, id)))
2629
- .limit(1);
2630
- return rows[0] ? rowToConsensusSession(rows[0]) : null;
2631
- }
2632
- async getByStep(workspaceId, executionId, stepIndex) {
2633
- const rows = await this.db
2634
- .select()
2635
- .from(consensusSessions)
2636
- .where(and(eq(consensusSessions.workspace_id, workspaceId), eq(consensusSessions.execution_id, executionId), eq(consensusSessions.step_index, stepIndex)))
2637
- .orderBy(desc(consensusSessions.created_at))
2638
- .limit(1);
2639
- return rows[0] ? rowToConsensusSession(rows[0]) : null;
2640
- }
2641
- async getByBlock(workspaceId, blockId) {
2642
- const rows = await this.db
2643
- .select()
2644
- .from(consensusSessions)
2645
- .where(and(eq(consensusSessions.workspace_id, workspaceId), eq(consensusSessions.block_id, blockId)))
2646
- .orderBy(desc(consensusSessions.created_at))
2647
- .limit(1);
2648
- return rows[0] ? rowToConsensusSession(rows[0]) : null;
2649
- }
2650
- async upsert(workspaceId, session) {
2651
- const values = {
2652
- workspace_id: workspaceId,
2653
- id: session.id,
2654
- block_id: session.blockId,
2655
- execution_id: session.executionId,
2656
- step_index: session.stepIndex,
2657
- agent_kind: session.agentKind,
2658
- strategy: session.strategy,
2659
- status: session.status,
2660
- participants: JSON.stringify(session.participants),
2661
- rounds: JSON.stringify(session.rounds),
2662
- synthesis: session.synthesis,
2663
- confidence: session.confidence ?? null,
2664
- dissent: JSON.stringify(session.dissent ?? []),
2665
- error: session.error ?? null,
2666
- created_at: session.createdAt,
2667
- updated_at: session.updatedAt,
2668
- };
2669
- await this.db
2670
- .insert(consensusSessions)
2671
- .values(values)
2672
- .onConflictDoUpdate({
2673
- target: [consensusSessions.workspace_id, consensusSessions.id],
2674
- set: {
2675
- block_id: values.block_id,
2676
- execution_id: values.execution_id,
2677
- step_index: values.step_index,
2678
- agent_kind: values.agent_kind,
2679
- strategy: values.strategy,
2680
- status: values.status,
2681
- participants: values.participants,
2682
- rounds: values.rounds,
2683
- synthesis: values.synthesis,
2684
- confidence: values.confidence,
2685
- dissent: values.dissent,
2686
- error: values.error,
2687
- updated_at: values.updated_at,
2688
- },
2689
- });
2690
- }
2691
- }
2692
- /**
2693
- * Clarity (bug-report triage) reviews over Postgres — the Drizzle mirror of the Worker's
2694
- * `D1ClarityReviewRepository`. Behaviourally identical to the D1 repo so the cross-runtime
2695
- * conformance suite asserts the same clarified-brief substitution against both stores.
2696
- */
2697
- export class DrizzleClarityReviewRepository {
2698
- db;
2699
- constructor(db) {
2700
- this.db = db;
2701
- }
2702
- async getByBlock(workspaceId, blockId) {
2703
- const rows = await this.db
2704
- .select()
2705
- .from(clarityReviews)
2706
- .where(and(eq(clarityReviews.workspace_id, workspaceId), eq(clarityReviews.block_id, blockId)))
2707
- .orderBy(desc(clarityReviews.created_at))
2708
- .limit(1);
2709
- return rows[0] ? rowToClarityReview(rows[0]) : null;
2710
- }
2711
- async get(workspaceId, id) {
2712
- const rows = await this.db
2713
- .select()
2714
- .from(clarityReviews)
2715
- .where(and(eq(clarityReviews.workspace_id, workspaceId), eq(clarityReviews.id, id)))
2716
- .limit(1);
2717
- return rows[0] ? rowToClarityReview(rows[0]) : null;
2718
- }
2719
- async upsert(workspaceId, review) {
2720
- const values = {
2721
- workspace_id: workspaceId,
2722
- id: review.id,
2723
- block_id: review.blockId,
2724
- status: review.status,
2725
- items: JSON.stringify(review.items),
2726
- model: review.model,
2727
- clarified_report: review.clarifiedReport,
2728
- iteration: review.iteration ?? 1,
2729
- max_iterations: review.maxIterations ?? 1,
2730
- created_at: review.createdAt,
2731
- updated_at: review.updatedAt,
2732
- };
2733
- await this.db
2734
- .insert(clarityReviews)
2735
- .values(values)
2736
- .onConflictDoUpdate({
2737
- target: [clarityReviews.workspace_id, clarityReviews.id],
2738
- set: {
2739
- block_id: values.block_id,
2740
- status: values.status,
2741
- items: values.items,
2742
- model: values.model,
2743
- clarified_report: values.clarified_report,
2744
- iteration: values.iteration,
2745
- max_iterations: values.max_iterations,
2746
- updated_at: values.updated_at,
2747
- },
2748
- });
2749
- }
2750
- async deleteByBlock(workspaceId, blockId) {
2751
- await this.db
2752
- .delete(clarityReviews)
2753
- .where(and(eq(clarityReviews.workspace_id, workspaceId), eq(clarityReviews.block_id, blockId)));
2754
- }
2755
- }
2756
- function rowToBrainstormSession(row) {
2757
- let items = [];
2758
- try {
2759
- const parsed = JSON.parse(row.items);
2760
- if (Array.isArray(parsed))
2761
- items = parsed;
2762
- }
2763
- catch {
2764
- items = [];
2765
- }
2766
- return {
2767
- id: row.id,
2768
- blockId: row.block_id,
2769
- stage: row.stage,
2770
- status: row.status,
2771
- items,
2772
- model: row.model,
2773
- convergedDirection: row.converged_direction,
2774
- iteration: row.iteration,
2775
- maxIterations: row.max_iterations,
2776
- createdAt: row.created_at,
2777
- updatedAt: row.updated_at,
2778
- };
2779
- }
2780
- /**
2781
- * Brainstorm (structured-dialogue) sessions over Postgres — the Drizzle mirror of the Worker's
2782
- * `D1BrainstormSessionRepository`. Behaviourally identical so the cross-runtime conformance
2783
- * suite asserts the same per-stage round-trip and brainstorm direction handoff against both
2784
- * stores. Keyed per (block, stage): a block may hold a live `requirements` AND `architecture`
2785
- * session at once.
2786
- */
2787
- class DrizzleBrainstormSessionRepository {
2788
- db;
2789
- constructor(db) {
2790
- this.db = db;
2791
- }
2792
- async getByBlockStage(workspaceId, blockId, stage) {
2793
- const rows = await this.db
2794
- .select()
2795
- .from(brainstormSessions)
2796
- .where(and(eq(brainstormSessions.workspace_id, workspaceId), eq(brainstormSessions.block_id, blockId), eq(brainstormSessions.stage, stage)))
2797
- .orderBy(desc(brainstormSessions.created_at))
2798
- .limit(1);
2799
- return rows[0] ? rowToBrainstormSession(rows[0]) : null;
2800
- }
2801
- async get(workspaceId, id) {
2802
- const rows = await this.db
2803
- .select()
2804
- .from(brainstormSessions)
2805
- .where(and(eq(brainstormSessions.workspace_id, workspaceId), eq(brainstormSessions.id, id)))
2806
- .limit(1);
2807
- return rows[0] ? rowToBrainstormSession(rows[0]) : null;
2808
- }
2809
- async upsert(workspaceId, session) {
2810
- const values = {
2811
- workspace_id: workspaceId,
2812
- id: session.id,
2813
- block_id: session.blockId,
2814
- stage: session.stage,
2815
- status: session.status,
2816
- items: JSON.stringify(session.items),
2817
- model: session.model,
2818
- converged_direction: session.convergedDirection,
2819
- iteration: session.iteration ?? 1,
2820
- max_iterations: session.maxIterations ?? 1,
2821
- created_at: session.createdAt,
2822
- updated_at: session.updatedAt,
2823
- };
2824
- await this.db
2825
- .insert(brainstormSessions)
2826
- .values(values)
2827
- .onConflictDoUpdate({
2828
- target: [brainstormSessions.workspace_id, brainstormSessions.id],
2829
- set: {
2830
- block_id: values.block_id,
2831
- stage: values.stage,
2832
- status: values.status,
2833
- items: values.items,
2834
- model: values.model,
2835
- converged_direction: values.converged_direction,
2836
- iteration: values.iteration,
2837
- max_iterations: values.max_iterations,
2838
- updated_at: values.updated_at,
2839
- },
2840
- });
2841
- }
2842
- async deleteByBlockStage(workspaceId, blockId, stage) {
2843
- await this.db
2844
- .delete(brainstormSessions)
2845
- .where(and(eq(brainstormSessions.workspace_id, workspaceId), eq(brainstormSessions.block_id, blockId), eq(brainstormSessions.stage, stage)));
2846
- }
2847
- }
2848
- // The row → entity decode (doc blob + column-lifted keys) is the shared
2849
- // `decodeInitiativeRow` (contracts), so the Drizzle and D1 repos can't drift.
2850
- const rowToInitiative = decodeInitiativeRow;
2851
- /**
2852
- * Initiatives over Postgres — the Drizzle mirror of the Worker's
2853
- * `D1InitiativeRepository` (migration 0035). Behaviourally identical so the
2854
- * cross-runtime conformance suite asserts the same CRUD + rev-guarded CAS against
2855
- * both stores.
2856
- */
2857
- class DrizzleInitiativeRepository {
2858
- db;
2859
- constructor(db) {
2860
- this.db = db;
2861
- }
2862
- async get(workspaceId, id) {
2863
- const rows = await this.db
2864
- .select()
2865
- .from(initiatives)
2866
- .where(and(eq(initiatives.workspace_id, workspaceId), eq(initiatives.id, id)))
2867
- .limit(1);
2868
- return rows[0] ? rowToInitiative(rows[0]) : null;
2869
- }
2870
- async getByBlock(workspaceId, blockId) {
2871
- const rows = await this.db
2872
- .select()
2873
- .from(initiatives)
2874
- .where(and(eq(initiatives.workspace_id, workspaceId), eq(initiatives.block_id, blockId)))
2875
- .limit(1);
2876
- return rows[0] ? rowToInitiative(rows[0]) : null;
2877
- }
2878
- async list(workspaceId) {
2879
- const rows = await this.db
2880
- .select()
2881
- .from(initiatives)
2882
- .where(eq(initiatives.workspace_id, workspaceId))
2883
- .orderBy(asc(initiatives.created_at));
2884
- // Snapshot-facing list read: drop a corrupt row rather than failing the board load.
2885
- return rows.map(rowToInitiative).filter((i) => i !== null);
2886
- }
2887
- async listExecuting() {
2888
- const rows = await this.db
2889
- .select()
2890
- .from(initiatives)
2891
- .where(eq(initiatives.status, 'executing'))
2892
- .orderBy(asc(initiatives.created_at));
2893
- return rows
2894
- .map((row) => {
2895
- const initiative = rowToInitiative(row);
2896
- return initiative ? { workspaceId: row.workspace_id, initiative } : null;
2897
- })
2898
- .filter((r) => r !== null);
2899
- }
2900
- async insert(workspaceId, initiative) {
2901
- await this.db.insert(initiatives).values({
2902
- workspace_id: workspaceId,
2903
- id: initiative.id,
2904
- block_id: initiative.blockId,
2905
- slug: initiative.slug,
2906
- status: initiative.status,
2907
- rev: initiative.rev,
2908
- doc: JSON.stringify(initiative),
2909
- created_at: initiative.createdAt,
2910
- updated_at: initiative.updatedAt,
2911
- });
2912
- }
2913
- async compareAndSwap(workspaceId, next, expectedRev) {
2914
- const result = await this.db
2915
- .update(initiatives)
2916
- .set({
2917
- slug: next.slug,
2918
- status: next.status,
2919
- rev: next.rev,
2920
- doc: JSON.stringify(next),
2921
- updated_at: next.updatedAt,
2922
- })
2923
- .where(and(eq(initiatives.workspace_id, workspaceId), eq(initiatives.id, next.id), eq(initiatives.rev, expectedRev)));
2924
- return (result.rowCount ?? 0) > 0;
2925
- }
2926
- async delete(workspaceId, id) {
2927
- await this.db
2928
- .delete(initiatives)
2929
- .where(and(eq(initiatives.workspace_id, workspaceId), eq(initiatives.id, id)));
2930
- }
2931
- }
2932
- function rowToRiskPolicy(row) {
2933
- return {
2934
- id: row.id,
2935
- name: row.name,
2936
- maxComplexity: row.max_complexity,
2937
- maxRisk: row.max_risk,
2938
- maxImpact: row.max_impact,
2939
- ciMaxAttempts: row.ci_max_attempts,
2940
- maxRequirementIterations: row.max_requirement_iterations,
2941
- maxRequirementConcernAllowed: row.max_requirement_concern_allowed,
2942
- maxTesterQualityIterations: row.max_tester_quality_iterations,
2943
- releaseWatchWindowMinutes: row.release_watch_window_minutes,
2944
- releaseMaxAttempts: row.release_max_attempts,
2945
- humanReviewGraceMinutes: row.human_review_grace_minutes,
2946
- autoMergeEnabled: row.auto_merge_enabled === 1,
2947
- forkDecision: row.fork_decision
2948
- ? JSON.parse(row.fork_decision)
2949
- : null,
2950
- isDefault: row.is_default === 1,
2951
- ...(row.version != null ? { version: row.version } : {}),
2952
- createdAt: row.created_at,
2953
- };
2954
- }
2955
- /**
2956
- * Per-workspace merge threshold presets over Postgres (the Drizzle mirror of the
2957
- * Worker's `D1RiskPolicyRepository`, migration 0024). Enforces the single-default
2958
- * invariant: promoting a preset to default demotes every other in the workspace
2959
- * before the upsert. The default preset cannot be removed (the service keeps that
2960
- * rule too; the DELETE also guards `is_default = 0`). Behaviourally identical to the
2961
- * D1 repo so the cross-runtime conformance suite asserts the same preset resolution.
2962
- */
2963
- class DrizzleRiskPolicyRepository {
2964
- db;
2965
- constructor(db) {
2966
- this.db = db;
2967
- }
2968
- async get(workspaceId, id) {
2969
- const rows = await this.db
2970
- .select()
2971
- .from(riskPolicies)
2972
- .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.id, id)))
2973
- .limit(1);
2974
- return rows[0] ? rowToRiskPolicy(rows[0]) : null;
2975
- }
2976
- async list(workspaceId) {
2977
- const rows = await this.db
2978
- .select()
2979
- .from(riskPolicies)
2980
- .where(eq(riskPolicies.workspace_id, workspaceId))
2981
- .orderBy(riskPolicies.created_at);
2982
- return rows.map(rowToRiskPolicy);
2983
- }
2984
- async getDefault(workspaceId) {
2985
- const rows = await this.db
2986
- .select()
2987
- .from(riskPolicies)
2988
- .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.is_default, 1)))
2989
- .orderBy(riskPolicies.created_at)
2990
- .limit(1);
2991
- return rows[0] ? rowToRiskPolicy(rows[0]) : null;
2992
- }
2993
- async upsert(workspaceId, preset) {
2994
- const values = {
2995
- workspace_id: workspaceId,
2996
- id: preset.id,
2997
- name: preset.name,
2998
- max_complexity: preset.maxComplexity,
2999
- max_risk: preset.maxRisk,
3000
- max_impact: preset.maxImpact,
3001
- ci_max_attempts: preset.ciMaxAttempts,
3002
- max_requirement_iterations: preset.maxRequirementIterations,
3003
- max_requirement_concern_allowed: preset.maxRequirementConcernAllowed,
3004
- max_tester_quality_iterations: preset.maxTesterQualityIterations,
3005
- release_watch_window_minutes: preset.releaseWatchWindowMinutes,
3006
- release_max_attempts: preset.releaseMaxAttempts,
3007
- human_review_grace_minutes: preset.humanReviewGraceMinutes,
3008
- auto_merge_enabled: preset.autoMergeEnabled ? 1 : 0,
3009
- fork_decision: preset.forkDecision ? JSON.stringify(preset.forkDecision) : null,
3010
- version: preset.version ?? null,
3011
- is_default: preset.isDefault ? 1 : 0,
3012
- created_at: preset.createdAt,
3013
- };
3014
- // Demote + upsert run in one transaction so the single-default invariant can never
3015
- // be observed broken (zero or two defaults) by a concurrent reader or a partial failure.
3016
- await this.db.transaction(async (tx) => {
3017
- // Promoting this preset to default demotes any other default first.
3018
- if (preset.isDefault) {
3019
- await tx
3020
- .update(riskPolicies)
3021
- .set({ is_default: 0 })
3022
- .where(and(eq(riskPolicies.workspace_id, workspaceId), sql `${riskPolicies.id} <> ${preset.id}`));
3023
- }
3024
- await tx
3025
- .insert(riskPolicies)
3026
- .values(values)
3027
- .onConflictDoUpdate({
3028
- target: [riskPolicies.workspace_id, riskPolicies.id],
3029
- set: {
3030
- name: values.name,
3031
- max_complexity: values.max_complexity,
3032
- max_risk: values.max_risk,
3033
- max_impact: values.max_impact,
3034
- ci_max_attempts: values.ci_max_attempts,
3035
- max_requirement_iterations: values.max_requirement_iterations,
3036
- max_requirement_concern_allowed: values.max_requirement_concern_allowed,
3037
- max_tester_quality_iterations: values.max_tester_quality_iterations,
3038
- release_watch_window_minutes: values.release_watch_window_minutes,
3039
- release_max_attempts: values.release_max_attempts,
3040
- human_review_grace_minutes: values.human_review_grace_minutes,
3041
- auto_merge_enabled: values.auto_merge_enabled,
3042
- fork_decision: values.fork_decision,
3043
- version: values.version,
3044
- is_default: values.is_default,
3045
- },
3046
- });
3047
- });
3048
- }
3049
- async remove(workspaceId, id) {
3050
- await this.db
3051
- .delete(riskPolicies)
3052
- .where(and(eq(riskPolicies.workspace_id, workspaceId), eq(riskPolicies.id, id), eq(riskPolicies.is_default, 0)));
3053
- }
3054
- }
3055
- // Shape-guarded parsers matching the D1 mirror (`D1SharedStackRepository`) EXACTLY, so a
3056
- // malformed/hand-edited JSON column coerces identically on both stores (a non-array ⇒ `[]`, a
3057
- // non-object health gate ⇒ `null`) rather than the Node facade handing the domain a raw value the
3058
- // Worker would have dropped — the "keep the runtimes symmetric" guarantee holds for bad data too.
3059
- function parseSharedStackArray(json) {
3060
- try {
3061
- const parsed = JSON.parse(json);
3062
- return Array.isArray(parsed) ? parsed : [];
3063
- }
3064
- catch {
3065
- return [];
3066
- }
3067
- }
3068
- function parseSharedStackHealthGate(json) {
3069
- if (!json)
3070
- return null;
3071
- try {
3072
- const parsed = JSON.parse(json);
3073
- return parsed && typeof parsed === 'object'
3074
- ? parsed
3075
- : null;
3076
- }
3077
- catch {
3078
- return null;
3079
- }
3080
- }
3081
- function rowToSharedStack(row) {
3082
- return {
3083
- id: row.id,
3084
- workspaceId: row.workspace_id,
3085
- name: row.name,
3086
- cloneUrl: row.clone_url,
3087
- gitRef: row.git_ref,
3088
- composeFiles: parseSharedStackArray(row.compose_files),
3089
- composeProfiles: parseSharedStackArray(row.compose_profiles),
3090
- envFiles: parseSharedStackArray(row.env_files),
3091
- managedNetworks: parseSharedStackArray(row.managed_networks),
3092
- setupSteps: parseSharedStackArray(row.setup_steps),
3093
- prerequisites: parseSharedStackArray(row.prerequisites),
3094
- healthGate: parseSharedStackHealthGate(row.health_gate),
3095
- allowHostCommands: row.allow_host_commands === 1,
3096
- status: row.status,
3097
- lastError: row.last_error,
3098
- createdAt: row.created_at,
3099
- updatedAt: row.updated_at,
3100
- };
3101
- }
3102
- /**
3103
- * A workspace's shared stacks over Postgres (the Drizzle mirror of the Worker's
3104
- * `D1SharedStackRepository`, migration 0041). JSON-shaped fields are stored as text JSON and
3105
- * `allow_host_commands` as 0/1; behaviourally identical to the D1 repo so the cross-runtime
3106
- * conformance suite asserts the same round-trip.
3107
- */
3108
- class DrizzleSharedStackRepository {
3109
- db;
3110
- constructor(db) {
3111
- this.db = db;
3112
- }
3113
- async get(workspaceId, id) {
3114
- const rows = await this.db
3115
- .select()
3116
- .from(sharedStacks)
3117
- .where(and(eq(sharedStacks.workspace_id, workspaceId), eq(sharedStacks.id, id)))
3118
- .limit(1);
3119
- return rows[0] ? rowToSharedStack(rows[0]) : null;
3120
- }
3121
- async list(workspaceId) {
3122
- const rows = await this.db
3123
- .select()
3124
- .from(sharedStacks)
3125
- .where(eq(sharedStacks.workspace_id, workspaceId))
3126
- .orderBy(sharedStacks.created_at);
3127
- return rows.map(rowToSharedStack);
3128
- }
3129
- async upsert(workspaceId, stack) {
3130
- const values = {
3131
- workspace_id: workspaceId,
3132
- id: stack.id,
3133
- name: stack.name,
3134
- clone_url: stack.cloneUrl,
3135
- git_ref: stack.gitRef,
3136
- compose_files: JSON.stringify(stack.composeFiles),
3137
- compose_profiles: JSON.stringify(stack.composeProfiles),
3138
- env_files: JSON.stringify(stack.envFiles),
3139
- managed_networks: JSON.stringify(stack.managedNetworks),
3140
- setup_steps: JSON.stringify(stack.setupSteps),
3141
- prerequisites: JSON.stringify(stack.prerequisites),
3142
- health_gate: stack.healthGate ? JSON.stringify(stack.healthGate) : null,
3143
- allow_host_commands: stack.allowHostCommands ? 1 : 0,
3144
- status: stack.status,
3145
- last_error: stack.lastError,
3146
- created_at: stack.createdAt,
3147
- updated_at: stack.updatedAt,
3148
- };
3149
- await this.db
3150
- .insert(sharedStacks)
3151
- .values(values)
3152
- .onConflictDoUpdate({
3153
- target: [sharedStacks.workspace_id, sharedStacks.id],
3154
- set: {
3155
- name: values.name,
3156
- clone_url: values.clone_url,
3157
- git_ref: values.git_ref,
3158
- compose_files: values.compose_files,
3159
- compose_profiles: values.compose_profiles,
3160
- env_files: values.env_files,
3161
- managed_networks: values.managed_networks,
3162
- setup_steps: values.setup_steps,
3163
- prerequisites: values.prerequisites,
3164
- health_gate: values.health_gate,
3165
- allow_host_commands: values.allow_host_commands,
3166
- status: values.status,
3167
- last_error: values.last_error,
3168
- updated_at: values.updated_at,
3169
- },
3170
- });
3171
- }
3172
- async remove(workspaceId, id) {
3173
- await this.db
3174
- .delete(sharedStacks)
3175
- .where(and(eq(sharedStacks.workspace_id, workspaceId), eq(sharedStacks.id, id)));
3176
- }
3177
- }
3178
- // ---- Sandbox (parallel prompt/model testing surface; migration 0012) --------
3179
- // The Drizzle mirror of the Worker's five `D1Sandbox*Repository` classes. JSON-shaped
3180
- // fields are stored as text JSON, parsed defensively; behaviourally identical to the D1
3181
- // repos so the cross-runtime conformance suite asserts the same Sandbox behaviour.
3182
- class DrizzleSandboxPromptVersionRepository {
3183
- db;
3184
- constructor(db) {
3185
- this.db = db;
3186
- }
3187
- async get(workspaceId, id) {
3188
- const rows = await this.db
3189
- .select()
3190
- .from(sandboxPromptVersions)
3191
- .where(and(eq(sandboxPromptVersions.workspace_id, workspaceId), eq(sandboxPromptVersions.id, id)))
3192
- .limit(1);
3193
- return rows[0] ? rowToSandboxPromptVersion(rows[0]) : null;
3194
- }
3195
- async list(workspaceId) {
3196
- const rows = await this.db
3197
- .select()
3198
- .from(sandboxPromptVersions)
3199
- .where(and(eq(sandboxPromptVersions.workspace_id, workspaceId), isNull(sandboxPromptVersions.archived_at)))
3200
- .orderBy(desc(sandboxPromptVersions.created_at));
3201
- return rows.map((r) => rowToSandboxPromptVersion(r));
3202
- }
3203
- async listByKind(workspaceId, agentKind) {
3204
- const rows = await this.db
3205
- .select()
3206
- .from(sandboxPromptVersions)
3207
- .where(and(eq(sandboxPromptVersions.workspace_id, workspaceId), eq(sandboxPromptVersions.agent_kind, agentKind), isNull(sandboxPromptVersions.archived_at)))
3208
- .orderBy(desc(sandboxPromptVersions.created_at));
3209
- return rows.map((r) => rowToSandboxPromptVersion(r));
3210
- }
3211
- async upsert(workspaceId, version) {
3212
- const values = {
3213
- workspace_id: workspaceId,
3214
- id: version.id,
3215
- lineage_id: version.lineageId,
3216
- agent_kind: version.agentKind,
3217
- name: version.name,
3218
- origin: version.origin,
3219
- system_text: version.systemText,
3220
- base_prompt_id: version.basePromptId,
3221
- version: version.version,
3222
- parent_id: version.parentId,
3223
- labels: JSON.stringify(version.labels),
3224
- created_at: version.createdAt,
3225
- created_by: version.createdBy,
3226
- archived_at: version.archivedAt,
3227
- };
3228
- await this.db
3229
- .insert(sandboxPromptVersions)
3230
- .values(values)
3231
- .onConflictDoUpdate({
3232
- target: [sandboxPromptVersions.workspace_id, sandboxPromptVersions.id],
3233
- set: {
3234
- lineage_id: values.lineage_id,
3235
- agent_kind: values.agent_kind,
3236
- name: values.name,
3237
- origin: values.origin,
3238
- system_text: values.system_text,
3239
- base_prompt_id: values.base_prompt_id,
3240
- version: values.version,
3241
- parent_id: values.parent_id,
3242
- labels: values.labels,
3243
- created_by: values.created_by,
3244
- archived_at: values.archived_at,
3245
- },
3246
- });
3247
- }
3248
- async archive(workspaceId, id, at) {
3249
- await this.db
3250
- .update(sandboxPromptVersions)
3251
- .set({ archived_at: at })
3252
- .where(and(eq(sandboxPromptVersions.workspace_id, workspaceId), eq(sandboxPromptVersions.id, id)));
3253
- }
3254
- }
3255
- class DrizzleSandboxFixtureRepository {
3256
- db;
3257
- constructor(db) {
3258
- this.db = db;
3259
- }
3260
- async get(workspaceId, id) {
3261
- const rows = await this.db
3262
- .select()
3263
- .from(sandboxFixtures)
3264
- .where(and(eq(sandboxFixtures.workspace_id, workspaceId), eq(sandboxFixtures.id, id)))
3265
- .limit(1);
3266
- return rows[0] ? rowToSandboxFixture(rows[0]) : null;
3267
- }
3268
- async list(workspaceId) {
3269
- const rows = await this.db
3270
- .select()
3271
- .from(sandboxFixtures)
3272
- .where(eq(sandboxFixtures.workspace_id, workspaceId))
3273
- .orderBy(sandboxFixtures.created_at);
3274
- return rows.map((r) => rowToSandboxFixture(r));
3275
- }
3276
- async upsert(workspaceId, fixture) {
3277
- const values = {
3278
- workspace_id: workspaceId,
3279
- id: fixture.id,
3280
- kind: fixture.kind,
3281
- name: fixture.name,
3282
- payload: fixture.payload ? JSON.stringify(fixture.payload) : null,
3283
- repo_ref: fixture.repoRef ? JSON.stringify(fixture.repoRef) : null,
3284
- objective: fixture.objective ? JSON.stringify(fixture.objective) : null,
3285
- origin: fixture.origin,
3286
- created_at: fixture.createdAt,
3287
- };
3288
- await this.db
3289
- .insert(sandboxFixtures)
3290
- .values(values)
3291
- .onConflictDoUpdate({
3292
- target: [sandboxFixtures.workspace_id, sandboxFixtures.id],
3293
- set: {
3294
- kind: values.kind,
3295
- name: values.name,
3296
- payload: values.payload,
3297
- repo_ref: values.repo_ref,
3298
- objective: values.objective,
3299
- origin: values.origin,
3300
- },
3301
- });
3302
- }
3303
- async remove(workspaceId, id) {
3304
- await this.db
3305
- .delete(sandboxFixtures)
3306
- .where(and(eq(sandboxFixtures.workspace_id, workspaceId), eq(sandboxFixtures.id, id)));
3307
- }
3308
- }
3309
- class DrizzleSandboxExperimentRepository {
3310
- db;
3311
- constructor(db) {
3312
- this.db = db;
3313
- }
3314
- async get(workspaceId, id) {
3315
- const rows = await this.db
3316
- .select()
3317
- .from(sandboxExperiments)
3318
- .where(and(eq(sandboxExperiments.workspace_id, workspaceId), eq(sandboxExperiments.id, id)))
3319
- .limit(1);
3320
- return rows[0] ? rowToSandboxExperiment(rows[0]) : null;
3321
- }
3322
- async list(workspaceId) {
3323
- const rows = await this.db
3324
- .select()
3325
- .from(sandboxExperiments)
3326
- .where(eq(sandboxExperiments.workspace_id, workspaceId))
3327
- .orderBy(desc(sandboxExperiments.created_at));
3328
- return rows.map((r) => rowToSandboxExperiment(r));
3329
- }
3330
- async upsert(workspaceId, experiment) {
3331
- const values = {
3332
- workspace_id: workspaceId,
3333
- id: experiment.id,
3334
- name: experiment.name,
3335
- agent_kind: experiment.agentKind,
3336
- judge_model: experiment.judgeModel,
3337
- repeats: experiment.repeats,
3338
- status: experiment.status,
3339
- matrix: JSON.stringify(experiment.matrix),
3340
- budget_tokens: experiment.budgetTokens,
3341
- created_at: experiment.createdAt,
3342
- created_by: experiment.createdBy,
3343
- };
3344
- await this.db
3345
- .insert(sandboxExperiments)
3346
- .values(values)
3347
- .onConflictDoUpdate({
3348
- target: [sandboxExperiments.workspace_id, sandboxExperiments.id],
3349
- set: {
3350
- name: values.name,
3351
- agent_kind: values.agent_kind,
3352
- judge_model: values.judge_model,
3353
- repeats: values.repeats,
3354
- status: values.status,
3355
- matrix: values.matrix,
3356
- budget_tokens: values.budget_tokens,
3357
- created_by: values.created_by,
3358
- },
3359
- });
3360
- }
3361
- async setStatus(workspaceId, id, status) {
3362
- await this.db
3363
- .update(sandboxExperiments)
3364
- .set({ status })
3365
- .where(and(eq(sandboxExperiments.workspace_id, workspaceId), eq(sandboxExperiments.id, id)));
3366
- }
3367
- async claimForRun(workspaceId, id) {
3368
- // Conditional update: only flips a non-running experiment to `running`. `.returning()`
3369
- // reports whether this caller won the claim (empty ⇒ already running). Atomic, so
3370
- // concurrent launches can't both clear + re-expand the grid (see the port doc).
3371
- const rows = await this.db
3372
- .update(sandboxExperiments)
3373
- .set({ status: 'running' })
3374
- .where(and(eq(sandboxExperiments.workspace_id, workspaceId), eq(sandboxExperiments.id, id), ne(sandboxExperiments.status, 'running')))
3375
- .returning({ id: sandboxExperiments.id });
3376
- return rows.length > 0;
3377
- }
3378
- }
3379
- class DrizzleSandboxRunRepository {
3380
- db;
3381
- constructor(db) {
3382
- this.db = db;
3383
- }
3384
- async get(workspaceId, id) {
3385
- const rows = await this.db
3386
- .select()
3387
- .from(sandboxRuns)
3388
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.id, id)))
3389
- .limit(1);
3390
- return rows[0] ? rowToSandboxRun(rows[0]) : null;
3391
- }
3392
- async listByExperiment(workspaceId, experimentId) {
3393
- const rows = await this.db
3394
- .select()
3395
- .from(sandboxRuns)
3396
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.experiment_id, experimentId)))
3397
- .orderBy(sandboxRuns.prompt_version_id, sandboxRuns.model, sandboxRuns.fixture_id, sandboxRuns.repeat_index);
3398
- return rows.map((r) => rowToSandboxRun(r));
3399
- }
3400
- async listQueued(workspaceId, experimentId) {
3401
- const rows = await this.db
3402
- .select()
3403
- .from(sandboxRuns)
3404
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.experiment_id, experimentId), eq(sandboxRuns.status, 'queued')))
3405
- .orderBy(sandboxRuns.started_at, sandboxRuns.id);
3406
- return rows.map((r) => rowToSandboxRun(r));
3407
- }
3408
- async upsert(workspaceId, run) {
3409
- const values = {
3410
- workspace_id: workspaceId,
3411
- id: run.id,
3412
- experiment_id: run.experimentId,
3413
- prompt_version_id: run.promptVersionId,
3414
- model: run.model,
3415
- fixture_id: run.fixtureId,
3416
- repeat_index: run.repeatIndex,
3417
- status: run.status,
3418
- output_text: run.outputText,
3419
- usage: run.usage ? JSON.stringify(run.usage) : null,
3420
- latency_ms: run.latencyMs,
3421
- branch: run.branch,
3422
- pr_url: run.prUrl,
3423
- diff: run.diff,
3424
- error: run.error,
3425
- seed_sha: run.seedSha,
3426
- prompt_label: run.promptLabel,
3427
- started_at: run.startedAt,
3428
- finished_at: run.finishedAt,
3429
- };
3430
- await this.db
3431
- .insert(sandboxRuns)
3432
- .values(values)
3433
- .onConflictDoUpdate({
3434
- target: [sandboxRuns.workspace_id, sandboxRuns.id],
3435
- set: {
3436
- experiment_id: values.experiment_id,
3437
- prompt_version_id: values.prompt_version_id,
3438
- model: values.model,
3439
- fixture_id: values.fixture_id,
3440
- repeat_index: values.repeat_index,
3441
- status: values.status,
3442
- output_text: values.output_text,
3443
- usage: values.usage,
3444
- latency_ms: values.latency_ms,
3445
- branch: values.branch,
3446
- pr_url: values.pr_url,
3447
- diff: values.diff,
3448
- error: values.error,
3449
- seed_sha: values.seed_sha,
3450
- prompt_label: values.prompt_label,
3451
- started_at: values.started_at,
3452
- finished_at: values.finished_at,
3453
- },
3454
- });
3455
- }
3456
- async setStatus(workspaceId, id, status) {
3457
- await this.db
3458
- .update(sandboxRuns)
3459
- .set({ status })
3460
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.id, id)));
3461
- }
3462
- async removeByExperiment(workspaceId, experimentId) {
3463
- await this.db
3464
- .delete(sandboxRuns)
3465
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.experiment_id, experimentId)));
3466
- }
3467
- }
3468
- class DrizzleSandboxGradeRepository {
3469
- db;
3470
- constructor(db) {
3471
- this.db = db;
3472
- }
3473
- async getByRun(workspaceId, runId) {
3474
- const rows = await this.db
3475
- .select()
3476
- .from(sandboxGrades)
3477
- .where(and(eq(sandboxGrades.workspace_id, workspaceId), eq(sandboxGrades.run_id, runId)))
3478
- .orderBy(desc(sandboxGrades.created_at))
3479
- .limit(1);
3480
- return rows[0] ? rowToSandboxGrade(rows[0]) : null;
3481
- }
3482
- async listByExperiment(workspaceId, experimentId) {
3483
- const rows = await this.db
3484
- .select({ grade: sandboxGrades })
3485
- .from(sandboxGrades)
3486
- .innerJoin(sandboxRuns, and(eq(sandboxRuns.workspace_id, sandboxGrades.workspace_id), eq(sandboxRuns.id, sandboxGrades.run_id)))
3487
- .where(and(eq(sandboxGrades.workspace_id, workspaceId), eq(sandboxRuns.experiment_id, experimentId)))
3488
- .orderBy(sandboxGrades.created_at);
3489
- return rows.map((r) => rowToSandboxGrade(r.grade));
3490
- }
3491
- async upsert(workspaceId, grade) {
3492
- const values = {
3493
- workspace_id: workspaceId,
3494
- id: grade.id,
3495
- run_id: grade.runId,
3496
- judge_model: grade.judgeModel,
3497
- scores: JSON.stringify(grade.scores),
3498
- weighted_total: grade.weightedTotal,
3499
- objective: grade.objective ? JSON.stringify(grade.objective) : null,
3500
- created_at: grade.createdAt,
3501
- };
3502
- await this.db
3503
- .insert(sandboxGrades)
3504
- .values(values)
3505
- .onConflictDoUpdate({
3506
- target: [sandboxGrades.workspace_id, sandboxGrades.id],
3507
- set: {
3508
- run_id: values.run_id,
3509
- judge_model: values.judge_model,
3510
- scores: values.scores,
3511
- weighted_total: values.weighted_total,
3512
- objective: values.objective,
3513
- },
3514
- });
3515
- }
3516
- async removeByExperiment(workspaceId, experimentId) {
3517
- // Grades carry no experiment_id; scope them through their run's experiment.
3518
- const runIds = this.db
3519
- .select({ id: sandboxRuns.id })
3520
- .from(sandboxRuns)
3521
- .where(and(eq(sandboxRuns.workspace_id, workspaceId), eq(sandboxRuns.experiment_id, experimentId)));
3522
- await this.db
3523
- .delete(sandboxGrades)
3524
- .where(and(eq(sandboxGrades.workspace_id, workspaceId), inArray(sandboxGrades.run_id, runIds)));
3525
- }
3526
- }
3527
- /**
3528
- * The Sandbox's persistence as one spreadable mixin (the Drizzle analogue of the
3529
- * Worker's `selectSandboxDeps`). The Node container spreads `...createDrizzleSandboxDeps(db)`
3530
- * into its dependencies so the container body never enumerates the Sandbox repos — the
3531
- * knowledge of which repos exist lives here, next to their implementations. Typed by the
3532
- * kernel ports (not `CoreDependencies`) so this module stays free of the orchestration import.
3533
- */
3534
- export function createDrizzleSandboxDeps(db) {
3535
- return {
3536
- sandboxPromptVersionRepository: new DrizzleSandboxPromptVersionRepository(db),
3537
- sandboxFixtureRepository: new DrizzleSandboxFixtureRepository(db),
3538
- sandboxExperimentRepository: new DrizzleSandboxExperimentRepository(db),
3539
- sandboxRunRepository: new DrizzleSandboxRunRepository(db),
3540
- sandboxGradeRepository: new DrizzleSandboxGradeRepository(db),
3541
- };
3542
- }
3543
- function rowToWorkspaceSettings(row) {
3544
- let perType = null;
3545
- if (row.task_limit_per_type) {
3546
- try {
3547
- perType = JSON.parse(row.task_limit_per_type);
3548
- }
3549
- catch {
3550
- perType = null;
3551
- }
3552
- }
3553
- return {
3554
- waitingEscalationMinutes: row.waiting_escalation_minutes,
3555
- taskLimitMode: row.task_limit_mode,
3556
- taskLimitShared: row.task_limit_shared,
3557
- taskLimitPerType: perType,
3558
- storeAgentContext: row.store_agent_context === 1,
3559
- artifactRetentionDays: row.artifact_retention_days,
3560
- kaizenEnabled: row.kaizen_enabled === 1,
3561
- delegateAgentsToRunnerPool: row.delegate_agents_to_runner_pool === 1,
3562
- spendCurrency: row.spend_currency,
3563
- spendMonthlyLimit: row.spend_monthly_limit,
3564
- };
3565
- }
3566
- /**
3567
- * Per-workspace runtime settings over Postgres (the Drizzle mirror of the Worker's
3568
- * `D1WorkspaceSettingsRepository`, migration 0004). One row per workspace; the service
3569
- * lazily seeds the default, so an absent row reads as null. Per-type task limits are a
3570
- * JSON column.
3571
- */
3572
- export class DrizzleWorkspaceSettingsRepository {
3573
- db;
3574
- constructor(db) {
3575
- this.db = db;
3576
- }
3577
- async get(workspaceId) {
3578
- const rows = await this.db
3579
- .select()
3580
- .from(workspaceSettings)
3581
- .where(eq(workspaceSettings.workspace_id, workspaceId))
3582
- .limit(1);
3583
- const row = rows[0];
3584
- return row ? rowToWorkspaceSettings(row) : null;
3585
- }
3586
- async listByWorkspaceIds(workspaceIds) {
3587
- const out = new Map();
3588
- if (workspaceIds.length === 0)
3589
- return out;
3590
- for (let i = 0; i < workspaceIds.length; i += 500) {
3591
- const rows = await this.db
3592
- .select()
3593
- .from(workspaceSettings)
3594
- .where(inArray(workspaceSettings.workspace_id, workspaceIds.slice(i, i + 500)));
3595
- for (const row of rows)
3596
- out.set(row.workspace_id, rowToWorkspaceSettings(row));
3597
- }
3598
- return out;
3599
- }
3600
- async upsert(workspaceId, settings) {
3601
- const values = {
3602
- workspace_id: workspaceId,
3603
- waiting_escalation_minutes: settings.waitingEscalationMinutes,
3604
- task_limit_mode: settings.taskLimitMode,
3605
- task_limit_shared: settings.taskLimitShared,
3606
- task_limit_per_type: settings.taskLimitPerType
3607
- ? JSON.stringify(settings.taskLimitPerType)
3608
- : null,
3609
- store_agent_context: settings.storeAgentContext ? 1 : 0,
3610
- artifact_retention_days: settings.artifactRetentionDays,
3611
- kaizen_enabled: settings.kaizenEnabled ? 1 : 0,
3612
- delegate_agents_to_runner_pool: settings.delegateAgentsToRunnerPool ? 1 : 0,
3613
- spend_currency: settings.spendCurrency,
3614
- spend_monthly_limit: settings.spendMonthlyLimit,
3615
- };
3616
- await this.db
3617
- .insert(workspaceSettings)
3618
- .values(values)
3619
- .onConflictDoUpdate({
3620
- target: [workspaceSettings.workspace_id],
3621
- set: {
3622
- waiting_escalation_minutes: values.waiting_escalation_minutes,
3623
- task_limit_mode: values.task_limit_mode,
3624
- task_limit_shared: values.task_limit_shared,
3625
- task_limit_per_type: values.task_limit_per_type,
3626
- store_agent_context: values.store_agent_context,
3627
- artifact_retention_days: values.artifact_retention_days,
3628
- kaizen_enabled: values.kaizen_enabled,
3629
- delegate_agents_to_runner_pool: values.delegate_agents_to_runner_pool,
3630
- spend_currency: values.spend_currency,
3631
- spend_monthly_limit: values.spend_monthly_limit,
3632
- },
3633
- });
3634
- }
3635
- }
3636
- /**
3637
- * A workspace's observability connection over Postgres (the Drizzle mirror of the Worker's
3638
- * `D1ObservabilityConnectionRepository`, migration 0007). One row per workspace; the
3639
- * provider-specific credentials are stored as a sealed JSON blob (encrypted by the caller),
3640
- * with a non-secret `summary` blob for display.
3641
- */
3642
- class DrizzleObservabilityConnectionRepository {
3643
- db;
3644
- constructor(db) {
3645
- this.db = db;
3646
- }
3647
- async get(workspaceId) {
3648
- const rows = await this.db
3649
- .select()
3650
- .from(observabilityConnections)
3651
- .where(eq(observabilityConnections.workspace_id, workspaceId))
3652
- .limit(1);
3653
- const row = rows[0];
3654
- if (!row)
3655
- return null;
3656
- return {
3657
- workspaceId: row.workspace_id,
3658
- provider: row.provider,
3659
- credentials: row.credentials,
3660
- summary: row.summary,
3661
- createdAt: row.created_at,
3662
- updatedAt: row.updated_at,
3663
- };
3664
- }
3665
- async upsert(record) {
3666
- const values = {
3667
- workspace_id: record.workspaceId,
3668
- provider: record.provider,
3669
- credentials: record.credentials,
3670
- summary: record.summary,
3671
- created_at: record.createdAt,
3672
- updated_at: record.updatedAt,
3673
- };
3674
- await this.db
3675
- .insert(observabilityConnections)
3676
- .values(values)
3677
- .onConflictDoUpdate({
3678
- target: observabilityConnections.workspace_id,
3679
- set: {
3680
- provider: values.provider,
3681
- credentials: values.credentials,
3682
- summary: values.summary,
3683
- updated_at: values.updated_at,
3684
- },
3685
- });
3686
- }
3687
- async delete(workspaceId) {
3688
- await this.db
3689
- .delete(observabilityConnections)
3690
- .where(eq(observabilityConnections.workspace_id, workspaceId));
3691
- }
3692
- }
3693
- /**
3694
- * Postgres-backed modeled subscription quota-cycle counters (the Drizzle mirror of the
3695
- * Worker's `D1SubscriptionQuotaCycleRepository`, migration 0047), column-for-column so
3696
- * behaviour matches across stores.
3697
- */
3698
- class DrizzleSubscriptionQuotaCycleRepository {
3699
- db;
3700
- constructor(db) {
3701
- this.db = db;
3702
- }
3703
- async recordUsage(key, usage, at, windowMs) {
3704
- // Windowed UPSERT (mirrors the D1 repo): INSERT anchors a fresh window at `at`; on
3705
- // conflict, an active window (younger than `windowMs`) accumulates and a stale one
3706
- // resets to `at`. Every SET RHS reads the row's pre-update values, so referencing
3707
- // `window_started_at` in the counter branches is safe though it's also reassigned.
3708
- const cols = subscriptionQuotaCycles;
3709
- const active = sql `(${at} - ${cols.window_started_at} < ${windowMs})`;
3710
- await this.db
3711
- .insert(cols)
3712
- .values({
3713
- id: key.id,
3714
- scope: key.scope,
3715
- scope_id: key.scopeId,
3716
- vendor: key.vendor,
3717
- window_kind: key.windowKind,
3718
- window_started_at: at,
3719
- input_tokens: usage.inputTokens,
3720
- output_tokens: usage.outputTokens,
3721
- request_count: 1,
3722
- updated_at: at,
3723
- })
3724
- .onConflictDoUpdate({
3725
- target: [cols.scope, cols.scope_id, cols.vendor, cols.window_kind],
3726
- set: {
3727
- window_started_at: sql `CASE WHEN ${active} THEN ${cols.window_started_at} ELSE ${at} END`,
3728
- input_tokens: sql `CASE WHEN ${active} THEN ${cols.input_tokens} ELSE 0 END + ${usage.inputTokens}`,
3729
- output_tokens: sql `CASE WHEN ${active} THEN ${cols.output_tokens} ELSE 0 END + ${usage.outputTokens}`,
3730
- request_count: sql `CASE WHEN ${active} THEN ${cols.request_count} ELSE 0 END + 1`,
3731
- updated_at: at,
3732
- },
3733
- });
3734
- }
3735
- async listByScopeVendor(scope, scopeId, vendor) {
3736
- const rows = await this.db
3737
- .select()
3738
- .from(subscriptionQuotaCycles)
3739
- .where(and(eq(subscriptionQuotaCycles.scope, scope), eq(subscriptionQuotaCycles.scope_id, scopeId), eq(subscriptionQuotaCycles.vendor, vendor)));
3740
- return rows.map((row) => ({
3741
- id: row.id,
3742
- scope: row.scope,
3743
- scopeId: row.scope_id,
3744
- vendor: decodeEnum(subscriptionVendorSchema, row.vendor, {
3745
- table: 'subscription_quota_cycles',
3746
- column: 'vendor',
3747
- id: row.id,
3748
- }),
3749
- windowKind: row.window_kind,
3750
- windowStartedAt: row.window_started_at,
3751
- inputTokens: row.input_tokens,
3752
- outputTokens: row.output_tokens,
3753
- requestCount: row.request_count,
3754
- updatedAt: row.updated_at,
3755
- }));
3756
- }
3757
- async deleteOlderThan(epochMs) {
3758
- const deleted = await this.db
3759
- .delete(subscriptionQuotaCycles)
3760
- .where(lt(subscriptionQuotaCycles.window_started_at, epochMs))
3761
- .returning({ id: subscriptionQuotaCycles.id });
3762
- return deleted.length;
3763
- }
3764
- }
3765
- /**
3766
- * A workspace's private package-registry connection over Postgres (the Drizzle mirror
3767
- * of the Worker's `D1PackageRegistryConnectionRepository`, migration 0034). One row per
3768
- * workspace; the registry entries are stored as ONE sealed JSON array (encrypted by the
3769
- * caller), with a non-secret `summary` blob for display.
3770
- */
3771
- class DrizzlePackageRegistryConnectionRepository {
3772
- db;
3773
- constructor(db) {
3774
- this.db = db;
3775
- }
3776
- async get(workspaceId) {
3777
- const rows = await this.db
3778
- .select()
3779
- .from(packageRegistryConnections)
3780
- .where(eq(packageRegistryConnections.workspace_id, workspaceId))
3781
- .limit(1);
3782
- const row = rows[0];
3783
- if (!row)
3784
- return null;
3785
- return {
3786
- workspaceId: row.workspace_id,
3787
- entries: row.entries,
3788
- summary: row.summary,
3789
- createdAt: row.created_at,
3790
- updatedAt: row.updated_at,
3791
- };
3792
- }
3793
- async upsert(record) {
3794
- const values = {
3795
- workspace_id: record.workspaceId,
3796
- entries: record.entries,
3797
- summary: record.summary,
3798
- created_at: record.createdAt,
3799
- updated_at: record.updatedAt,
3800
- };
3801
- await this.db
3802
- .insert(packageRegistryConnections)
3803
- .values(values)
3804
- .onConflictDoUpdate({
3805
- target: packageRegistryConnections.workspace_id,
3806
- set: {
3807
- entries: values.entries,
3808
- summary: values.summary,
3809
- updated_at: values.updated_at,
3810
- },
3811
- });
3812
- }
3813
- async delete(workspaceId) {
3814
- await this.db
3815
- .delete(packageRegistryConnections)
3816
- .where(eq(packageRegistryConnections.workspace_id, workspaceId));
3817
- }
3818
- }
3819
- /**
3820
- * A workspace's incident-enrichment connection over Postgres (the Drizzle mirror of the
3821
- * Worker's `D1IncidentEnrichmentConnectionRepository`, migration 0013). One row per
3822
- * workspace; both PagerDuty + incident.io credentials live in ONE sealed JSON blob
3823
- * (encrypted by the caller), with a non-secret `summary` presence blob.
3824
- */
3825
- class DrizzleIncidentEnrichmentConnectionRepository {
3826
- db;
3827
- constructor(db) {
3828
- this.db = db;
3829
- }
3830
- async get(workspaceId) {
3831
- const rows = await this.db
3832
- .select()
3833
- .from(incidentEnrichmentConnections)
3834
- .where(eq(incidentEnrichmentConnections.workspace_id, workspaceId))
3835
- .limit(1);
3836
- const row = rows[0];
3837
- if (!row)
3838
- return null;
3839
- return {
3840
- workspaceId: row.workspace_id,
3841
- credentials: row.credentials,
3842
- summary: row.summary,
3843
- createdAt: row.created_at,
3844
- updatedAt: row.updated_at,
3845
- };
3846
- }
3847
- async upsert(record) {
3848
- const values = {
3849
- workspace_id: record.workspaceId,
3850
- credentials: record.credentials,
3851
- summary: record.summary,
3852
- created_at: record.createdAt,
3853
- updated_at: record.updatedAt,
3854
- };
3855
- await this.db
3856
- .insert(incidentEnrichmentConnections)
3857
- .values(values)
3858
- .onConflictDoUpdate({
3859
- target: incidentEnrichmentConnections.workspace_id,
3860
- set: {
3861
- credentials: values.credentials,
3862
- summary: values.summary,
3863
- updated_at: values.updated_at,
3864
- },
3865
- });
3866
- }
3867
- async delete(workspaceId) {
3868
- await this.db
3869
- .delete(incidentEnrichmentConnections)
3870
- .where(eq(incidentEnrichmentConnections.workspace_id, workspaceId));
3871
- }
3872
- }
3873
- /**
3874
- * Per-account (deployment-wide) settings over Postgres (the Drizzle mirror of the Worker's
3875
- * `D1AccountSettingsRepository`, migration 0014). One row per account; `config` + `summary`
3876
- * are non-secret JSON, the ONE sealed `secrets_cipher` blob is encrypted by the caller.
3877
- */
3878
- class DrizzleAccountSettingsRepository {
3879
- db;
3880
- constructor(db) {
3881
- this.db = db;
3882
- }
3883
- async getByAccount(accountId) {
3884
- const rows = await this.db
3885
- .select()
3886
- .from(accountSettings)
3887
- .where(eq(accountSettings.account_id, accountId))
3888
- .limit(1);
3889
- const row = rows[0];
3890
- if (!row)
3891
- return null;
3892
- return {
3893
- accountId: row.account_id,
3894
- config: row.config,
3895
- secretsCipher: row.secrets_cipher,
3896
- summary: row.summary,
3897
- createdAt: row.created_at,
3898
- updatedAt: row.updated_at,
3899
- };
3900
- }
3901
- async upsert(record) {
3902
- const values = {
3903
- account_id: record.accountId,
3904
- config: record.config,
3905
- secrets_cipher: record.secretsCipher,
3906
- summary: record.summary,
3907
- created_at: record.createdAt,
3908
- updated_at: record.updatedAt,
3909
- };
3910
- await this.db
3911
- .insert(accountSettings)
3912
- .values(values)
3913
- .onConflictDoUpdate({
3914
- target: accountSettings.account_id,
3915
- set: {
3916
- config: values.config,
3917
- secrets_cipher: values.secrets_cipher,
3918
- summary: values.summary,
3919
- updated_at: values.updated_at,
3920
- },
3921
- });
3922
- }
3923
- async listAll() {
3924
- const rows = await this.db.select().from(accountSettings);
3925
- return rows.map((row) => ({
3926
- accountId: row.account_id,
3927
- config: row.config,
3928
- secretsCipher: row.secrets_cipher,
3929
- summary: row.summary,
3930
- createdAt: row.created_at,
3931
- updatedAt: row.updated_at,
3932
- }));
3933
- }
3934
- }
3935
- /** The fixed key for the local-mode settings singleton row (one developer's machine). */
3936
- const LOCAL_SETTINGS_ID = 'local';
3937
- /**
3938
- * The local-mode operational settings singleton (warm-pool sizing + per-repo checkout
3939
- * reuse), replacing the old `LOCAL_POOL_*` / `HARNESS_*` env vars. One row, addressed by a
3940
- * fixed id. Local-mode-only — there is no D1 mirror (the warm pool is the local Docker
3941
- * runner's differentiator).
3942
- */
3943
- export class DrizzleLocalSettingsRepository {
3944
- db;
3945
- constructor(db) {
3946
- this.db = db;
3947
- }
3948
- async get() {
3949
- const rows = await this.db
3950
- .select()
3951
- .from(localSettings)
3952
- .where(eq(localSettings.id, LOCAL_SETTINGS_ID))
3953
- .limit(1);
3954
- const row = rows[0];
3955
- if (!row)
3956
- return null;
3957
- return { config: row.config, createdAt: row.created_at, updatedAt: row.updated_at };
3958
- }
3959
- async upsert(record) {
3960
- await this.db
3961
- .insert(localSettings)
3962
- .values({
3963
- id: LOCAL_SETTINGS_ID,
3964
- config: record.config,
3965
- created_at: record.createdAt,
3966
- updated_at: record.updatedAt,
3967
- })
3968
- .onConflictDoUpdate({
3969
- target: localSettings.id,
3970
- set: { config: record.config, updated_at: record.updatedAt },
3971
- });
3972
- }
3973
- }
3974
- function parseReleaseIds(json) {
3975
- try {
3976
- const parsed = JSON.parse(json);
3977
- return Array.isArray(parsed) ? parsed.map((x) => String(x)) : [];
3978
- }
3979
- catch {
3980
- return [];
3981
- }
3982
- }
3983
- function rowToReleaseHealthConfig(row) {
3984
- return {
3985
- workspaceId: row.workspace_id,
3986
- blockId: row.block_id,
3987
- monitorIds: parseReleaseIds(row.monitor_ids),
3988
- sloIds: parseReleaseIds(row.slo_ids),
3989
- envTag: row.env_tag,
3990
- createdAt: row.created_at,
3991
- updatedAt: row.updated_at,
3992
- };
3993
- }
3994
- /**
3995
- * Per-block monitor/SLO mapping for the post-release-health gate over Postgres (the
3996
- * Drizzle mirror of the Worker's `D1ReleaseHealthConfigRepository`, migration 0003).
3997
- */
3998
- class DrizzleReleaseHealthConfigRepository {
3999
- db;
4000
- constructor(db) {
4001
- this.db = db;
4002
- }
4003
- async getByBlock(workspaceId, blockId) {
4004
- const rows = await this.db
4005
- .select()
4006
- .from(releaseHealthConfigs)
4007
- .where(and(eq(releaseHealthConfigs.workspace_id, workspaceId), eq(releaseHealthConfigs.block_id, blockId)))
4008
- .limit(1);
4009
- return rows[0] ? rowToReleaseHealthConfig(rows[0]) : null;
4010
- }
4011
- async listByWorkspace(workspaceId) {
4012
- const rows = await this.db
4013
- .select()
4014
- .from(releaseHealthConfigs)
4015
- .where(eq(releaseHealthConfigs.workspace_id, workspaceId))
4016
- .orderBy(releaseHealthConfigs.block_id);
4017
- return rows.map(rowToReleaseHealthConfig);
4018
- }
4019
- async upsert(record) {
4020
- const values = {
4021
- workspace_id: record.workspaceId,
4022
- block_id: record.blockId,
4023
- monitor_ids: JSON.stringify(record.monitorIds),
4024
- slo_ids: JSON.stringify(record.sloIds),
4025
- env_tag: record.envTag,
4026
- created_at: record.createdAt,
4027
- updated_at: record.updatedAt,
4028
- };
4029
- await this.db
4030
- .insert(releaseHealthConfigs)
4031
- .values(values)
4032
- .onConflictDoUpdate({
4033
- target: [releaseHealthConfigs.workspace_id, releaseHealthConfigs.block_id],
4034
- set: {
4035
- monitor_ids: values.monitor_ids,
4036
- slo_ids: values.slo_ids,
4037
- env_tag: values.env_tag,
4038
- updated_at: values.updated_at,
4039
- },
4040
- });
4041
- }
4042
- async delete(workspaceId, blockId) {
4043
- await this.db
4044
- .delete(releaseHealthConfigs)
4045
- .where(and(eq(releaseHealthConfigs.workspace_id, workspaceId), eq(releaseHealthConfigs.block_id, blockId)));
4046
- }
4047
- }
4048
- /**
4049
- * A service frame's sensitive test credentials over Postgres (the Drizzle mirror of the
4050
- * Worker's `D1TestSecretsRepository`, migration 0044). At most one row per (workspace, block);
4051
- * `credentials` is a sealed envelope of the `TestSecretEntry[]` JSON, `summary` a non-secret
4052
- * `TestSecretRef[]` display blob.
4053
- */
4054
- export class DrizzleTestSecretsRepository {
4055
- db;
4056
- constructor(db) {
4057
- this.db = db;
4058
- }
4059
- async getByBlock(workspaceId, blockId) {
4060
- const rows = await this.db
4061
- .select()
4062
- .from(testSecrets)
4063
- .where(and(eq(testSecrets.workspace_id, workspaceId), eq(testSecrets.block_id, blockId)))
4064
- .limit(1);
4065
- const row = rows[0];
4066
- if (!row)
4067
- return null;
4068
- return {
4069
- workspaceId: row.workspace_id,
4070
- blockId: row.block_id,
4071
- credentials: row.credentials,
4072
- summary: row.summary,
4073
- createdAt: row.created_at,
4074
- updatedAt: row.updated_at,
4075
- };
4076
- }
4077
- async listByWorkspace(workspaceId) {
4078
- const rows = await this.db
4079
- .select()
4080
- .from(testSecrets)
4081
- .where(eq(testSecrets.workspace_id, workspaceId))
4082
- .orderBy(testSecrets.block_id);
4083
- return rows.map((row) => ({
4084
- workspaceId: row.workspace_id,
4085
- blockId: row.block_id,
4086
- credentials: row.credentials,
4087
- summary: row.summary,
4088
- createdAt: row.created_at,
4089
- updatedAt: row.updated_at,
4090
- }));
4091
- }
4092
- async upsert(record) {
4093
- const values = {
4094
- workspace_id: record.workspaceId,
4095
- block_id: record.blockId,
4096
- credentials: record.credentials,
4097
- summary: record.summary,
4098
- created_at: record.createdAt,
4099
- updated_at: record.updatedAt,
4100
- };
4101
- await this.db
4102
- .insert(testSecrets)
4103
- .values(values)
4104
- .onConflictDoUpdate({
4105
- target: [testSecrets.workspace_id, testSecrets.block_id],
4106
- set: {
4107
- credentials: values.credentials,
4108
- summary: values.summary,
4109
- updated_at: values.updated_at,
4110
- },
4111
- });
4112
- }
4113
- async deleteByBlock(workspaceId, blockId) {
4114
- await this.db
4115
- .delete(testSecrets)
4116
- .where(and(eq(testSecrets.workspace_id, workspaceId), eq(testSecrets.block_id, blockId)));
4117
- }
4118
- }
1
+ // Barrel for the Drizzle/Postgres core repositories. The concrete port
2
+ // implementations are split by domain under ./drizzle/*; this module assembles them
3
+ // into the CoreRepositories set and re-exports the handful consumed directly by
4
+ // callers (index.ts, container.ts, the test harness). Split out of a single
5
+ // ~5,000-line module see docs/refactoring-candidates.md #1.
6
+ import { DrizzleBlockRepository, DrizzleServiceFragmentDefaultsRepository, DrizzleServiceRepository, DrizzleWorkspaceMountRepository, DrizzleWorkspaceRepository, } from './drizzle/board.js';
7
+ import { DrizzleAgentRunRepository, DrizzleExecutionRepository, DrizzlePipelineRepository, DrizzlePipelineScheduleRepository, } from './drizzle/execution.js';
8
+ import { DrizzleAccountInvitationRepository, DrizzleAccountRepository, DrizzleEmailConnectionRepository, DrizzleMembershipRepository, DrizzlePasswordResetTokenRepository, DrizzleUserRepository, } from './drizzle/accounts.js';
9
+ import { DrizzleAgentContextSnapshotRepository, DrizzleAgentSearchQueryRepository, DrizzleBinaryArtifactMetadataStore, DrizzleLlmCallMetricRepository, DrizzleProvisioningLogRepository, DrizzleTokenUsageRepository, } from './drizzle/telemetry.js';
10
+ import { DrizzleAccountSettingsRepository, DrizzleModelPresetRepository, DrizzleTrackerSettingsRepository, DrizzleUserSettingsRepository, DrizzleWorkspaceSettingsRepository, } from './drizzle/settings.js';
11
+ import { DrizzleBrainstormSessionRepository, DrizzleClarityReviewRepository, DrizzleConsensusSessionRepository, DrizzleDocInterviewRepository, DrizzleRequirementReviewRepository, } from './drizzle/reviews.js';
12
+ import { DrizzleKaizenGradingRepository, DrizzleKaizenVerifiedComboRepository, } from './drizzle/kaizen.js';
13
+ import { DrizzleInitiativeRepository, DrizzleRiskPolicyRepository, DrizzleSharedStackRepository, } from './drizzle/initiatives.js';
14
+ import { DrizzleIncidentEnrichmentConnectionRepository, DrizzleObservabilityConnectionRepository, DrizzlePackageRegistryConnectionRepository, DrizzleReleaseHealthConfigRepository, DrizzleSubscriptionQuotaCycleRepository, DrizzleTestSecretsRepository, } from './drizzle/connections.js';
4119
15
  /** Build the Drizzle/Postgres-backed core repositories. */
4120
16
  export function createDrizzleRepositories(db, clock) {
4121
17
  return {
@@ -4163,4 +59,10 @@ export function createDrizzleRepositories(db, clock) {
4163
59
  provisioningLogRepository: new DrizzleProvisioningLogRepository(db),
4164
60
  };
4165
61
  }
62
+ // Re-exported for direct consumers (see index.ts / test harness).
63
+ export { DrizzleServiceRepository } from './drizzle/board.js';
64
+ export { DrizzleLocalSettingsRepository, DrizzleWorkspaceSettingsRepository, } from './drizzle/settings.js';
65
+ export { DrizzleClarityReviewRepository, DrizzleDocInterviewRepository, DrizzleRequirementReviewRepository, } from './drizzle/reviews.js';
66
+ export { createDrizzleSandboxDeps } from './drizzle/sandbox.js';
67
+ export { DrizzleTestSecretsRepository } from './drizzle/connections.js';
4166
68
  //# sourceMappingURL=drizzle.js.map