@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.
- package/CHANGELOG.md +170 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +94 -16
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +50 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +37 -49
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- 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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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,
|