@detergent-software/conductor 0.0.0-stage → 1.0.0-dev.10

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 (100) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +295 -2
  3. package/build/claude.js +102 -0
  4. package/build/claude.js.map +1 -0
  5. package/build/cli.js +730 -0
  6. package/build/cli.js.map +1 -0
  7. package/build/client.js +64 -0
  8. package/build/client.js.map +1 -0
  9. package/build/clock.js +17 -0
  10. package/build/clock.js.map +1 -0
  11. package/build/conditions.js +197 -0
  12. package/build/conditions.js.map +1 -0
  13. package/build/config.js +165 -0
  14. package/build/config.js.map +1 -0
  15. package/build/credential.js +52 -0
  16. package/build/credential.js.map +1 -0
  17. package/build/daemon.js +182 -0
  18. package/build/daemon.js.map +1 -0
  19. package/build/errors.js +17 -0
  20. package/build/errors.js.map +1 -0
  21. package/build/events.js +106 -0
  22. package/build/events.js.map +1 -0
  23. package/build/inbox.js +385 -0
  24. package/build/inbox.js.map +1 -0
  25. package/build/jsonc.js +24 -0
  26. package/build/jsonc.js.map +1 -0
  27. package/build/lock.js +64 -0
  28. package/build/lock.js.map +1 -0
  29. package/build/log.js +43 -0
  30. package/build/log.js.map +1 -0
  31. package/build/manager.js +637 -0
  32. package/build/manager.js.map +1 -0
  33. package/build/mod.js +51 -0
  34. package/build/mod.js.map +1 -0
  35. package/build/note.js +71 -0
  36. package/build/note.js.map +1 -0
  37. package/build/notes.js +188 -0
  38. package/build/notes.js.map +1 -0
  39. package/build/notify.js +393 -0
  40. package/build/notify.js.map +1 -0
  41. package/build/paths.js +17 -0
  42. package/build/paths.js.map +1 -0
  43. package/build/profile.js +81 -0
  44. package/build/profile.js.map +1 -0
  45. package/build/pushover.js +67 -0
  46. package/build/pushover.js.map +1 -0
  47. package/build/registry.js +35 -0
  48. package/build/registry.js.map +1 -0
  49. package/build/resolve.js +28 -0
  50. package/build/resolve.js.map +1 -0
  51. package/build/rule-input.js +17 -0
  52. package/build/rule-input.js.map +1 -0
  53. package/build/rules-service.js +391 -0
  54. package/build/rules-service.js.map +1 -0
  55. package/build/rules.js +842 -0
  56. package/build/rules.js.map +1 -0
  57. package/build/run.js +52 -0
  58. package/build/run.js.map +1 -0
  59. package/build/server.js +367 -0
  60. package/build/server.js.map +1 -0
  61. package/build/spend.js +142 -0
  62. package/build/spend.js.map +1 -0
  63. package/build/startup.js +78 -0
  64. package/build/startup.js.map +1 -0
  65. package/build/store.js +724 -0
  66. package/build/store.js.map +1 -0
  67. package/build/tab.js +26 -0
  68. package/build/tab.js.map +1 -0
  69. package/build/token.js +58 -0
  70. package/build/token.js.map +1 -0
  71. package/build/upgrade.js +105 -0
  72. package/build/upgrade.js.map +1 -0
  73. package/build/wait.js +149 -0
  74. package/build/wait.js.map +1 -0
  75. package/build/watchers/ado.js +167 -0
  76. package/build/watchers/ado.js.map +1 -0
  77. package/build/watchers/github.js +82 -0
  78. package/build/watchers/github.js.map +1 -0
  79. package/build/watchers/health.js +42 -0
  80. package/build/watchers/health.js.map +1 -0
  81. package/build/watchers/index.js +226 -0
  82. package/build/watchers/index.js.map +1 -0
  83. package/build/watchers/linear-key.js +17 -0
  84. package/build/watchers/linear-key.js.map +1 -0
  85. package/build/watchers/linear.js +227 -0
  86. package/build/watchers/linear.js.map +1 -0
  87. package/build/watchers/npm.js +35 -0
  88. package/build/watchers/npm.js.map +1 -0
  89. package/build/watchers/source.js +94 -0
  90. package/build/watchers/source.js.map +1 -0
  91. package/build/worktrees.js +143 -0
  92. package/build/worktrees.js.map +1 -0
  93. package/mod/.claude-plugin/marketplace.json +6 -0
  94. package/mod/.claude-plugin/plugin.json +7 -0
  95. package/mod/README.md +17 -0
  96. package/mod/hooks/hooks.json +1 -0
  97. package/mod/hooks/model.ts +395 -0
  98. package/mod/hooks/register.tsx +505 -0
  99. package/mod/types/index.d.ts +57 -0
  100. package/package.json +61 -5
package/build/store.js ADDED
@@ -0,0 +1,724 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { z } from 'zod';
4
+ import { ConductorError } from "./errors.js";
5
+ import { issueLines } from "./jsonc.js";
6
+ import { profileSchema } from "./profile.js";
7
+ import { totalTokens } from "./spend.js";
8
+ // Migrations by schema version (PRAGMA user_version). Append only: a shipped migration never
9
+ // changes. Version 1 is the design note's section 15; the tables later build steps fill start empty.
10
+ // Exported so tests can build a database at an older version.
11
+ export const MIGRATIONS = [
12
+ `
13
+ CREATE TABLE projects (
14
+ name TEXT PRIMARY KEY,
15
+ root TEXT NOT NULL,
16
+ profile_path TEXT NOT NULL,
17
+ protocol INTEGER NOT NULL,
18
+ profile TEXT NOT NULL,
19
+ enabled INTEGER NOT NULL DEFAULT 1,
20
+ added_at TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL
22
+ ) STRICT;
23
+
24
+ CREATE TABLE items (
25
+ project TEXT NOT NULL,
26
+ id TEXT NOT NULL,
27
+ title TEXT,
28
+ state TEXT,
29
+ labels TEXT NOT NULL DEFAULT '[]',
30
+ area TEXT,
31
+ blocked_by TEXT NOT NULL DEFAULT '[]',
32
+ priority INTEGER,
33
+ latest_note TEXT,
34
+ updated_at TEXT NOT NULL,
35
+ PRIMARY KEY (project, id)
36
+ ) STRICT;
37
+
38
+ CREATE TABLE workers (
39
+ id INTEGER PRIMARY KEY,
40
+ project TEXT NOT NULL,
41
+ item TEXT NOT NULL,
42
+ session_id TEXT,
43
+ worktree TEXT,
44
+ branch TEXT,
45
+ pr TEXT,
46
+ phase TEXT,
47
+ status TEXT NOT NULL CHECK (status IN ('running', 'parked', 'stopped-unexpectedly', 'exited')),
48
+ retries INTEGER NOT NULL DEFAULT 0,
49
+ created_at TEXT NOT NULL,
50
+ updated_at TEXT NOT NULL
51
+ ) STRICT;
52
+
53
+ CREATE TABLE waits (
54
+ id INTEGER PRIMARY KEY,
55
+ worker INTEGER NOT NULL,
56
+ condition TEXT NOT NULL,
57
+ created_at TEXT NOT NULL,
58
+ resolved_at TEXT,
59
+ resolution TEXT
60
+ ) STRICT;
61
+
62
+ CREATE TABLE inbox (
63
+ id INTEGER PRIMARY KEY,
64
+ project TEXT,
65
+ item TEXT,
66
+ kind TEXT NOT NULL,
67
+ text TEXT NOT NULL,
68
+ commands TEXT NOT NULL DEFAULT '[]',
69
+ links TEXT NOT NULL DEFAULT '[]',
70
+ raised_at TEXT NOT NULL,
71
+ resolved_at TEXT,
72
+ resolved_by TEXT
73
+ ) STRICT;
74
+
75
+ CREATE TABLE spend (
76
+ worker INTEGER NOT NULL,
77
+ project TEXT NOT NULL,
78
+ item TEXT NOT NULL,
79
+ day TEXT NOT NULL,
80
+ tokens INTEGER NOT NULL DEFAULT 0,
81
+ PRIMARY KEY (worker, day)
82
+ ) STRICT;
83
+
84
+ CREATE TABLE rules (
85
+ id INTEGER PRIMARY KEY,
86
+ source TEXT NOT NULL,
87
+ position INTEGER NOT NULL,
88
+ rule TEXT NOT NULL,
89
+ loaded_at TEXT NOT NULL
90
+ ) STRICT;
91
+
92
+ CREATE TABLE overrides (
93
+ id INTEGER PRIMARY KEY,
94
+ project TEXT,
95
+ kind TEXT NOT NULL,
96
+ value TEXT NOT NULL,
97
+ until TEXT NOT NULL,
98
+ created_at TEXT NOT NULL
99
+ ) STRICT;
100
+
101
+ CREATE TABLE todos (
102
+ id INTEGER PRIMARY KEY,
103
+ project TEXT,
104
+ item TEXT,
105
+ action TEXT NOT NULL,
106
+ after_ticks INTEGER,
107
+ after TEXT,
108
+ created_at TEXT NOT NULL,
109
+ done_at TEXT
110
+ ) STRICT;
111
+
112
+ CREATE TABLE locks (
113
+ name TEXT PRIMARY KEY,
114
+ holder TEXT NOT NULL,
115
+ since TEXT NOT NULL
116
+ ) STRICT;
117
+
118
+ CREATE TABLE events (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ at TEXT NOT NULL,
121
+ project TEXT,
122
+ kind TEXT NOT NULL,
123
+ subject TEXT,
124
+ data TEXT NOT NULL,
125
+ rule TEXT
126
+ ) STRICT;
127
+ CREATE INDEX events_by_project ON events (project, id);
128
+ CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
129
+ CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
130
+ `,
131
+ // 2: the session manager (ATK-309). A worker is `launching` while its worktree is made, and
132
+ // `failed` when that or the session start fails. `name` is its session's and worktree's name
133
+ // (probe-<item> for a probe); `handle` is the short id `claude stop`/`rm`/`attach` take. A
134
+ // resolved wait is delivered when a resume carries it. Spend keeps the four kinds of token, and
135
+ // `tokens` is their sum. `transcripts` holds where reading each transcript stopped.
136
+ `
137
+ CREATE TABLE workers_v2 (
138
+ id INTEGER PRIMARY KEY,
139
+ project TEXT NOT NULL,
140
+ item TEXT NOT NULL,
141
+ name TEXT NOT NULL,
142
+ probe INTEGER NOT NULL DEFAULT 0,
143
+ session_id TEXT,
144
+ handle TEXT,
145
+ worktree TEXT,
146
+ branch TEXT,
147
+ pr TEXT,
148
+ phase TEXT,
149
+ status TEXT NOT NULL CHECK (status IN ('launching', 'running', 'parked', 'stopped-unexpectedly', 'failed', 'exited')),
150
+ retries INTEGER NOT NULL DEFAULT 0,
151
+ attached_at TEXT,
152
+ stopped_at TEXT,
153
+ created_at TEXT NOT NULL,
154
+ updated_at TEXT NOT NULL
155
+ ) STRICT;
156
+ INSERT INTO workers_v2 (id, project, item, name, session_id, worktree, branch, pr, phase, status, retries, created_at, updated_at)
157
+ SELECT id, project, item, item, session_id, worktree, branch, pr, phase, status, retries, created_at, updated_at FROM workers;
158
+ DROP TABLE workers;
159
+ ALTER TABLE workers_v2 RENAME TO workers;
160
+ CREATE UNIQUE INDEX workers_in_flight ON workers (project, name) WHERE status NOT IN ('failed', 'exited');
161
+
162
+ ALTER TABLE waits ADD COLUMN delivered_at TEXT;
163
+ CREATE INDEX waits_by_worker ON waits (worker, id);
164
+
165
+ ALTER TABLE spend ADD COLUMN input INTEGER NOT NULL DEFAULT 0;
166
+ ALTER TABLE spend ADD COLUMN output INTEGER NOT NULL DEFAULT 0;
167
+ ALTER TABLE spend ADD COLUMN cache_read INTEGER NOT NULL DEFAULT 0;
168
+ ALTER TABLE spend ADD COLUMN cache_write INTEGER NOT NULL DEFAULT 0;
169
+
170
+ CREATE TABLE transcripts (
171
+ file TEXT PRIMARY KEY,
172
+ worker INTEGER NOT NULL,
173
+ bytes INTEGER NOT NULL DEFAULT 0,
174
+ recent_ids TEXT NOT NULL DEFAULT '[]'
175
+ ) STRICT;
176
+ `,
177
+ // 3: the inbox (ATK-312). An item may belong to a worker (a stall, a failed session) and may
178
+ // carry the condition whose wait resolves it (an approval, a PR, an npm version).
179
+ `
180
+ ALTER TABLE inbox ADD COLUMN worker INTEGER;
181
+ ALTER TABLE inbox ADD COLUMN condition TEXT;
182
+ CREATE INDEX inbox_open ON inbox (resolved_at, id);
183
+ `,
184
+ // 4: the notifier (ATK-313). One row per inbox item it routed: the channels, the reminders its
185
+ // rule gave it, how many pushes went out and when the last did, and whether quiet hours hold it.
186
+ // `dedupe_key` finds an earlier push of the same item, kind and text.
187
+ `
188
+ CREATE TABLE notify_state (
189
+ inbox INTEGER PRIMARY KEY,
190
+ dedupe_key TEXT NOT NULL,
191
+ channels TEXT NOT NULL DEFAULT '[]',
192
+ remind_after REAL NOT NULL,
193
+ remind_times INTEGER NOT NULL,
194
+ sends INTEGER NOT NULL DEFAULT 0,
195
+ last_sent_at TEXT,
196
+ held_at TEXT
197
+ ) STRICT;
198
+ CREATE INDEX notify_state_dedupe ON notify_state (dedupe_key, last_sent_at);
199
+ CREATE INDEX overrides_by_kind ON overrides (kind, id);
200
+ `,
201
+ ];
202
+ export const SCHEMA_VERSION = MIGRATIONS.length;
203
+ const eventRow = z.object({
204
+ at: z.string(),
205
+ data: z.string().transform((s) => z.record(z.string(), z.unknown()).parse(JSON.parse(s))),
206
+ id: z.number(),
207
+ kind: z.string(),
208
+ project: z.string().nullable(),
209
+ rule: z.string().nullable(),
210
+ subject: z.string().nullable(),
211
+ });
212
+ const projectRow = z.object({
213
+ added_at: z.string(),
214
+ enabled: z.number(),
215
+ name: z.string(),
216
+ profile: z.string(),
217
+ profile_path: z.string(),
218
+ protocol: z.number(),
219
+ root: z.string(),
220
+ updated_at: z.string(),
221
+ });
222
+ // A registered project as the API returns it. A stored profile that this build's schema no longer
223
+ // accepts comes back as `profile: null` with the reason in `problem`, so one stale row doesn't break
224
+ // every listing; `conduct project add` again refreshes it.
225
+ export const projectSchema = z.object({
226
+ addedAt: z.string(),
227
+ enabled: z.boolean(),
228
+ name: z.string(),
229
+ problem: z.string().nullable(),
230
+ profile: profileSchema.nullable(),
231
+ profilePath: z.string(),
232
+ protocol: z.number(),
233
+ root: z.string(),
234
+ updatedAt: z.string(),
235
+ });
236
+ export const WORKER_STATUSES = ['launching', 'running', 'parked', 'stopped-unexpectedly', 'failed', 'exited'];
237
+ // The statuses of a worker the daemon still looks after; `failed` and `exited` are history.
238
+ export const ACTIVE_STATUSES = ['launching', 'running', 'parked', 'stopped-unexpectedly'];
239
+ export const workerSchema = z.object({
240
+ attachedAt: z.string().nullable(),
241
+ branch: z.string().nullable(),
242
+ createdAt: z.string(),
243
+ handle: z.string().nullable(),
244
+ id: z.number(),
245
+ item: z.string(),
246
+ name: z.string(),
247
+ // The newest phase note it wrote through `conduct note` (Started, Plan, PR opened, …).
248
+ phase: z.string().nullable(),
249
+ probe: z.boolean(),
250
+ project: z.string(),
251
+ sessionId: z.string().nullable(),
252
+ status: z.enum(WORKER_STATUSES),
253
+ stoppedAt: z.string().nullable(),
254
+ updatedAt: z.string(),
255
+ worktree: z.string().nullable(),
256
+ });
257
+ export const waitSchema = z.object({
258
+ condition: z.string(),
259
+ createdAt: z.string(),
260
+ deliveredAt: z.string().nullable(),
261
+ id: z.number(),
262
+ resolution: z.string().nullable(),
263
+ resolvedAt: z.string().nullable(),
264
+ worker: z.number(),
265
+ });
266
+ // What waits on Jason (design note, section 7). `ready-to-release`, `queue-drained` and
267
+ // `prod-approval` belong to the scheduler (ATK-315) and `/release-atk`; nothing raises them yet.
268
+ export const INBOX_KINDS = [
269
+ 'plan',
270
+ 'merge',
271
+ 'npm-otp',
272
+ 'prod-approval',
273
+ 'permission',
274
+ 'jason',
275
+ 'human',
276
+ 'scope',
277
+ 'blocked',
278
+ 'abandoned',
279
+ 'failed',
280
+ 'stall',
281
+ 'runaway',
282
+ 'ready-to-release',
283
+ 'queue-drained',
284
+ ];
285
+ export const inboxItemSchema = z.object({
286
+ commands: z.array(z.string()),
287
+ // The full-form condition whose wait resolves it, when one is known.
288
+ condition: z.string().nullable(),
289
+ id: z.number(),
290
+ // The tracker item it is about, such as ATK-312.
291
+ item: z.string().nullable(),
292
+ kind: z.enum(INBOX_KINDS),
293
+ links: z.array(z.string()),
294
+ project: z.string().nullable(),
295
+ raisedAt: z.string(),
296
+ resolvedAt: z.string().nullable(),
297
+ // What resolved it: the condition seen, a later note, or Jason's answer.
298
+ resolvedBy: z.string().nullable(),
299
+ text: z.string(),
300
+ worker: z.number().nullable(),
301
+ });
302
+ export const NO_EXPIRY = '9999-12-31T23:59:59.999Z';
303
+ const workerRow = z.object({
304
+ attached_at: z.string().nullable(),
305
+ branch: z.string().nullable(),
306
+ created_at: z.string(),
307
+ handle: z.string().nullable(),
308
+ id: z.number(),
309
+ item: z.string(),
310
+ name: z.string(),
311
+ phase: z.string().nullable(),
312
+ probe: z.number(),
313
+ project: z.string(),
314
+ session_id: z.string().nullable(),
315
+ status: z.enum(WORKER_STATUSES),
316
+ stopped_at: z.string().nullable(),
317
+ updated_at: z.string(),
318
+ worktree: z.string().nullable(),
319
+ });
320
+ const waitRow = z.object({
321
+ condition: z.string(),
322
+ created_at: z.string(),
323
+ delivered_at: z.string().nullable(),
324
+ id: z.number(),
325
+ resolution: z.string().nullable(),
326
+ resolved_at: z.string().nullable(),
327
+ worker: z.number(),
328
+ });
329
+ const inboxRow = z.object({
330
+ commands: z.string(),
331
+ condition: z.string().nullable(),
332
+ id: z.number(),
333
+ item: z.string().nullable(),
334
+ kind: z.enum(INBOX_KINDS),
335
+ links: z.string(),
336
+ project: z.string().nullable(),
337
+ raised_at: z.string(),
338
+ resolved_at: z.string().nullable(),
339
+ resolved_by: z.string().nullable(),
340
+ text: z.string(),
341
+ worker: z.number().nullable(),
342
+ });
343
+ const notifyRow = z.object({
344
+ channels: z.string(),
345
+ dedupe_key: z.string(),
346
+ held_at: z.string().nullable(),
347
+ inbox: z.number(),
348
+ last_sent_at: z.string().nullable(),
349
+ remind_after: z.number(),
350
+ remind_times: z.number(),
351
+ sends: z.number(),
352
+ });
353
+ const overrideRow = z.object({
354
+ created_at: z.string(),
355
+ id: z.number(),
356
+ kind: z.string(),
357
+ project: z.string().nullable(),
358
+ until: z.string(),
359
+ value: z.string(),
360
+ });
361
+ const PATCH_KEYS = [
362
+ 'attachedAt',
363
+ 'branch',
364
+ 'handle',
365
+ 'phase',
366
+ 'sessionId',
367
+ 'status',
368
+ 'stoppedAt',
369
+ 'worktree',
370
+ ];
371
+ const WORKER_COLUMNS = {
372
+ attachedAt: 'attached_at',
373
+ branch: 'branch',
374
+ handle: 'handle',
375
+ phase: 'phase',
376
+ sessionId: 'session_id',
377
+ status: 'status',
378
+ stoppedAt: 'stopped_at',
379
+ worktree: 'worktree',
380
+ };
381
+ export function openStore(file, now = () => new Date()) {
382
+ const db = new DatabaseSync(file);
383
+ try {
384
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
385
+ migrate(db, file);
386
+ }
387
+ catch (error) {
388
+ db.close();
389
+ throw error;
390
+ }
391
+ const emitter = new EventEmitter();
392
+ emitter.setMaxListeners(0);
393
+ const insertEvent = db.prepare('INSERT INTO events (at, project, kind, subject, data, rule) VALUES (?, ?, ?, ?, ?, ?) RETURNING *');
394
+ const selectAfter = db.prepare('SELECT * FROM events WHERE id > ? ORDER BY id LIMIT ?');
395
+ // The only writer to `events`. Everything the daemon observes or does goes through here.
396
+ const appendEvent = (event) => {
397
+ const row = eventRow.parse(insertEvent.get(now().toISOString(), event.project ?? null, event.kind, event.subject ?? null, JSON.stringify(event.data ?? {}), event.rule ?? null));
398
+ emitter.emit('event', row);
399
+ return row;
400
+ };
401
+ const toProject = (row) => {
402
+ const r = projectRow.parse(row);
403
+ const stored = parseStoredProfile(r.profile);
404
+ return {
405
+ addedAt: r.added_at,
406
+ enabled: r.enabled === 1,
407
+ name: r.name,
408
+ problem: stored.problem,
409
+ profile: stored.profile,
410
+ profilePath: r.profile_path,
411
+ protocol: r.protocol,
412
+ root: r.root,
413
+ updatedAt: r.updated_at,
414
+ };
415
+ };
416
+ const getWorker = (id) => {
417
+ const row = db.prepare('SELECT * FROM workers WHERE id = ?').get(id);
418
+ return row === undefined ? null : toWorker(row);
419
+ };
420
+ return {
421
+ addSpend: ({ day, item, project, usage, worker }) => {
422
+ db.prepare(`INSERT INTO spend (worker, project, item, day, tokens, input, output, cache_read, cache_write)
423
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
424
+ ON CONFLICT (worker, day) DO UPDATE SET
425
+ tokens = tokens + excluded.tokens, input = input + excluded.input, output = output + excluded.output,
426
+ cache_read = cache_read + excluded.cache_read, cache_write = cache_write + excluded.cache_write`).run(worker, project, item, day, totalTokens(usage), usage.input, usage.output, usage.cacheRead, usage.cacheWrite);
427
+ },
428
+ addWait: (worker, condition) => toWait(db
429
+ .prepare('INSERT INTO waits (worker, condition, created_at) VALUES (?, ?, ?) RETURNING *')
430
+ .get(worker, condition, now().toISOString())),
431
+ appendEvent,
432
+ close: () => db.close(),
433
+ closeWaits: (worker, resolution) => {
434
+ const at = now().toISOString();
435
+ db.prepare(`UPDATE waits SET resolved_at = coalesce(resolved_at, ?), resolution = coalesce(resolution, ?), delivered_at = ?
436
+ WHERE worker = ? AND delivered_at IS NULL`).run(at, resolution, at, worker);
437
+ },
438
+ deliverWaits: (ids) => {
439
+ const deliver = db.prepare('UPDATE waits SET delivered_at = ? WHERE id = ? AND resolved_at IS NOT NULL');
440
+ const at = now().toISOString();
441
+ for (const id of ids)
442
+ deliver.run(at, id);
443
+ },
444
+ eventsAfter: (id, limit = 500) => selectAfter.all(id, limit).map((row) => eventRow.parse(row)),
445
+ getInbox: (id) => {
446
+ const row = db.prepare('SELECT * FROM inbox WHERE id = ?').get(id);
447
+ return row === undefined ? null : toInbox(row);
448
+ },
449
+ getNotifyState: (inbox) => {
450
+ const row = db.prepare('SELECT * FROM notify_state WHERE inbox = ?').get(inbox);
451
+ return row === undefined ? null : toNotifyState(row);
452
+ },
453
+ getWait: (id) => {
454
+ const row = db.prepare('SELECT * FROM waits WHERE id = ?').get(id);
455
+ return row === undefined ? null : toWait(row);
456
+ },
457
+ getWorker,
458
+ inboxOf: (worker) => db.prepare('SELECT * FROM inbox WHERE worker = ? ORDER BY id').all(worker).map(toInbox),
459
+ insertInbox: ({ commands, condition, item, kind, links, project, text, worker }) => toInbox(db
460
+ .prepare(`INSERT INTO inbox (project, item, worker, kind, text, commands, links, condition, raised_at)
461
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
462
+ .get(project, item, worker, kind, text, JSON.stringify(commands), JSON.stringify(links), condition, now().toISOString())),
463
+ insertWorker: ({ item, name, probe, project }) => {
464
+ const at = now().toISOString();
465
+ try {
466
+ return toWorker(db
467
+ .prepare(`INSERT INTO workers (project, item, name, probe, status, created_at, updated_at)
468
+ VALUES (?, ?, ?, ?, 'launching', ?, ?) RETURNING *`)
469
+ .get(project, item, name, probe ? 1 : 0, at, at));
470
+ }
471
+ catch (error) {
472
+ if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) {
473
+ throw new ConductorError(`${name} is already in flight in ${project}`);
474
+ }
475
+ throw error;
476
+ }
477
+ },
478
+ lastNotified: (dedupeKey, since) => {
479
+ const row = db
480
+ .prepare('SELECT * FROM notify_state WHERE dedupe_key = ? AND sends > 0 AND last_sent_at >= ? ORDER BY last_sent_at DESC, inbox DESC LIMIT 1')
481
+ .get(dedupeKey, since);
482
+ return row === undefined ? null : toNotifyState(row);
483
+ },
484
+ latestEventId: () => z.object({ id: z.number() }).parse(db.prepare('SELECT coalesce(max(id), 0) AS id FROM events').get()).id,
485
+ latestInbox: (project, item, kind) => {
486
+ const row = db
487
+ .prepare('SELECT * FROM inbox WHERE project IS ? AND upper(item) = upper(?) AND kind = ? ORDER BY id DESC LIMIT 1')
488
+ .get(project, item, kind);
489
+ return row === undefined ? null : toInbox(row);
490
+ },
491
+ listProjects: () => db.prepare('SELECT * FROM projects ORDER BY name').all().map(toProject),
492
+ listWorkers: (statuses = WORKER_STATUSES) => db
493
+ .prepare(`SELECT * FROM workers WHERE status IN (${statuses.map(() => '?').join(', ')}) ORDER BY id`)
494
+ .all(...statuses)
495
+ .map(toWorker),
496
+ liveNotifyStates: () => db
497
+ .prepare(`SELECT n.* FROM notify_state n JOIN inbox i ON i.id = n.inbox
498
+ WHERE i.resolved_at IS NULL OR n.held_at IS NOT NULL ORDER BY n.inbox`)
499
+ .all()
500
+ .map(toNotifyState),
501
+ liveOverride: (kind, project = null) => {
502
+ const row = db
503
+ .prepare('SELECT * FROM overrides WHERE kind = ? AND project IS ? AND until > ? ORDER BY id DESC LIMIT 1')
504
+ .get(kind, project, now().toISOString());
505
+ return row === undefined ? null : toOverride(row);
506
+ },
507
+ onEvent: (listener) => {
508
+ emitter.on('event', listener);
509
+ return () => emitter.off('event', listener);
510
+ },
511
+ openInbox: () => db.prepare('SELECT * FROM inbox WHERE resolved_at IS NULL ORDER BY id').all().map(toInbox),
512
+ openWaits: () => db.prepare('SELECT * FROM waits WHERE resolved_at IS NULL ORDER BY id').all().map(toWait),
513
+ resolveInbox: (id, by) => {
514
+ const row = db
515
+ .prepare('UPDATE inbox SET resolved_at = ?, resolved_by = ? WHERE id = ? AND resolved_at IS NULL RETURNING *')
516
+ .get(now().toISOString(), by, id);
517
+ return row === undefined ? null : toInbox(row);
518
+ },
519
+ resolveWait: (id, resolution) => {
520
+ const row = db
521
+ .prepare('UPDATE waits SET resolved_at = ?, resolution = ? WHERE id = ? AND resolved_at IS NULL RETURNING *')
522
+ .get(now().toISOString(), resolution, id);
523
+ if (row === undefined)
524
+ throw new ConductorError(`wait ${id} is not open`);
525
+ return toWait(row);
526
+ },
527
+ saveItemNote: ({ item, phase, project }) => {
528
+ db.prepare(`INSERT INTO items (project, id, latest_note, updated_at) VALUES (?, ?, ?, ?)
529
+ ON CONFLICT (project, id) DO UPDATE SET latest_note = excluded.latest_note, updated_at = excluded.updated_at`).run(project, item, phase, now().toISOString());
530
+ },
531
+ saveNotifyState: (s) => {
532
+ db.prepare(`INSERT INTO notify_state (inbox, dedupe_key, channels, remind_after, remind_times, sends, last_sent_at, held_at)
533
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
534
+ ON CONFLICT (inbox) DO UPDATE SET dedupe_key = excluded.dedupe_key, channels = excluded.channels,
535
+ remind_after = excluded.remind_after, remind_times = excluded.remind_times, sends = excluded.sends,
536
+ last_sent_at = excluded.last_sent_at, held_at = excluded.held_at`).run(s.inbox, s.dedupeKey, JSON.stringify(s.channels), s.remindAfterMinutes, s.remindTimes, s.sends, s.lastSentAt, s.heldAt);
537
+ },
538
+ saveTranscript: (file, worker, cursor) => {
539
+ db.prepare(`INSERT INTO transcripts (file, worker, bytes, recent_ids) VALUES (?, ?, ?, ?)
540
+ ON CONFLICT (file) DO UPDATE SET worker = excluded.worker, bytes = excluded.bytes, recent_ids = excluded.recent_ids`).run(file, worker, cursor.bytes, JSON.stringify(cursor.recentIds));
541
+ },
542
+ setInboxCondition: (id, condition) => {
543
+ db.prepare('UPDATE inbox SET condition = ? WHERE id = ?').run(condition, id);
544
+ },
545
+ setOverride: ({ kind, project, until, value }) => toOverride(db
546
+ .prepare('INSERT INTO overrides (project, kind, value, until, created_at) VALUES (?, ?, ?, ?, ?) RETURNING *')
547
+ .get(project, kind, value, until, now().toISOString())),
548
+ spendByWorker: (day) => {
549
+ const rows = db
550
+ .prepare(`SELECT worker, sum(tokens) AS total, sum(CASE WHEN day = ? THEN tokens ELSE 0 END) AS today
551
+ FROM spend GROUP BY worker`)
552
+ .all(day);
553
+ const totals = z.array(z.object({ today: z.number(), total: z.number(), worker: z.number() })).parse(rows);
554
+ return new Map(totals.map((t) => [t.worker, { today: t.today, total: t.total }]));
555
+ },
556
+ tables: () => db
557
+ .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
558
+ .all()
559
+ .map((row) => z.object({ name: z.string() }).parse(row).name),
560
+ transaction: (write) => {
561
+ db.exec('BEGIN IMMEDIATE');
562
+ try {
563
+ const result = write();
564
+ db.exec('COMMIT');
565
+ return result;
566
+ }
567
+ catch (error) {
568
+ db.exec('ROLLBACK');
569
+ throw error;
570
+ }
571
+ },
572
+ transcriptCursor: (file) => {
573
+ const row = db.prepare('SELECT bytes, recent_ids FROM transcripts WHERE file = ?').get(file);
574
+ if (row === undefined)
575
+ return null;
576
+ const r = z.object({ bytes: z.number(), recent_ids: z.string() }).parse(row);
577
+ return { bytes: r.bytes, recentIds: z.array(z.string()).parse(JSON.parse(r.recent_ids)) };
578
+ },
579
+ updateWorker: (id, patch) => {
580
+ const keys = PATCH_KEYS.filter((k) => patch[k] !== undefined);
581
+ const row = db
582
+ .prepare(`UPDATE workers SET ${[...keys.map((k) => `${WORKER_COLUMNS[k]} = ?`), 'updated_at = ?'].join(', ')}
583
+ WHERE id = ? RETURNING *`)
584
+ .get(...keys.map((k) => patch[k] ?? null), now().toISOString(), id);
585
+ if (row === undefined)
586
+ throw new ConductorError(`no worker ${id}`);
587
+ return toWorker(row);
588
+ },
589
+ // One row per project name. Adding a project again (or from another checkout path) updates it.
590
+ upsertProject: ({ profile, profilePath, root }) => {
591
+ const at = now().toISOString();
592
+ const existing = db.prepare('SELECT 1 FROM projects WHERE name = ?').get(profile.name) !== undefined;
593
+ const project = toProject(db
594
+ .prepare(`INSERT INTO projects (name, root, profile_path, protocol, profile, added_at, updated_at)
595
+ VALUES (?, ?, ?, ?, ?, ?, ?)
596
+ ON CONFLICT (name) DO UPDATE SET
597
+ root = excluded.root, profile_path = excluded.profile_path, protocol = excluded.protocol,
598
+ profile = excluded.profile, updated_at = excluded.updated_at
599
+ RETURNING *`)
600
+ .get(profile.name, root, profilePath, profile.protocol, JSON.stringify(profile), at, at));
601
+ appendEvent({
602
+ data: { profilePath, root },
603
+ kind: existing ? 'project.updated' : 'project.added',
604
+ project: project.name,
605
+ });
606
+ return { created: !existing, project };
607
+ },
608
+ waitsOf: (worker) => db.prepare('SELECT * FROM waits WHERE worker = ? AND delivered_at IS NULL ORDER BY id').all(worker).map(toWait),
609
+ workerEvents: (worker, limit = 20) => db
610
+ .prepare("SELECT * FROM events WHERE json_extract(data, '$.worker') = ? ORDER BY id DESC LIMIT ?")
611
+ .all(worker, limit)
612
+ .map((row) => eventRow.parse(row)),
613
+ workerSpend: (worker) => z.object({ tokens: z.number() }).parse(db.prepare('SELECT coalesce(sum(input + output + cache_write), 0) AS tokens FROM spend WHERE worker = ?').get(worker))
614
+ .tokens,
615
+ };
616
+ }
617
+ // The daemon and a CLI running without it can open a new database at the same moment, so the
618
+ // version is read again under the write lock before anything is created.
619
+ function migrate(db, file) {
620
+ if (schemaVersion(db, file) === SCHEMA_VERSION)
621
+ return;
622
+ db.exec('BEGIN IMMEDIATE');
623
+ try {
624
+ for (let next = schemaVersion(db, file); next < SCHEMA_VERSION; next++) {
625
+ db.exec(MIGRATIONS[next] ?? '');
626
+ db.exec(`PRAGMA user_version = ${next + 1}`);
627
+ }
628
+ db.exec('COMMIT');
629
+ }
630
+ catch (error) {
631
+ db.exec('ROLLBACK');
632
+ throw error;
633
+ }
634
+ }
635
+ function parseStoredProfile(text) {
636
+ let value;
637
+ try {
638
+ value = JSON.parse(text);
639
+ }
640
+ catch {
641
+ return { problem: 'the stored profile is not JSON', profile: null };
642
+ }
643
+ const result = profileSchema.safeParse(value);
644
+ return result.success
645
+ ? { problem: null, profile: result.data }
646
+ : { problem: `the stored profile no longer validates: ${issueLines(result.error).join('; ')}`, profile: null };
647
+ }
648
+ function schemaVersion(db, file) {
649
+ const { user_version: version } = z.object({ user_version: z.number() }).parse(db.prepare('PRAGMA user_version').get());
650
+ if (version > SCHEMA_VERSION) {
651
+ throw new ConductorError(`${file} has schema version ${version}; this conductor knows up to ${SCHEMA_VERSION}`, [
652
+ 'upgrade the conductor (npm install --global @detergent-software/conductor@dev, or @latest; a linked checkout: pull and rebuild) before starting it',
653
+ ]);
654
+ }
655
+ return version;
656
+ }
657
+ function toInbox(row) {
658
+ const r = inboxRow.parse(row);
659
+ const strings = z.array(z.string());
660
+ return {
661
+ commands: strings.parse(JSON.parse(r.commands)),
662
+ condition: r.condition,
663
+ id: r.id,
664
+ item: r.item,
665
+ kind: r.kind,
666
+ links: strings.parse(JSON.parse(r.links)),
667
+ project: r.project,
668
+ raisedAt: r.raised_at,
669
+ resolvedAt: r.resolved_at,
670
+ resolvedBy: r.resolved_by,
671
+ text: r.text,
672
+ worker: r.worker,
673
+ };
674
+ }
675
+ function toNotifyState(row) {
676
+ const r = notifyRow.parse(row);
677
+ return {
678
+ channels: z.array(z.string()).parse(JSON.parse(r.channels)),
679
+ dedupeKey: r.dedupe_key,
680
+ heldAt: r.held_at,
681
+ inbox: r.inbox,
682
+ lastSentAt: r.last_sent_at,
683
+ remindAfterMinutes: r.remind_after,
684
+ remindTimes: r.remind_times,
685
+ sends: r.sends,
686
+ };
687
+ }
688
+ function toOverride(row) {
689
+ const r = overrideRow.parse(row);
690
+ return { createdAt: r.created_at, id: r.id, kind: r.kind, project: r.project, until: r.until, value: r.value };
691
+ }
692
+ function toWait(row) {
693
+ const r = waitRow.parse(row);
694
+ return {
695
+ condition: r.condition,
696
+ createdAt: r.created_at,
697
+ deliveredAt: r.delivered_at,
698
+ id: r.id,
699
+ resolution: r.resolution,
700
+ resolvedAt: r.resolved_at,
701
+ worker: r.worker,
702
+ };
703
+ }
704
+ function toWorker(row) {
705
+ const r = workerRow.parse(row);
706
+ return {
707
+ attachedAt: r.attached_at,
708
+ branch: r.branch,
709
+ createdAt: r.created_at,
710
+ handle: r.handle,
711
+ id: r.id,
712
+ item: r.item,
713
+ name: r.name,
714
+ phase: r.phase,
715
+ probe: r.probe === 1,
716
+ project: r.project,
717
+ sessionId: r.session_id,
718
+ status: r.status,
719
+ stoppedAt: r.stopped_at,
720
+ updatedAt: r.updated_at,
721
+ worktree: r.worktree,
722
+ };
723
+ }
724
+ //# sourceMappingURL=store.js.map