@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.21

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 (104) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +361 -11
  3. package/build/broker-cli.js +125 -0
  4. package/build/broker-cli.js.map +1 -0
  5. package/build/broker.js +154 -0
  6. package/build/broker.js.map +1 -0
  7. package/build/cli.js +610 -44
  8. package/build/cli.js.map +1 -1
  9. package/build/clock.js +17 -0
  10. package/build/clock.js.map +1 -0
  11. package/build/conditions.js +19 -13
  12. package/build/conditions.js.map +1 -1
  13. package/build/config.js +94 -1
  14. package/build/config.js.map +1 -1
  15. package/build/console.js +99 -0
  16. package/build/console.js.map +1 -0
  17. package/build/credential.js +52 -0
  18. package/build/credential.js.map +1 -0
  19. package/build/daemon.js +153 -2
  20. package/build/daemon.js.map +1 -1
  21. package/build/events.js +106 -0
  22. package/build/events.js.map +1 -0
  23. package/build/history.js +630 -0
  24. package/build/history.js.map +1 -0
  25. package/build/inbox.js +386 -0
  26. package/build/inbox.js.map +1 -0
  27. package/build/manager.js +94 -16
  28. package/build/manager.js.map +1 -1
  29. package/build/mod.js +51 -0
  30. package/build/mod.js.map +1 -0
  31. package/build/note.js +71 -0
  32. package/build/note.js.map +1 -0
  33. package/build/notes.js +172 -0
  34. package/build/notes.js.map +1 -0
  35. package/build/notify.js +422 -0
  36. package/build/notify.js.map +1 -0
  37. package/build/overrides.js +83 -0
  38. package/build/overrides.js.map +1 -0
  39. package/build/paths.js +10 -1
  40. package/build/paths.js.map +1 -1
  41. package/build/profile.js +68 -2
  42. package/build/profile.js.map +1 -1
  43. package/build/pushover.js +67 -0
  44. package/build/pushover.js.map +1 -0
  45. package/build/queue.js +183 -0
  46. package/build/queue.js.map +1 -0
  47. package/build/registry.js +17 -10
  48. package/build/registry.js.map +1 -1
  49. package/build/rule-actions.js +444 -0
  50. package/build/rule-actions.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 +402 -0
  54. package/build/rules-service.js.map +1 -0
  55. package/build/rules.js +850 -0
  56. package/build/rules.js.map +1 -0
  57. package/build/scheduler.js +274 -0
  58. package/build/scheduler.js.map +1 -0
  59. package/build/server.js +294 -0
  60. package/build/server.js.map +1 -1
  61. package/build/spend.js +50 -0
  62. package/build/spend.js.map +1 -1
  63. package/build/store.js +452 -4
  64. package/build/store.js.map +1 -1
  65. package/build/trackers.js +12 -0
  66. package/build/trackers.js.map +1 -0
  67. package/build/upgrade.js +36 -22
  68. package/build/upgrade.js.map +1 -1
  69. package/build/wait.js +35 -41
  70. package/build/wait.js.map +1 -1
  71. package/build/watchers/ado-api.js +55 -0
  72. package/build/watchers/ado-api.js.map +1 -0
  73. package/build/watchers/ado.js +37 -49
  74. package/build/watchers/ado.js.map +1 -1
  75. package/build/watchers/boards-queue.js +120 -0
  76. package/build/watchers/boards-queue.js.map +1 -0
  77. package/build/watchers/boards-writes.js +83 -0
  78. package/build/watchers/boards-writes.js.map +1 -0
  79. package/build/watchers/boards.js +267 -0
  80. package/build/watchers/boards.js.map +1 -0
  81. package/build/watchers/github.js +5 -2
  82. package/build/watchers/github.js.map +1 -1
  83. package/build/watchers/index.js +1 -0
  84. package/build/watchers/index.js.map +1 -1
  85. package/build/watchers/linear-api.js +55 -0
  86. package/build/watchers/linear-api.js.map +1 -0
  87. package/build/watchers/linear-key.js +6 -45
  88. package/build/watchers/linear-key.js.map +1 -1
  89. package/build/watchers/linear-labels.js +43 -0
  90. package/build/watchers/linear-labels.js.map +1 -0
  91. package/build/watchers/linear-queue.js +96 -0
  92. package/build/watchers/linear-queue.js.map +1 -0
  93. package/build/watchers/linear.js +29 -18
  94. package/build/watchers/linear.js.map +1 -1
  95. package/build/why.js +78 -0
  96. package/build/why.js.map +1 -0
  97. package/mod/.claude-plugin/marketplace.json +6 -0
  98. package/mod/.claude-plugin/plugin.json +7 -0
  99. package/mod/README.md +17 -0
  100. package/mod/hooks/hooks.json +1 -0
  101. package/mod/hooks/model.ts +457 -0
  102. package/mod/hooks/register.tsx +543 -0
  103. package/mod/types/index.d.ts +57 -0
  104. package/package.json +6 -1
package/build/store.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { existsSync } from 'node:fs';
2
3
  import { DatabaseSync } from 'node:sqlite';
3
4
  import { z } from 'zod';
4
5
  import { ConductorError } from "./errors.js";
@@ -173,6 +174,61 @@ export const MIGRATIONS = [
173
174
  bytes INTEGER NOT NULL DEFAULT 0,
174
175
  recent_ids TEXT NOT NULL DEFAULT '[]'
175
176
  ) STRICT;
177
+ `,
178
+ // 3: the inbox (ATK-312). An item may belong to a worker (a stall, a failed session) and may
179
+ // carry the condition whose wait resolves it (an approval, a PR, an npm version).
180
+ `
181
+ ALTER TABLE inbox ADD COLUMN worker INTEGER;
182
+ ALTER TABLE inbox ADD COLUMN condition TEXT;
183
+ CREATE INDEX inbox_open ON inbox (resolved_at, id);
184
+ `,
185
+ // 4: the notifier (ATK-313). One row per inbox item it routed: the channels, the reminders its
186
+ // rule gave it, how many pushes went out and when the last did, and whether quiet hours hold it.
187
+ // `dedupe_key` finds an earlier push of the same item, kind and text.
188
+ `
189
+ CREATE TABLE notify_state (
190
+ inbox INTEGER PRIMARY KEY,
191
+ dedupe_key TEXT NOT NULL,
192
+ channels TEXT NOT NULL DEFAULT '[]',
193
+ remind_after REAL NOT NULL,
194
+ remind_times INTEGER NOT NULL,
195
+ sends INTEGER NOT NULL DEFAULT 0,
196
+ last_sent_at TEXT,
197
+ held_at TEXT
198
+ ) STRICT;
199
+ CREATE INDEX notify_state_dedupe ON notify_state (dedupe_key, last_sent_at);
200
+ CREATE INDEX overrides_by_kind ON overrides (kind, id);
201
+ `,
202
+ // 5: the scheduler (ATK-315). `items` holds each project's last queue read: the state's type
203
+ // (backlog, started, completed …), the tracker project it is in, when it was created, and
204
+ // `blocked_by` as the blockers still open, so a read that finds none left says it unblocked.
205
+ `
206
+ ALTER TABLE items ADD COLUMN state_type TEXT;
207
+ ALTER TABLE items ADD COLUMN tracker_project TEXT;
208
+ ALTER TABLE items ADD COLUMN created_at TEXT;
209
+ `,
210
+ // 6: rule actions (ATK-315). A state rule fires once per subject while it holds: `rule_state`
211
+ // keeps the ones that hold now, so a restart doesn't fire them again. An override says who set
212
+ // it (a command, or a rule's ref), so a rule ends only the pause it set.
213
+ `
214
+ CREATE TABLE rule_state (
215
+ rule TEXT NOT NULL,
216
+ subject TEXT NOT NULL,
217
+ fired_at TEXT NOT NULL,
218
+ PRIMARY KEY (rule, subject)
219
+ ) STRICT;
220
+ ALTER TABLE overrides ADD COLUMN set_by TEXT;
221
+ `,
222
+ // 7: the port broker (ATK-318). Each worktree's slot and the ports it gives (base + slot), so
223
+ // worktrees of every project get ports no other one has. Its locks (chrome) go in `locks`.
224
+ `
225
+ CREATE TABLE ports (
226
+ worktree TEXT PRIMARY KEY,
227
+ project TEXT NOT NULL,
228
+ slot INTEGER NOT NULL,
229
+ ports TEXT NOT NULL,
230
+ assigned_at TEXT NOT NULL
231
+ ) STRICT;
176
232
  `,
177
233
  ];
178
234
  export const SCHEMA_VERSION = MIGRATIONS.length;
@@ -220,6 +276,8 @@ export const workerSchema = z.object({
220
276
  id: z.number(),
221
277
  item: z.string(),
222
278
  name: z.string(),
279
+ // The newest phase note it wrote through `conduct note` (Started, Plan, PR opened, …).
280
+ phase: z.string().nullable(),
223
281
  probe: z.boolean(),
224
282
  project: z.string(),
225
283
  sessionId: z.string().nullable(),
@@ -237,6 +295,68 @@ export const waitSchema = z.object({
237
295
  resolvedAt: z.string().nullable(),
238
296
  worker: z.number(),
239
297
  });
298
+ // What waits on Jason (design note, section 7). The scheduler raises `ready-to-release` and
299
+ // `queue-drained`; `prod-approval` belongs to `/release-atk`, which raises none yet. `console` is the
300
+ // console's finding on an escalation that needs Jason (`conduct escalations done --ask`).
301
+ export const INBOX_KINDS = [
302
+ 'plan',
303
+ 'merge',
304
+ 'npm-otp',
305
+ 'prod-approval',
306
+ 'permission',
307
+ 'jason',
308
+ 'human',
309
+ 'scope',
310
+ 'blocked',
311
+ 'abandoned',
312
+ 'failed',
313
+ 'stall',
314
+ 'runaway',
315
+ 'ready-to-release',
316
+ 'queue-drained',
317
+ 'console',
318
+ ];
319
+ export const inboxItemSchema = z.object({
320
+ commands: z.array(z.string()),
321
+ // The full-form condition whose wait resolves it, when one is known.
322
+ condition: z.string().nullable(),
323
+ id: z.number(),
324
+ // The tracker item it is about, such as ATK-312.
325
+ item: z.string().nullable(),
326
+ kind: z.enum(INBOX_KINDS),
327
+ links: z.array(z.string()),
328
+ project: z.string().nullable(),
329
+ raisedAt: z.string(),
330
+ resolvedAt: z.string().nullable(),
331
+ // What resolved it: the condition seen, a later note, or Jason's answer.
332
+ resolvedBy: z.string().nullable(),
333
+ text: z.string(),
334
+ worker: z.number().nullable(),
335
+ });
336
+ export const NO_EXPIRY = '9999-12-31T23:59:59.999Z';
337
+ const queueItemRow = z.object({
338
+ area: z.string().nullable(),
339
+ blocked_by: z.string(),
340
+ created_at: z.string().nullable(),
341
+ id: z.string(),
342
+ labels: z.string(),
343
+ priority: z.number().nullable(),
344
+ state_type: z.string().nullable(),
345
+ title: z.string().nullable(),
346
+ tracker_project: z.string().nullable(),
347
+ });
348
+ // A checkout's dev ports from the broker. A main checkout has slot 0 and isn't stored, so its
349
+ // `assignedAt` is null.
350
+ export const portAssignmentSchema = z.object({
351
+ assignedAt: z.string().nullable(),
352
+ ports: z.record(z.string(), z.number()),
353
+ project: z.string(),
354
+ slot: z.number(),
355
+ worktree: z.string(),
356
+ });
357
+ // A broker lock: who holds it, and when they last acquired it.
358
+ export const lockSchema = z.object({ holder: z.string(), name: z.string(), since: z.string() });
359
+ const portsRow = z.object({ assigned_at: z.string(), ports: z.string(), project: z.string(), slot: z.number(), worktree: z.string() });
240
360
  const workerRow = z.object({
241
361
  attached_at: z.string().nullable(),
242
362
  branch: z.string().nullable(),
@@ -245,6 +365,7 @@ const workerRow = z.object({
245
365
  id: z.number(),
246
366
  item: z.string(),
247
367
  name: z.string(),
368
+ phase: z.string().nullable(),
248
369
  probe: z.number(),
249
370
  project: z.string(),
250
371
  session_id: z.string().nullable(),
@@ -262,10 +383,44 @@ const waitRow = z.object({
262
383
  resolved_at: z.string().nullable(),
263
384
  worker: z.number(),
264
385
  });
386
+ const inboxRow = z.object({
387
+ commands: z.string(),
388
+ condition: z.string().nullable(),
389
+ id: z.number(),
390
+ item: z.string().nullable(),
391
+ kind: z.enum(INBOX_KINDS),
392
+ links: z.string(),
393
+ project: z.string().nullable(),
394
+ raised_at: z.string(),
395
+ resolved_at: z.string().nullable(),
396
+ resolved_by: z.string().nullable(),
397
+ text: z.string(),
398
+ worker: z.number().nullable(),
399
+ });
400
+ const notifyRow = z.object({
401
+ channels: z.string(),
402
+ dedupe_key: z.string(),
403
+ held_at: z.string().nullable(),
404
+ inbox: z.number(),
405
+ last_sent_at: z.string().nullable(),
406
+ remind_after: z.number(),
407
+ remind_times: z.number(),
408
+ sends: z.number(),
409
+ });
410
+ const overrideRow = z.object({
411
+ created_at: z.string(),
412
+ id: z.number(),
413
+ kind: z.string(),
414
+ project: z.string().nullable(),
415
+ set_by: z.string().nullable(),
416
+ until: z.string(),
417
+ value: z.string(),
418
+ });
265
419
  const PATCH_KEYS = [
266
420
  'attachedAt',
267
421
  'branch',
268
422
  'handle',
423
+ 'phase',
269
424
  'sessionId',
270
425
  'status',
271
426
  'stoppedAt',
@@ -275,16 +430,27 @@ const WORKER_COLUMNS = {
275
430
  attachedAt: 'attached_at',
276
431
  branch: 'branch',
277
432
  handle: 'handle',
433
+ phase: 'phase',
278
434
  sessionId: 'session_id',
279
435
  status: 'status',
280
436
  stoppedAt: 'stopped_at',
281
437
  worktree: 'worktree',
282
438
  };
283
- export function openStore(file, now = () => new Date()) {
284
- const db = new DatabaseSync(file);
439
+ // `readOnly` opens the database as it is, for a reader beside a daemon that may be older (conduct
440
+ // history): it never migrates, so it can't move the schema out from under a running daemon.
441
+ export function openStore(file, now = () => new Date(), { readOnly = false } = {}) {
442
+ if (readOnly && !existsSync(file))
443
+ throw new ConductorError(`${file} doesn't exist: the conductor hasn't recorded anything yet`);
444
+ const db = new DatabaseSync(file, { readOnly });
285
445
  try {
286
- db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
287
- migrate(db, file);
446
+ if (readOnly) {
447
+ db.exec('PRAGMA busy_timeout = 5000;');
448
+ schemaVersion(db, file);
449
+ }
450
+ else {
451
+ db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;');
452
+ migrate(db, file);
453
+ }
288
454
  }
289
455
  catch (error) {
290
456
  db.close();
@@ -327,6 +493,9 @@ export function openStore(file, now = () => new Date()) {
327
493
  tokens = tokens + excluded.tokens, input = input + excluded.input, output = output + excluded.output,
328
494
  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);
329
495
  },
496
+ addTodo: ({ action, after, item, project }) => z
497
+ .object({ id: z.number() })
498
+ .parse(db.prepare('INSERT INTO todos (project, item, action, after, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id').get(project, item, action, after, now().toISOString())).id,
330
499
  addWait: (worker, condition) => toWait(db
331
500
  .prepare('INSERT INTO waits (worker, condition, created_at) VALUES (?, ?, ?) RETURNING *')
332
501
  .get(worker, condition, now().toISOString())),
@@ -337,18 +506,76 @@ export function openStore(file, now = () => new Date()) {
337
506
  db.prepare(`UPDATE waits SET resolved_at = coalesce(resolved_at, ?), resolution = coalesce(resolution, ?), delivered_at = ?
338
507
  WHERE worker = ? AND delivered_at IS NULL`).run(at, resolution, at, worker);
339
508
  },
509
+ deleteLock: (name) => {
510
+ db.prepare('DELETE FROM locks WHERE name = ?').run(name);
511
+ },
512
+ deletePorts: (worktree) => {
513
+ const row = db.prepare('DELETE FROM ports WHERE worktree = ? RETURNING *').get(worktree);
514
+ return row === undefined ? null : toPorts(row);
515
+ },
340
516
  deliverWaits: (ids) => {
341
517
  const deliver = db.prepare('UPDATE waits SET delivered_at = ? WHERE id = ? AND resolved_at IS NOT NULL');
342
518
  const at = now().toISOString();
343
519
  for (const id of ids)
344
520
  deliver.run(at, id);
345
521
  },
522
+ dueTodos: (at) => z
523
+ .array(z.object({ action: z.string(), after: z.string(), created_at: z.string(), id: z.number(), item: z.string().nullable(), project: z.string().nullable() }))
524
+ .parse(db.prepare('SELECT id, project, item, action, after, created_at FROM todos WHERE done_at IS NULL AND after <= ? ORDER BY id').all(at))
525
+ .map(({ created_at: createdAt, ...t }) => ({ ...t, createdAt })),
526
+ escalationHandled: (id) => db.prepare("SELECT 1 FROM events WHERE kind = 'console.handled' AND json_extract(data, '$.cause') = ? LIMIT 1").get(id) !== undefined,
346
527
  eventsAfter: (id, limit = 500) => selectAfter.all(id, limit).map((row) => eventRow.parse(row)),
528
+ eventsBetween: ({ since, until }, { kinds, project, subjects, workers } = {}) => {
529
+ const where = [];
530
+ const args = [];
531
+ const add = (clause, arg) => {
532
+ where.push(clause);
533
+ args.push(arg);
534
+ };
535
+ add('at < ?', until);
536
+ if (since !== null)
537
+ add('at >= ?', since);
538
+ if (kinds)
539
+ add('kind IN (SELECT value FROM json_each(?))', JSON.stringify(kinds));
540
+ if (project !== undefined)
541
+ add('project IS ?', project);
542
+ if (subjects)
543
+ add('upper(subject) IN (SELECT upper(value) FROM json_each(?))', JSON.stringify(subjects));
544
+ if (workers)
545
+ add("json_extract(data, '$.worker') IN (SELECT value FROM json_each(?))", JSON.stringify(workers));
546
+ return db.prepare(`SELECT * FROM events WHERE ${where.join(' AND ')} ORDER BY id`).all(...args).map((row) => eventRow.parse(row));
547
+ },
548
+ finishTodo: (id) => {
549
+ db.prepare('UPDATE todos SET done_at = ? WHERE id = ?').run(now().toISOString(), id);
550
+ },
551
+ getEvent: (id) => {
552
+ const row = db.prepare('SELECT * FROM events WHERE id = ?').get(id);
553
+ return row === undefined ? null : eventRow.parse(row);
554
+ },
555
+ getInbox: (id) => {
556
+ const row = db.prepare('SELECT * FROM inbox WHERE id = ?').get(id);
557
+ return row === undefined ? null : toInbox(row);
558
+ },
559
+ getLock: (name) => {
560
+ const row = db.prepare('SELECT * FROM locks WHERE name = ?').get(name);
561
+ return row === undefined ? null : lockSchema.parse(row);
562
+ },
563
+ getNotifyState: (inbox) => {
564
+ const row = db.prepare('SELECT * FROM notify_state WHERE inbox = ?').get(inbox);
565
+ return row === undefined ? null : toNotifyState(row);
566
+ },
347
567
  getWait: (id) => {
348
568
  const row = db.prepare('SELECT * FROM waits WHERE id = ?').get(id);
349
569
  return row === undefined ? null : toWait(row);
350
570
  },
351
571
  getWorker,
572
+ holdRule: (rule, subject) => db.prepare('INSERT INTO rule_state (rule, subject, fired_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(rule, subject, now().toISOString()).changes > 0,
573
+ inboxOf: (worker) => db.prepare('SELECT * FROM inbox WHERE worker = ? ORDER BY id').all(worker).map(toInbox),
574
+ inboxRaisedBetween: (since, until) => db.prepare('SELECT * FROM inbox WHERE raised_at >= ? AND raised_at < ? ORDER BY id').all(since, until).map(toInbox),
575
+ insertInbox: ({ commands, condition, item, kind, links, project, text, worker }) => toInbox(db
576
+ .prepare(`INSERT INTO inbox (project, item, worker, kind, text, commands, links, condition, raised_at)
577
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`)
578
+ .get(project, item, worker, kind, text, JSON.stringify(commands), JSON.stringify(links), condition, now().toISOString())),
352
579
  insertWorker: ({ item, name, probe, project }) => {
353
580
  const at = now().toISOString();
354
581
  try {
@@ -364,17 +591,107 @@ export function openStore(file, now = () => new Date()) {
364
591
  throw error;
365
592
  }
366
593
  },
594
+ itemTitles: () => new Map(z
595
+ .array(z.object({ id: z.string(), project: z.string(), title: z.string() }))
596
+ .parse(db.prepare('SELECT project, id, title FROM items WHERE title IS NOT NULL').all())
597
+ .map((r) => [`${r.project}|${r.id.toUpperCase()}`, r.title])),
598
+ lastNotified: (dedupeKey, since) => {
599
+ const row = db
600
+ .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')
601
+ .get(dedupeKey, since);
602
+ return row === undefined ? null : toNotifyState(row);
603
+ },
604
+ latestEvent: (kinds) => {
605
+ const row = kinds === undefined
606
+ ? db.prepare('SELECT * FROM events ORDER BY id DESC LIMIT 1').get()
607
+ : db.prepare('SELECT * FROM events WHERE kind IN (SELECT value FROM json_each(?)) ORDER BY id DESC LIMIT 1').get(JSON.stringify(kinds));
608
+ return row === undefined ? null : eventRow.parse(row);
609
+ },
367
610
  latestEventId: () => z.object({ id: z.number() }).parse(db.prepare('SELECT coalesce(max(id), 0) AS id FROM events').get()).id,
611
+ latestInbox: (project, item, kind) => {
612
+ const row = db
613
+ .prepare('SELECT * FROM inbox WHERE project IS ? AND upper(item) = upper(?) AND kind = ? ORDER BY id DESC LIMIT 1')
614
+ .get(project, item, kind);
615
+ return row === undefined ? null : toInbox(row);
616
+ },
617
+ latestTier: (project, item) => {
618
+ const row = db
619
+ .prepare(`SELECT json_extract(data, '$.tier') AS tier FROM events
620
+ WHERE kind = 'note.posted' AND project = ? AND upper(subject) = upper(?) AND json_extract(data, '$.tier') IS NOT NULL
621
+ ORDER BY id DESC LIMIT 1`)
622
+ .get(project, item);
623
+ return row === undefined ? null : z.object({ tier: z.string() }).parse(row).tier;
624
+ },
625
+ listLocks: () => z.array(lockSchema).parse(db.prepare('SELECT * FROM locks ORDER BY name').all()),
368
626
  listProjects: () => db.prepare('SELECT * FROM projects ORDER BY name').all().map(toProject),
369
627
  listWorkers: (statuses = WORKER_STATUSES) => db
370
628
  .prepare(`SELECT * FROM workers WHERE status IN (${statuses.map(() => '?').join(', ')}) ORDER BY id`)
371
629
  .all(...statuses)
372
630
  .map(toWorker),
631
+ liveNotifyStates: () => db
632
+ .prepare(`SELECT n.* FROM notify_state n JOIN inbox i ON i.id = n.inbox
633
+ WHERE i.resolved_at IS NULL OR n.held_at IS NOT NULL ORDER BY n.inbox`)
634
+ .all()
635
+ .map(toNotifyState),
636
+ liveOverride: (kind, project = null) => {
637
+ const row = db
638
+ .prepare('SELECT * FROM overrides WHERE kind = ? AND project IS ? AND until > ? ORDER BY id DESC LIMIT 1')
639
+ .get(kind, project, now().toISOString());
640
+ return row === undefined ? null : toOverride(row);
641
+ },
642
+ liveOverrides: () => db
643
+ .prepare(`SELECT * FROM overrides o WHERE until > ? AND id = (
644
+ SELECT max(id) FROM overrides n WHERE n.kind = o.kind AND n.project IS o.project AND n.until > ?)
645
+ ORDER BY id`)
646
+ .all(now().toISOString(), now().toISOString())
647
+ .map(toOverride),
373
648
  onEvent: (listener) => {
374
649
  emitter.on('event', listener);
375
650
  return () => emitter.off('event', listener);
376
651
  },
652
+ openEscalations: (since) => {
653
+ // Two scans, not a correlated subquery: `events` has no index on kind, and adding one would be
654
+ // a migration an older daemon refuses to open (a downgrade).
655
+ const handled = new Set(z
656
+ .array(z.object({ cause: z.number().nullable() }))
657
+ .parse(db.prepare("SELECT json_extract(data, '$.cause') AS cause FROM events WHERE kind = 'console.handled'").all())
658
+ .map((r) => r.cause));
659
+ return db
660
+ .prepare("SELECT * FROM events WHERE kind = 'console.escalated' AND at >= ? ORDER BY id")
661
+ .all(since)
662
+ .map((row) => eventRow.parse(row))
663
+ .filter((e) => !handled.has(e.id));
664
+ },
665
+ openInbox: () => db.prepare('SELECT * FROM inbox WHERE resolved_at IS NULL ORDER BY id').all().map(toInbox),
377
666
  openWaits: () => db.prepare('SELECT * FROM waits WHERE resolved_at IS NULL ORDER BY id').all().map(toWait),
667
+ portAssignments: () => db.prepare('SELECT * FROM ports ORDER BY slot, worktree').all().map(toPorts),
668
+ queueItems: (project) => db
669
+ .prepare('SELECT * FROM items WHERE project = ? AND state_type IS NOT NULL ORDER BY id')
670
+ .all(project)
671
+ .map((row) => {
672
+ const r = queueItemRow.parse(row);
673
+ const strings = z.array(z.string());
674
+ return {
675
+ area: r.area,
676
+ blockedBy: strings.parse(JSON.parse(r.blocked_by)),
677
+ createdAt: r.created_at,
678
+ id: r.id,
679
+ labels: strings.parse(JSON.parse(r.labels)),
680
+ priority: r.priority,
681
+ stateType: r.state_type,
682
+ title: r.title,
683
+ trackerProject: r.tracker_project,
684
+ };
685
+ }),
686
+ releaseRule: (rule, subject) => {
687
+ db.prepare('DELETE FROM rule_state WHERE rule = ? AND subject = ?').run(rule, subject);
688
+ },
689
+ resolveInbox: (id, by) => {
690
+ const row = db
691
+ .prepare('UPDATE inbox SET resolved_at = ?, resolved_by = ? WHERE id = ? AND resolved_at IS NULL RETURNING *')
692
+ .get(now().toISOString(), by, id);
693
+ return row === undefined ? null : toInbox(row);
694
+ },
378
695
  resolveWait: (id, resolution) => {
379
696
  const row = db
380
697
  .prepare('UPDATE waits SET resolved_at = ?, resolution = ? WHERE id = ? AND resolved_at IS NULL RETURNING *')
@@ -383,10 +700,75 @@ export function openStore(file, now = () => new Date()) {
383
700
  throw new ConductorError(`wait ${id} is not open`);
384
701
  return toWait(row);
385
702
  },
703
+ ruleEvents: (item, limit = 10) => db
704
+ .prepare('SELECT * FROM events WHERE upper(subject) = upper(?) AND rule IS NOT NULL ORDER BY id DESC LIMIT ?')
705
+ .all(item, limit)
706
+ .map((row) => eventRow.parse(row)),
707
+ ruleStates: () => z.array(z.object({ rule: z.string(), subject: z.string() })).parse(db.prepare('SELECT rule, subject FROM rule_state ORDER BY rule, subject').all()),
708
+ saveItemNote: ({ item, phase, project }) => {
709
+ db.prepare(`INSERT INTO items (project, id, latest_note, updated_at) VALUES (?, ?, ?, ?)
710
+ ON CONFLICT (project, id) DO UPDATE SET latest_note = excluded.latest_note, updated_at = excluded.updated_at`).run(project, item, phase, now().toISOString());
711
+ },
712
+ saveLock: (name, holder) => lockSchema.parse(db
713
+ .prepare('INSERT INTO locks (name, holder, since) VALUES (?, ?, ?) ON CONFLICT (name) DO UPDATE SET holder = excluded.holder, since = excluded.since RETURNING *')
714
+ .get(name, holder, now().toISOString())),
715
+ saveNotifyState: (s) => {
716
+ db.prepare(`INSERT INTO notify_state (inbox, dedupe_key, channels, remind_after, remind_times, sends, last_sent_at, held_at)
717
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
718
+ ON CONFLICT (inbox) DO UPDATE SET dedupe_key = excluded.dedupe_key, channels = excluded.channels,
719
+ remind_after = excluded.remind_after, remind_times = excluded.remind_times, sends = excluded.sends,
720
+ 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);
721
+ },
722
+ savePorts: ({ ports, project, slot, worktree }) => toPorts(db
723
+ .prepare('INSERT INTO ports (worktree, project, slot, ports, assigned_at) VALUES (?, ?, ?, ?, ?) RETURNING *')
724
+ .get(worktree, project, slot, JSON.stringify(ports), now().toISOString())),
725
+ saveQueueItems: (project, items) => {
726
+ const save = db.prepare(`INSERT INTO items (project, id, title, state_type, labels, area, blocked_by, priority, tracker_project, created_at, updated_at)
727
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
728
+ ON CONFLICT (project, id) DO UPDATE SET title = excluded.title, state_type = excluded.state_type,
729
+ labels = excluded.labels, area = excluded.area, blocked_by = excluded.blocked_by, priority = excluded.priority,
730
+ tracker_project = excluded.tracker_project, created_at = excluded.created_at, updated_at = excluded.updated_at`);
731
+ const at = now().toISOString();
732
+ // An item the read no longer lists isn't open: it keeps its note, not its blockers, so a
733
+ // reopened item doesn't read as unblocked against blockers from weeks ago.
734
+ const gone = db.prepare(`UPDATE items SET state_type = NULL, blocked_by = '[]', updated_at = ?
735
+ WHERE project = ? AND state_type IS NOT NULL AND id NOT IN (SELECT value FROM json_each(?))`);
736
+ db.exec('BEGIN IMMEDIATE');
737
+ try {
738
+ for (const i of items) {
739
+ save.run(project, i.id, i.title, i.stateType, JSON.stringify(i.labels), i.area, JSON.stringify(i.blockedBy), i.priority, i.trackerProject, i.createdAt, at);
740
+ }
741
+ gone.run(at, project, JSON.stringify(items.map((i) => i.id)));
742
+ db.exec('COMMIT');
743
+ }
744
+ catch (error) {
745
+ db.exec('ROLLBACK');
746
+ throw error;
747
+ }
748
+ },
386
749
  saveTranscript: (file, worker, cursor) => {
387
750
  db.prepare(`INSERT INTO transcripts (file, worker, bytes, recent_ids) VALUES (?, ?, ?, ?)
388
751
  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));
389
752
  },
753
+ setInboxCondition: (id, condition) => {
754
+ db.prepare('UPDATE inbox SET condition = ? WHERE id = ?').run(condition, id);
755
+ },
756
+ setOverride: ({ kind, project, setBy, until, value }) => toOverride(db
757
+ .prepare('INSERT INTO overrides (project, kind, value, until, created_at, set_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *')
758
+ .get(project, kind, value, until, now().toISOString(), setBy)),
759
+ spendBetween: (fromDay, toDay) => z
760
+ .array(z.object({
761
+ cache_read: z.number(),
762
+ cache_write: z.number(),
763
+ day: z.string(),
764
+ input: z.number(),
765
+ item: z.string(),
766
+ output: z.number(),
767
+ project: z.string(),
768
+ worker: z.number(),
769
+ }))
770
+ .parse(db.prepare('SELECT * FROM spend WHERE day >= ? AND day <= ? ORDER BY day, worker').all(fromDay, toDay))
771
+ .map(({ cache_read: cacheRead, cache_write: cacheWrite, ...r }) => ({ ...r, cacheRead, cacheWrite })),
390
772
  spendByWorker: (day) => {
391
773
  const rows = db
392
774
  .prepare(`SELECT worker, sum(tokens) AS total, sum(CASE WHEN day = ? THEN tokens ELSE 0 END) AS today
@@ -395,6 +777,19 @@ export function openStore(file, now = () => new Date()) {
395
777
  const totals = z.array(z.object({ today: z.number(), total: z.number(), worker: z.number() })).parse(rows);
396
778
  return new Map(totals.map((t) => [t.worker, { today: t.today, total: t.total }]));
397
779
  },
780
+ spendSummary: (day) => {
781
+ const sum = 'sum(input + output + cache_write)';
782
+ const rows = (sql, ...args) => z.array(z.object({ key: z.union([z.string(), z.number()]).transform(String), tokens: z.number() })).parse(db.prepare(sql).all(...args));
783
+ const byWorker = rows(`SELECT worker AS key, ${sum} AS tokens FROM spend GROUP BY worker`);
784
+ const byItem = rows(`SELECT project || '|' || upper(item) AS key, ${sum} AS tokens FROM spend GROUP BY project, upper(item)`);
785
+ const byProject = rows(`SELECT project AS key, ${sum} AS tokens FROM spend WHERE day = ? GROUP BY project`, day);
786
+ return {
787
+ byItem: new Map(byItem.map((r) => [r.key, r.tokens])),
788
+ byProjectToday: new Map(byProject.map((r) => [r.key, r.tokens])),
789
+ byWorker: new Map(byWorker.map((r) => [Number(r.key), r.tokens])),
790
+ today: byProject.reduce((n, r) => n + r.tokens, 0),
791
+ };
792
+ },
398
793
  tables: () => db
399
794
  .prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
400
795
  .all()
@@ -448,10 +843,23 @@ export function openStore(file, now = () => new Date()) {
448
843
  return { created: !existing, project };
449
844
  },
450
845
  waitsOf: (worker) => db.prepare('SELECT * FROM waits WHERE worker = ? AND delivered_at IS NULL ORDER BY id').all(worker).map(toWait),
846
+ waitsRegisteredBetween: (since, until) => db
847
+ .prepare(`SELECT w.*, k.item, k.probe, k.project FROM waits w JOIN workers k ON k.id = w.worker
848
+ WHERE w.created_at >= ? AND w.created_at < ? ORDER BY w.id`)
849
+ .all(since, until)
850
+ .map((row) => {
851
+ const r = z.looseObject({ item: z.string(), probe: z.number(), project: z.string() }).parse(row);
852
+ return { ...toWait(row), item: r.item, probe: r.probe === 1, project: r.project };
853
+ }),
451
854
  workerEvents: (worker, limit = 20) => db
452
855
  .prepare("SELECT * FROM events WHERE json_extract(data, '$.worker') = ? ORDER BY id DESC LIMIT ?")
453
856
  .all(worker, limit)
454
857
  .map((row) => eventRow.parse(row)),
858
+ // A worker gone before `since` was last updated then (failing or exiting is its last change).
859
+ workersBetween: (since, until) => db
860
+ .prepare(`SELECT * FROM workers WHERE created_at < ? AND (status IN (SELECT value FROM json_each(?)) OR updated_at >= ?) ORDER BY id`)
861
+ .all(until, JSON.stringify(ACTIVE_STATUSES), since)
862
+ .map(toWorker),
455
863
  };
456
864
  }
457
865
  // The daemon and a CLI running without it can open a new database at the same moment, so the
@@ -494,6 +902,45 @@ function schemaVersion(db, file) {
494
902
  }
495
903
  return version;
496
904
  }
905
+ function toInbox(row) {
906
+ const r = inboxRow.parse(row);
907
+ const strings = z.array(z.string());
908
+ return {
909
+ commands: strings.parse(JSON.parse(r.commands)),
910
+ condition: r.condition,
911
+ id: r.id,
912
+ item: r.item,
913
+ kind: r.kind,
914
+ links: strings.parse(JSON.parse(r.links)),
915
+ project: r.project,
916
+ raisedAt: r.raised_at,
917
+ resolvedAt: r.resolved_at,
918
+ resolvedBy: r.resolved_by,
919
+ text: r.text,
920
+ worker: r.worker,
921
+ };
922
+ }
923
+ function toNotifyState(row) {
924
+ const r = notifyRow.parse(row);
925
+ return {
926
+ channels: z.array(z.string()).parse(JSON.parse(r.channels)),
927
+ dedupeKey: r.dedupe_key,
928
+ heldAt: r.held_at,
929
+ inbox: r.inbox,
930
+ lastSentAt: r.last_sent_at,
931
+ remindAfterMinutes: r.remind_after,
932
+ remindTimes: r.remind_times,
933
+ sends: r.sends,
934
+ };
935
+ }
936
+ function toOverride(row) {
937
+ const r = overrideRow.parse(row);
938
+ return { createdAt: r.created_at, id: r.id, kind: r.kind, project: r.project, setBy: r.set_by, until: r.until, value: r.value };
939
+ }
940
+ function toPorts(row) {
941
+ const r = portsRow.parse(row);
942
+ return { assignedAt: r.assigned_at, ports: z.record(z.string(), z.number()).parse(JSON.parse(r.ports)), project: r.project, slot: r.slot, worktree: r.worktree };
943
+ }
497
944
  function toWait(row) {
498
945
  const r = waitRow.parse(row);
499
946
  return {
@@ -516,6 +963,7 @@ function toWorker(row) {
516
963
  id: r.id,
517
964
  item: r.item,
518
965
  name: r.name,
966
+ phase: r.phase,
519
967
  probe: r.probe === 1,
520
968
  project: r.project,
521
969
  sessionId: r.session_id,