@contextgit/store 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interface.d.ts +26 -1
- package/dist/interface.d.ts.map +1 -1
- package/dist/local/index.d.ts +26 -1
- package/dist/local/index.d.ts.map +1 -1
- package/dist/local/index.js +216 -6
- package/dist/local/index.js.map +1 -1
- package/dist/local/local-store.test.js +642 -2
- package/dist/local/local-store.test.js.map +1 -1
- package/dist/local/migrations.d.ts +11 -0
- package/dist/local/migrations.d.ts.map +1 -1
- package/dist/local/migrations.js +118 -1
- package/dist/local/migrations.js.map +1 -1
- package/dist/local/plan-nodes.test.d.ts +2 -0
- package/dist/local/plan-nodes.test.d.ts.map +1 -0
- package/dist/local/plan-nodes.test.js +291 -0
- package/dist/local/plan-nodes.test.js.map +1 -0
- package/dist/local/queries.d.ts +103 -2
- package/dist/local/queries.d.ts.map +1 -1
- package/dist/local/queries.js +506 -16
- package/dist/local/queries.js.map +1 -1
- package/dist/local/schema.d.ts +8 -0
- package/dist/local/schema.d.ts.map +1 -1
- package/dist/local/schema.js +88 -0
- package/dist/local/schema.js.map +1 -1
- package/dist/local/thread-archive.test.d.ts +2 -0
- package/dist/local/thread-archive.test.d.ts.map +1 -0
- package/dist/local/thread-archive.test.js +203 -0
- package/dist/local/thread-archive.test.js.map +1 -0
- package/dist/remote/index.d.ts +15 -0
- package/dist/remote/index.d.ts.map +1 -1
- package/dist/remote/index.js +44 -0
- package/dist/remote/index.js.map +1 -1
- package/dist/supabase/index.d.ts +15 -0
- package/dist/supabase/index.d.ts.map +1 -1
- package/dist/supabase/index.js +45 -2
- package/dist/supabase/index.js.map +1 -1
- package/package.json +2 -2
package/dist/local/queries.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// All SQL queries for LocalStore.
|
|
2
2
|
// Dates are stored as INTEGER (Unix ms) and converted to/from Date at this layer.
|
|
3
3
|
// IDs are generated by the caller (nanoid).
|
|
4
|
+
import { nanoid } from 'nanoid';
|
|
5
|
+
import { classifyThread, normalizeThreadSubject } from '@contextgit/core';
|
|
4
6
|
// ─── Row → domain type converters ───────────────────────────────────────────
|
|
5
7
|
function toProject(row) {
|
|
6
8
|
return {
|
|
@@ -53,8 +55,10 @@ function toThread(row) {
|
|
|
53
55
|
branchId: row.branch_id,
|
|
54
56
|
description: row.description,
|
|
55
57
|
status: row.status,
|
|
58
|
+
kind: row.kind,
|
|
56
59
|
workflowType: row.workflow_type ?? undefined,
|
|
57
60
|
openedInCommit: row.opened_in_commit,
|
|
61
|
+
lastTouchedCommit: row.last_touched_commit ?? undefined,
|
|
58
62
|
closedInCommit: row.closed_in_commit ?? undefined,
|
|
59
63
|
closedNote: row.closed_note ?? undefined,
|
|
60
64
|
createdAt: new Date(row.created_at),
|
|
@@ -76,6 +80,37 @@ function toClaim(row) {
|
|
|
76
80
|
threadId: row.thread_id ?? undefined,
|
|
77
81
|
};
|
|
78
82
|
}
|
|
83
|
+
function toTraceEntry(row) {
|
|
84
|
+
return {
|
|
85
|
+
id: row.id,
|
|
86
|
+
projectId: row.project_id,
|
|
87
|
+
branchId: row.branch_id,
|
|
88
|
+
note: row.note,
|
|
89
|
+
gitCommitSha: row.git_commit_sha ?? undefined,
|
|
90
|
+
createdAt: new Date(row.created_at),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function toArchivedThread(row) {
|
|
94
|
+
return {
|
|
95
|
+
...toThread(row),
|
|
96
|
+
archivedAt: new Date(row.archived_at),
|
|
97
|
+
archivedReason: row.archived_reason,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function toPlanNode(row) {
|
|
101
|
+
return {
|
|
102
|
+
id: row.id,
|
|
103
|
+
projectId: row.project_id,
|
|
104
|
+
parentId: row.parent_id ?? undefined,
|
|
105
|
+
level: row.level,
|
|
106
|
+
title: row.title,
|
|
107
|
+
status: row.status,
|
|
108
|
+
position: row.position,
|
|
109
|
+
gitCommitSha: row.git_commit_sha ?? undefined,
|
|
110
|
+
createdAt: new Date(row.created_at),
|
|
111
|
+
completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
79
114
|
function toAgent(row) {
|
|
80
115
|
return {
|
|
81
116
|
id: row.id,
|
|
@@ -136,28 +171,100 @@ export class Queries {
|
|
|
136
171
|
// Threads
|
|
137
172
|
insertThread: db.prepare(`
|
|
138
173
|
INSERT INTO threads
|
|
139
|
-
(id, project_id, branch_id, description, status, workflow_type,
|
|
140
|
-
opened_in_commit, created_at)
|
|
174
|
+
(id, project_id, branch_id, description, status, kind, workflow_type,
|
|
175
|
+
opened_in_commit, last_touched_commit, created_at)
|
|
141
176
|
VALUES
|
|
142
|
-
(@id, @project_id, @branch_id, @description, 'open', @workflow_type,
|
|
143
|
-
@opened_in_commit, @created_at)
|
|
177
|
+
(@id, @project_id, @branch_id, @description, 'open', @kind, @workflow_type,
|
|
178
|
+
@opened_in_commit, @opened_in_commit, @created_at)
|
|
144
179
|
`),
|
|
145
180
|
syncThread: db.prepare(`
|
|
146
181
|
INSERT OR IGNORE INTO threads
|
|
147
|
-
(id, project_id, branch_id, description, status, workflow_type,
|
|
148
|
-
opened_in_commit, created_at)
|
|
182
|
+
(id, project_id, branch_id, description, status, kind, workflow_type,
|
|
183
|
+
opened_in_commit, last_touched_commit, created_at)
|
|
149
184
|
VALUES
|
|
150
|
-
(@id, @project_id, @branch_id, @description, @status, @workflow_type,
|
|
151
|
-
@opened_in_commit, @created_at)
|
|
185
|
+
(@id, @project_id, @branch_id, @description, @status, @kind, @workflow_type,
|
|
186
|
+
@opened_in_commit, @last_touched_commit, @created_at)
|
|
152
187
|
`),
|
|
153
188
|
closeThread: db.prepare(`
|
|
154
189
|
UPDATE threads
|
|
155
190
|
SET status = 'closed', closed_in_commit = ?, closed_note = ?
|
|
156
191
|
WHERE id = ?
|
|
192
|
+
`),
|
|
193
|
+
updateThreadLastTouched: db.prepare(`
|
|
194
|
+
UPDATE threads SET last_touched_commit = ? WHERE id = ?
|
|
157
195
|
`),
|
|
158
196
|
selectOpenThreads: db.prepare(`SELECT * FROM threads WHERE project_id = ? AND status = 'open' ORDER BY created_at ASC`),
|
|
159
197
|
selectOpenThreadsByBranch: db.prepare(`SELECT * FROM threads WHERE branch_id = ? AND status = 'open' ORDER BY created_at ASC`),
|
|
160
198
|
reassignThreads: db.prepare(`UPDATE threads SET branch_id = ? WHERE branch_id = ? AND status = 'open'`),
|
|
199
|
+
// thread_archive (03 DELTA)
|
|
200
|
+
insertThreadArchive: db.prepare(`
|
|
201
|
+
INSERT INTO thread_archive
|
|
202
|
+
(id, project_id, branch_id, description, status, kind, workflow_type,
|
|
203
|
+
opened_in_commit, last_touched_commit, closed_in_commit, closed_note,
|
|
204
|
+
created_at, updated_at, archived_at, archived_reason)
|
|
205
|
+
SELECT
|
|
206
|
+
id, project_id, branch_id, description, status, kind, workflow_type,
|
|
207
|
+
opened_in_commit, last_touched_commit, ?, ?,
|
|
208
|
+
created_at, updated_at, ?, ?
|
|
209
|
+
FROM threads WHERE id = ?
|
|
210
|
+
`),
|
|
211
|
+
deleteThread: db.prepare(`DELETE FROM threads WHERE id = ?`),
|
|
212
|
+
selectArchivedThread: db.prepare(`SELECT * FROM thread_archive WHERE id = ?`),
|
|
213
|
+
restoreFromArchive: db.prepare(`
|
|
214
|
+
INSERT INTO threads
|
|
215
|
+
(id, project_id, branch_id, description, status, kind, workflow_type,
|
|
216
|
+
opened_in_commit, last_touched_commit, closed_in_commit, closed_note,
|
|
217
|
+
created_at, updated_at)
|
|
218
|
+
SELECT
|
|
219
|
+
id, project_id, branch_id, description, 'open', kind, workflow_type,
|
|
220
|
+
opened_in_commit, last_touched_commit, NULL, NULL,
|
|
221
|
+
created_at, ?
|
|
222
|
+
FROM thread_archive WHERE id = ?
|
|
223
|
+
`),
|
|
224
|
+
deleteFromArchive: db.prepare(`DELETE FROM thread_archive WHERE id = ?`),
|
|
225
|
+
selectThread: db.prepare(`SELECT * FROM threads WHERE id = ?`),
|
|
226
|
+
listArchivedByProject: db.prepare(`
|
|
227
|
+
SELECT * FROM thread_archive WHERE project_id = ? ORDER BY archived_at DESC
|
|
228
|
+
`),
|
|
229
|
+
findOpenByHandle: db.prepare(`
|
|
230
|
+
SELECT * FROM threads
|
|
231
|
+
WHERE project_id = ? AND status = 'open' AND id LIKE ? || '%'
|
|
232
|
+
LIMIT 2
|
|
233
|
+
`),
|
|
234
|
+
findArchivedByHandle: db.prepare(`
|
|
235
|
+
SELECT * FROM thread_archive
|
|
236
|
+
WHERE project_id = ? AND id LIKE ? || '%'
|
|
237
|
+
LIMIT 2
|
|
238
|
+
`),
|
|
239
|
+
// plan_nodes (04 DELTA)
|
|
240
|
+
insertPlanNode: db.prepare(`
|
|
241
|
+
INSERT INTO plan_nodes
|
|
242
|
+
(id, project_id, parent_id, level, title, status, position, git_commit_sha, created_at, completed_at)
|
|
243
|
+
VALUES
|
|
244
|
+
(@id, @project_id, @parent_id, @level, @title, @status, @position, @git_commit_sha, @created_at, @completed_at)
|
|
245
|
+
`),
|
|
246
|
+
selectPlanNode: db.prepare(`SELECT * FROM plan_nodes WHERE id = ?`),
|
|
247
|
+
listPlanNodesByProject: db.prepare(`
|
|
248
|
+
SELECT * FROM plan_nodes WHERE project_id = ? ORDER BY level, position
|
|
249
|
+
`),
|
|
250
|
+
updatePlanNodeStatus: db.prepare(`
|
|
251
|
+
UPDATE plan_nodes
|
|
252
|
+
SET status = @status,
|
|
253
|
+
completed_at = @completed_at,
|
|
254
|
+
git_commit_sha = COALESCE(@git_commit_sha, git_commit_sha)
|
|
255
|
+
WHERE id = @id
|
|
256
|
+
`),
|
|
257
|
+
updatePlanNodeTitle: db.prepare(`UPDATE plan_nodes SET title = ? WHERE id = ?`),
|
|
258
|
+
findPlanByHandle: db.prepare(`
|
|
259
|
+
SELECT * FROM plan_nodes
|
|
260
|
+
WHERE project_id = ? AND id LIKE ? || '%'
|
|
261
|
+
LIMIT 2
|
|
262
|
+
`),
|
|
263
|
+
findPlanByTitle: db.prepare(`
|
|
264
|
+
SELECT * FROM plan_nodes
|
|
265
|
+
WHERE project_id = ? AND title = ?
|
|
266
|
+
LIMIT 2
|
|
267
|
+
`),
|
|
161
268
|
// Agents
|
|
162
269
|
insertAgent: db.prepare(`
|
|
163
270
|
INSERT INTO agents
|
|
@@ -214,6 +321,26 @@ export class Queries {
|
|
|
214
321
|
`),
|
|
215
322
|
selectThreadChangesSince: db.prepare(`
|
|
216
323
|
SELECT * FROM threads WHERE project_id = ? AND (created_at > ? OR updated_at > ?) ORDER BY created_at ASC
|
|
324
|
+
`),
|
|
325
|
+
selectCommitCreatedAt: db.prepare(`
|
|
326
|
+
SELECT created_at FROM commits WHERE id = ?
|
|
327
|
+
`),
|
|
328
|
+
countProjectCommitsSince: db.prepare(`
|
|
329
|
+
SELECT COUNT(*) AS n FROM commits c
|
|
330
|
+
JOIN branches b ON c.branch_id = b.id
|
|
331
|
+
WHERE b.project_id = ? AND c.created_at > ?
|
|
332
|
+
`),
|
|
333
|
+
countBranchCommitsSince: db.prepare(`
|
|
334
|
+
SELECT COUNT(*) AS n FROM commits WHERE branch_id = ? AND created_at > ?
|
|
335
|
+
`),
|
|
336
|
+
insertTraceEntry: db.prepare(`
|
|
337
|
+
INSERT INTO trace (id, project_id, branch_id, note, git_commit_sha, created_at)
|
|
338
|
+
VALUES (@id, @project_id, @branch_id, @note, @git_commit_sha, @created_at)
|
|
339
|
+
`),
|
|
340
|
+
selectTraceEntries: db.prepare(`
|
|
341
|
+
SELECT * FROM trace WHERE project_id = ?
|
|
342
|
+
ORDER BY created_at DESC, rowid DESC
|
|
343
|
+
LIMIT ? OFFSET ?
|
|
217
344
|
`),
|
|
218
345
|
};
|
|
219
346
|
}
|
|
@@ -339,13 +466,14 @@ export class Queries {
|
|
|
339
466
|
return row ? toCommit(row) : null;
|
|
340
467
|
}
|
|
341
468
|
// ─── Threads ──────────────────────────────────────────────────────────────
|
|
342
|
-
insertThread(id, description, projectId, branchId, openedInCommit, workflowType) {
|
|
469
|
+
insertThread(id, description, projectId, branchId, openedInCommit, workflowType, kind = 'open') {
|
|
343
470
|
const now = Date.now();
|
|
344
471
|
this.stmts.insertThread.run({
|
|
345
472
|
id,
|
|
346
473
|
project_id: projectId,
|
|
347
474
|
branch_id: branchId,
|
|
348
475
|
description,
|
|
476
|
+
kind,
|
|
349
477
|
workflow_type: workflowType,
|
|
350
478
|
opened_in_commit: openedInCommit,
|
|
351
479
|
created_at: now,
|
|
@@ -356,8 +484,10 @@ export class Queries {
|
|
|
356
484
|
branchId,
|
|
357
485
|
description,
|
|
358
486
|
status: 'open',
|
|
487
|
+
kind,
|
|
359
488
|
workflowType: workflowType ?? undefined,
|
|
360
489
|
openedInCommit,
|
|
490
|
+
lastTouchedCommit: openedInCommit,
|
|
361
491
|
createdAt: new Date(now),
|
|
362
492
|
};
|
|
363
493
|
}
|
|
@@ -368,8 +498,10 @@ export class Queries {
|
|
|
368
498
|
branch_id: thread.branchId,
|
|
369
499
|
description: thread.description,
|
|
370
500
|
status: thread.status,
|
|
501
|
+
kind: thread.kind ?? 'open',
|
|
371
502
|
workflow_type: thread.workflowType ?? null,
|
|
372
503
|
opened_in_commit: thread.openedInCommit,
|
|
504
|
+
last_touched_commit: thread.lastTouchedCommit ?? thread.openedInCommit,
|
|
373
505
|
created_at: thread.createdAt.getTime(),
|
|
374
506
|
});
|
|
375
507
|
return thread;
|
|
@@ -377,18 +509,196 @@ export class Queries {
|
|
|
377
509
|
closeThread(threadId, closedInCommit, note) {
|
|
378
510
|
this.stmts.closeThread.run(closedInCommit, note, threadId);
|
|
379
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Move an open thread from `threads` to `thread_archive`. Single transaction.
|
|
514
|
+
* `closedInCommit` is the commit triggering the archive (the save's new commit
|
|
515
|
+
* for manual closes; the last-touched commit for sweep-based archival).
|
|
516
|
+
*/
|
|
517
|
+
archiveThread(threadId, reason, closedInCommit) {
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
this.db.transaction(() => {
|
|
520
|
+
this.stmts.insertThreadArchive.run(closedInCommit, null, now, reason, threadId);
|
|
521
|
+
this.stmts.deleteThread.run(threadId);
|
|
522
|
+
})();
|
|
523
|
+
const row = this.stmts.selectArchivedThread.get(threadId);
|
|
524
|
+
if (!row)
|
|
525
|
+
throw new Error(`archiveThread: thread ${threadId} not found after move`);
|
|
526
|
+
return toArchivedThread(row);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Move a row from `thread_archive` back to `threads` with `status='open'`,
|
|
530
|
+
* clearing closed_in_commit and closed_note (the restore semantically reopens it).
|
|
531
|
+
*/
|
|
532
|
+
restoreThread(threadId) {
|
|
533
|
+
const now = Date.now();
|
|
534
|
+
this.db.transaction(() => {
|
|
535
|
+
this.stmts.restoreFromArchive.run(now, threadId);
|
|
536
|
+
this.stmts.deleteFromArchive.run(threadId);
|
|
537
|
+
})();
|
|
538
|
+
const row = this.stmts.selectThread.get(threadId);
|
|
539
|
+
if (!row)
|
|
540
|
+
throw new Error(`restoreThread: thread ${threadId} not found after move`);
|
|
541
|
+
return toThread(row);
|
|
542
|
+
}
|
|
543
|
+
listArchivedThreads(projectId) {
|
|
544
|
+
const rows = this.stmts.listArchivedByProject.all(projectId);
|
|
545
|
+
return rows.map(toArchivedThread);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Bulk restore: move every archived row matching one of the given reasons back
|
|
549
|
+
* to `threads`. Returns the number of rows moved. Used to recover from a wrong
|
|
550
|
+
* one-time-sweep (e.g. the 0.2.1 calibration bug that archived live threads on
|
|
551
|
+
* long-lived branches via the old OR-of-distance rule).
|
|
552
|
+
*/
|
|
553
|
+
restoreAllArchivedByReason(projectId, reasons) {
|
|
554
|
+
if (reasons.length === 0)
|
|
555
|
+
return 0;
|
|
556
|
+
const rows = this.stmts.listArchivedByProject.all(projectId);
|
|
557
|
+
const targets = rows.filter((r) => reasons.includes(r.archived_reason));
|
|
558
|
+
if (targets.length === 0)
|
|
559
|
+
return 0;
|
|
560
|
+
const now = Date.now();
|
|
561
|
+
this.db.transaction(() => {
|
|
562
|
+
for (const r of targets) {
|
|
563
|
+
this.stmts.restoreFromArchive.run(now, r.id);
|
|
564
|
+
this.stmts.deleteFromArchive.run(r.id);
|
|
565
|
+
}
|
|
566
|
+
})();
|
|
567
|
+
return targets.length;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Find an open thread on this project whose normalized description matches.
|
|
571
|
+
* Used by dedupe-on-save (02 DELTA spec §A) — open-thread count per project is
|
|
572
|
+
* bounded by the decay system being built, so a JS-side scan is acceptable.
|
|
573
|
+
*/
|
|
574
|
+
findOpenThreadByNormalizedDescription(projectId, normalized) {
|
|
575
|
+
const rows = this.stmts.selectOpenThreads.all(projectId);
|
|
576
|
+
for (const row of rows) {
|
|
577
|
+
if (normalizeThreadSubject(row.description) === normalized)
|
|
578
|
+
return toThread(row);
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Resolve a 6-char handle to an open thread on the project.
|
|
584
|
+
* Returns undefined if no match; throws if multiple match (collision).
|
|
585
|
+
*/
|
|
586
|
+
findOpenThreadByHandle(projectId, handle) {
|
|
587
|
+
const rows = this.stmts.findOpenByHandle.all(projectId, handle);
|
|
588
|
+
if (rows.length === 0)
|
|
589
|
+
return undefined;
|
|
590
|
+
if (rows.length > 1) {
|
|
591
|
+
throw new Error(`findOpenThreadByHandle: handle '${handle}' matches ${rows.length} threads`);
|
|
592
|
+
}
|
|
593
|
+
const now = Date.now();
|
|
594
|
+
return this.attachDecayFlags(toThread(rows[0]), now);
|
|
595
|
+
}
|
|
596
|
+
findArchivedThreadByHandle(projectId, handle) {
|
|
597
|
+
const rows = this.stmts.findArchivedByHandle.all(projectId, handle);
|
|
598
|
+
if (rows.length === 0)
|
|
599
|
+
return undefined;
|
|
600
|
+
if (rows.length > 1) {
|
|
601
|
+
throw new Error(`findArchivedThreadByHandle: handle '${handle}' matches ${rows.length} threads`);
|
|
602
|
+
}
|
|
603
|
+
return toArchivedThread(rows[0]);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Project-scoped sweep run by createCommit at the end of every save: any open
|
|
607
|
+
* thread on this project that classifies as `stale` or `expired` is moved to
|
|
608
|
+
* `thread_archive`. Returns counts by reason for the save response.
|
|
609
|
+
*/
|
|
610
|
+
sweepStaleThreads(projectId, now) {
|
|
611
|
+
const threads = this.listOpenThreads(projectId);
|
|
612
|
+
const byReason = { 'stale-age': 0, 'stale-distance': 0, 'watch-expired': 0 };
|
|
613
|
+
let archived = 0;
|
|
614
|
+
for (const t of threads) {
|
|
615
|
+
if (!t.stale && !t.expired)
|
|
616
|
+
continue;
|
|
617
|
+
let reason;
|
|
618
|
+
if (t.expired) {
|
|
619
|
+
reason = 'watch-expired';
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
const touchId = t.lastTouchedCommit ?? t.openedInCommit;
|
|
623
|
+
const tsRow = this.stmts.selectCommitCreatedAt.get(touchId);
|
|
624
|
+
const touchTs = tsRow?.created_at ?? t.createdAt.getTime();
|
|
625
|
+
const branchN = this.stmts.countBranchCommitsSince.get(t.branchId, touchTs).n;
|
|
626
|
+
reason = branchN >= 30 ? 'stale-distance' : 'stale-age';
|
|
627
|
+
}
|
|
628
|
+
this.stmts.insertThreadArchive.run(null, null, now, reason, t.id);
|
|
629
|
+
this.stmts.deleteThread.run(t.id);
|
|
630
|
+
byReason[reason]++;
|
|
631
|
+
archived++;
|
|
632
|
+
}
|
|
633
|
+
return { archived, byReason };
|
|
634
|
+
}
|
|
635
|
+
updateThreadLastTouched(threadId, commitId) {
|
|
636
|
+
this.stmts.updateThreadLastTouched.run(commitId, threadId);
|
|
637
|
+
}
|
|
380
638
|
listOpenThreads(projectId) {
|
|
381
639
|
const rows = this.stmts.selectOpenThreads.all(projectId);
|
|
382
|
-
|
|
640
|
+
const now = Date.now();
|
|
641
|
+
return rows.map(toThread).map((t) => this.attachDecayFlags(t, now));
|
|
383
642
|
}
|
|
384
643
|
listOpenThreadsByBranch(branchId) {
|
|
385
644
|
const rows = this.stmts.selectOpenThreadsByBranch.all(branchId);
|
|
386
|
-
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
return rows.map(toThread).map((t) => this.attachDecayFlags(t, now));
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Set the derived `stale` (open) or `expired` (watch) flag on a thread by
|
|
650
|
+
* looking up the touched commit's timestamp and counting project/branch
|
|
651
|
+
* commits since. Returns a new Thread — never mutates the input.
|
|
652
|
+
*/
|
|
653
|
+
attachDecayFlags(thread, now) {
|
|
654
|
+
const touchCommitId = thread.lastTouchedCommit ?? thread.openedInCommit;
|
|
655
|
+
const touchRow = this.stmts.selectCommitCreatedAt.get(touchCommitId);
|
|
656
|
+
const touchTs = touchRow?.created_at ?? thread.createdAt.getTime();
|
|
657
|
+
const projectRow = this.stmts.countProjectCommitsSince.get(thread.projectId, touchTs);
|
|
658
|
+
const branchRow = this.stmts.countBranchCommitsSince.get(thread.branchId, touchTs);
|
|
659
|
+
const flag = classifyThread(thread, {
|
|
660
|
+
touchTs,
|
|
661
|
+
projectCommitsSince: projectRow.n,
|
|
662
|
+
branchCommitsSince: branchRow.n,
|
|
663
|
+
now,
|
|
664
|
+
});
|
|
665
|
+
if (flag === 'stale')
|
|
666
|
+
return { ...thread, stale: true };
|
|
667
|
+
if (flag === 'expired')
|
|
668
|
+
return { ...thread, expired: true };
|
|
669
|
+
return thread;
|
|
387
670
|
}
|
|
388
671
|
/** Move open threads from source branch to target (called during merge). */
|
|
389
672
|
reassignOpenThreads(fromBranchId, toBranchId) {
|
|
390
673
|
this.stmts.reassignThreads.run(toBranchId, fromBranchId);
|
|
391
674
|
}
|
|
675
|
+
// ─── Trace (fine tier, 02 DELTA) ─────────────────────────────────────────
|
|
676
|
+
//
|
|
677
|
+
// Append-only, pull-only. NEVER included in getSessionSnapshot / default load.
|
|
678
|
+
// Retrieved via project_memory_retrieve(tier: 'trace', window, offset).
|
|
679
|
+
insertTraceEntry(input) {
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
this.stmts.insertTraceEntry.run({
|
|
682
|
+
id: input.id,
|
|
683
|
+
project_id: input.projectId,
|
|
684
|
+
branch_id: input.branchId,
|
|
685
|
+
note: input.note,
|
|
686
|
+
git_commit_sha: input.gitCommitSha ?? null,
|
|
687
|
+
created_at: now,
|
|
688
|
+
});
|
|
689
|
+
return {
|
|
690
|
+
id: input.id,
|
|
691
|
+
projectId: input.projectId,
|
|
692
|
+
branchId: input.branchId,
|
|
693
|
+
note: input.note,
|
|
694
|
+
gitCommitSha: input.gitCommitSha,
|
|
695
|
+
createdAt: new Date(now),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
listTraceEntries(projectId, pagination) {
|
|
699
|
+
const rows = this.stmts.selectTraceEntries.all(projectId, pagination.limit, pagination.offset);
|
|
700
|
+
return rows.map(toTraceEntry);
|
|
701
|
+
}
|
|
392
702
|
// ─── Agents ───────────────────────────────────────────────────────────────
|
|
393
703
|
upsertAgent(input) {
|
|
394
704
|
const now = Date.now();
|
|
@@ -463,17 +773,37 @@ export class Queries {
|
|
|
463
773
|
const branchSummary = branch?.headCommitId
|
|
464
774
|
? (this.getCommit(branch.headCommitId)?.summary ?? '')
|
|
465
775
|
: '';
|
|
466
|
-
//
|
|
776
|
+
// Recent commits on current branch — windowed via 02 DELTA commitWindow (default 5).
|
|
777
|
+
const commitWindow = options?.commitWindow ?? 5;
|
|
467
778
|
const recentCommits = options?.agentRole
|
|
468
|
-
? this.stmts.selectCommitsByRole.all(branchId, options.agentRole,
|
|
469
|
-
: this.listCommits(branchId, { limit:
|
|
470
|
-
// All open threads
|
|
471
|
-
|
|
779
|
+
? this.stmts.selectCommitsByRole.all(branchId, options.agentRole, commitWindow).map(toCommit)
|
|
780
|
+
: this.listCommits(branchId, { limit: commitWindow, offset: 0 });
|
|
781
|
+
// All open threads (decorated with stale/expired flags by listOpenThreads).
|
|
782
|
+
// The default snapshot returns only live threads — the curated load (02 DELTA §B/§C):
|
|
783
|
+
// • kind='open' threads filtered out when stale=true → counted in staleThreadCount
|
|
784
|
+
// • kind='watch' threads filtered out when expired=true → counted in expiredWatchCount
|
|
785
|
+
// Stale/expired threads stay retrievable via project_memory_threads tool (Step 5).
|
|
786
|
+
const allOpenThreads = this.listOpenThreads(projectId);
|
|
787
|
+
let staleThreadCount = 0;
|
|
788
|
+
let expiredWatchCount = 0;
|
|
789
|
+
const openThreads = [];
|
|
790
|
+
for (const t of allOpenThreads) {
|
|
791
|
+
if (t.stale) {
|
|
792
|
+
staleThreadCount++;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (t.expired) {
|
|
796
|
+
expiredWatchCount++;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
openThreads.push(t);
|
|
800
|
+
}
|
|
472
801
|
const activeClaims = this.listActiveClaims(projectId);
|
|
473
802
|
// Check if the project has been initiated: true if main/master branch has a head commit
|
|
474
803
|
// OR if the current branch has any commits (covers projects without a named main branch)
|
|
475
804
|
const allCommits = this.listCommits(branchId, { limit: 1, offset: 0 });
|
|
476
805
|
const isInitiated = mainBranch?.headCommitId != null || allCommits.length > 0;
|
|
806
|
+
const planTree = this.getPlanTree(projectId);
|
|
477
807
|
return {
|
|
478
808
|
projectSummary,
|
|
479
809
|
branchName: branch?.name ?? '',
|
|
@@ -482,6 +812,9 @@ export class Queries {
|
|
|
482
812
|
openThreads,
|
|
483
813
|
activeClaims,
|
|
484
814
|
isInitiated,
|
|
815
|
+
staleThreadCount,
|
|
816
|
+
expiredWatchCount,
|
|
817
|
+
planTree,
|
|
485
818
|
};
|
|
486
819
|
}
|
|
487
820
|
// ─── Semantic search (sqlite-vec) ─────────────────────────────────────────
|
|
@@ -545,5 +878,162 @@ export class Queries {
|
|
|
545
878
|
// sqlite-vec not available
|
|
546
879
|
}
|
|
547
880
|
}
|
|
881
|
+
// ─── plan_nodes (04 DELTA) ────────────────────────────────────────────────
|
|
882
|
+
/**
|
|
883
|
+
* Insert a plan tree of arbitrary depth atomically. `level` is semantic:
|
|
884
|
+
* - root (parent_id IS NULL) → 'plan'
|
|
885
|
+
* - has children at insert time → 'step' (container)
|
|
886
|
+
* - no children at insert time → 'task' (leaf)
|
|
887
|
+
*
|
|
888
|
+
* Hierarchy is determined by parent_id, not by depth. The depth-based
|
|
889
|
+
* mapping from earlier 04 DELTA versions was wrong on trees deeper than 3
|
|
890
|
+
* (Slice at depth 2 would get labeled 'task' but actually had children).
|
|
891
|
+
* Content-based labeling is correct at any depth.
|
|
892
|
+
*/
|
|
893
|
+
insertPlanTree(projectId, input) {
|
|
894
|
+
const now = Date.now();
|
|
895
|
+
const levelFor = (isRoot, hasChildren) => {
|
|
896
|
+
if (isRoot)
|
|
897
|
+
return 'plan';
|
|
898
|
+
return hasChildren ? 'step' : 'task';
|
|
899
|
+
};
|
|
900
|
+
const insertRecursive = (node, parentId, position) => {
|
|
901
|
+
const id = nanoid();
|
|
902
|
+
const isRoot = parentId === null;
|
|
903
|
+
const hasChildren = (node.children?.length ?? 0) > 0;
|
|
904
|
+
const level = levelFor(isRoot, hasChildren);
|
|
905
|
+
this.stmts.insertPlanNode.run({
|
|
906
|
+
id,
|
|
907
|
+
project_id: projectId,
|
|
908
|
+
parent_id: parentId,
|
|
909
|
+
level,
|
|
910
|
+
title: node.title,
|
|
911
|
+
status: node.status ?? 'pending',
|
|
912
|
+
position,
|
|
913
|
+
git_commit_sha: null,
|
|
914
|
+
created_at: now,
|
|
915
|
+
completed_at: null,
|
|
916
|
+
});
|
|
917
|
+
const children = node.children?.map((c, i) => insertRecursive(c, id, i)) ?? [];
|
|
918
|
+
const inserted = toPlanNode(this.stmts.selectPlanNode.get(id));
|
|
919
|
+
if (children.length)
|
|
920
|
+
inserted.children = children;
|
|
921
|
+
return inserted;
|
|
922
|
+
};
|
|
923
|
+
return this.db.transaction(() => insertRecursive(input, null, 0))();
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Read the active plan tree for a project. Top-level plans with full child
|
|
927
|
+
* trees and derived `progress = { done, total }` per node — DIRECT-CHILD
|
|
928
|
+
* semantics: progress.total = number of direct children; progress.done =
|
|
929
|
+
* number of direct children that are effectively-done. Effectively-done
|
|
930
|
+
* propagates recursively: a node is effectively-done if (it has no children
|
|
931
|
+
* AND status='done') OR (it has children AND every child is effectively-done).
|
|
932
|
+
*
|
|
933
|
+
* Plans that are effectively-done are excluded from the active view — they
|
|
934
|
+
* belong in listCompletedPlans.
|
|
935
|
+
*/
|
|
936
|
+
getPlanTree(projectId) {
|
|
937
|
+
return this.buildPlanTrees(projectId, (_n, isDone) => !isDone);
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* List plans (parent_id IS NULL) that are effectively-done. The completed
|
|
941
|
+
* delivery record — kept indefinitely, never archived.
|
|
942
|
+
*/
|
|
943
|
+
listCompletedPlans(projectId) {
|
|
944
|
+
return this.buildPlanTrees(projectId, (_n, isDone) => isDone);
|
|
945
|
+
}
|
|
946
|
+
/** Shared tree builder for getPlanTree / listCompletedPlans. */
|
|
947
|
+
buildPlanTrees(projectId, includeTop) {
|
|
948
|
+
const rows = this.stmts.listPlanNodesByProject.all(projectId);
|
|
949
|
+
const byId = new Map();
|
|
950
|
+
const childrenByParent = new Map();
|
|
951
|
+
for (const row of rows) {
|
|
952
|
+
const node = toPlanNode(row);
|
|
953
|
+
byId.set(node.id, node);
|
|
954
|
+
if (node.parentId) {
|
|
955
|
+
if (!childrenByParent.has(node.parentId))
|
|
956
|
+
childrenByParent.set(node.parentId, []);
|
|
957
|
+
childrenByParent.get(node.parentId).push(node);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
for (const node of byId.values()) {
|
|
961
|
+
const kids = childrenByParent.get(node.id);
|
|
962
|
+
if (kids) {
|
|
963
|
+
kids.sort((a, b) => a.position - b.position);
|
|
964
|
+
node.children = kids;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Walk the subtree. For each container (level='plan'|'step'), set
|
|
968
|
+
// progress = {done: direct children that are complete, total: direct
|
|
969
|
+
// children count}. Returns whether this node itself is complete.
|
|
970
|
+
//
|
|
971
|
+
// Completeness rule (level-aware, depth-agnostic):
|
|
972
|
+
// - level='task' (leaf-by-design): complete iff status='done'.
|
|
973
|
+
// - level='plan'|'step' (container): complete iff has at least one
|
|
974
|
+
// child AND every child is complete.
|
|
975
|
+
//
|
|
976
|
+
// Containers with no children are NEVER complete — status='done' on an
|
|
977
|
+
// empty container is meaningless for the rollup. The level enum is what
|
|
978
|
+
// distinguishes "this is a leaf, status drives" from "this is a
|
|
979
|
+
// container, children drive."
|
|
980
|
+
const walkAndSetProgress = (node) => {
|
|
981
|
+
if (node.level === 'task') {
|
|
982
|
+
return node.status === 'done';
|
|
983
|
+
}
|
|
984
|
+
// 'plan' or 'step' — container semantics.
|
|
985
|
+
if (!node.children || node.children.length === 0) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
let doneCount = 0;
|
|
989
|
+
for (const c of node.children) {
|
|
990
|
+
if (walkAndSetProgress(c))
|
|
991
|
+
doneCount++;
|
|
992
|
+
}
|
|
993
|
+
node.progress = { done: doneCount, total: node.children.length };
|
|
994
|
+
return doneCount === node.children.length;
|
|
995
|
+
};
|
|
996
|
+
const tops = [];
|
|
997
|
+
for (const node of byId.values()) {
|
|
998
|
+
if (!node.parentId) {
|
|
999
|
+
const isDone = walkAndSetProgress(node);
|
|
1000
|
+
if (includeTop(node, isDone))
|
|
1001
|
+
tops.push(node);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
tops.sort((a, b) => a.position - b.position);
|
|
1005
|
+
return tops;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Set a plan node's status. When transitioning to 'done', stamps completed_at
|
|
1009
|
+
* and optionally git_commit_sha (the commit that completed it).
|
|
1010
|
+
*/
|
|
1011
|
+
updatePlanNodeStatus(id, status, gitCommitSha) {
|
|
1012
|
+
const completedAt = status === 'done' ? Date.now() : null;
|
|
1013
|
+
this.stmts.updatePlanNodeStatus.run({ id, status, completed_at: completedAt, git_commit_sha: gitCommitSha });
|
|
1014
|
+
}
|
|
1015
|
+
updatePlanNodeTitle(id, title) {
|
|
1016
|
+
this.stmts.updatePlanNodeTitle.run(title, id);
|
|
1017
|
+
}
|
|
1018
|
+
/** Resolve a 6-char handle to a plan node. Throws on prefix collision. */
|
|
1019
|
+
findPlanNodeByHandle(projectId, handle) {
|
|
1020
|
+
const rows = this.stmts.findPlanByHandle.all(projectId, handle);
|
|
1021
|
+
if (rows.length === 0)
|
|
1022
|
+
return undefined;
|
|
1023
|
+
if (rows.length > 1) {
|
|
1024
|
+
throw new Error(`findPlanNodeByHandle: handle '${handle}' matches ${rows.length} plan nodes`);
|
|
1025
|
+
}
|
|
1026
|
+
return toPlanNode(rows[0]);
|
|
1027
|
+
}
|
|
1028
|
+
/** Resolve an exact title match. Throws when the title is ambiguous across nodes. */
|
|
1029
|
+
findPlanNodeByTitle(projectId, title) {
|
|
1030
|
+
const rows = this.stmts.findPlanByTitle.all(projectId, title);
|
|
1031
|
+
if (rows.length === 0)
|
|
1032
|
+
return undefined;
|
|
1033
|
+
if (rows.length > 1) {
|
|
1034
|
+
throw new Error(`findPlanNodeByTitle: title '${title}' matches ${rows.length} plan nodes`);
|
|
1035
|
+
}
|
|
1036
|
+
return toPlanNode(rows[0]);
|
|
1037
|
+
}
|
|
548
1038
|
}
|
|
549
1039
|
//# sourceMappingURL=queries.js.map
|