@volter/supercode-orchestrator 0.5.206 → 0.5.208
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/README.md +30 -2
- package/agents.mjs +27 -12
- package/bin/connection-runner.mjs +12 -21
- package/bin/orchestrator.mjs +16 -3
- package/board/agent-declarations.mjs +3 -3
- package/board/args.mjs +2 -2
- package/board/card-state.mjs +29 -0
- package/board/ci.mjs +87 -29
- package/board/cli.mjs +160 -83
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +358 -85
- package/board/engine.mjs +84 -46
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +20 -4
- package/board/listing.mjs +15 -12
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +5 -0
- package/board/published.mjs +119 -32
- package/board/readable-id.mjs +34 -0
- package/board/record-view.mjs +10 -1
- package/board/request-mail.mjs +9 -3
- package/board/store.mjs +124 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +62 -18
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +203 -27
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +95 -26
- package/index.d.ts +6 -2
- package/loop.mjs +51 -3
- package/mcp/bridge.mjs +4 -1
- package/mcp/hermes-kanban-tools.json +5 -1
- package/ontology.d.ts +7 -24
- package/orchestration.mjs +6 -3
- package/package.json +7 -6
- package/worker-route.mjs +31 -3
package/board/published.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
// The card writer commits the changed cards' snapshots and a retained delivery obligation together (ADR 0003: a write is
|
|
2
3
|
// proportional to the cards it changed; ADR 0008: a source change and its publication are one transaction).
|
|
3
4
|
//
|
|
@@ -6,6 +7,8 @@
|
|
|
6
7
|
// publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
|
|
7
8
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
9
|
import { executionOf } from './session-work.mjs';
|
|
10
|
+
import { cardState } from './card-state.mjs';
|
|
11
|
+
import { workflowFor } from './workflow.mjs';
|
|
9
12
|
import { cardAgentScopeTransition } from '../agents.mjs';
|
|
10
13
|
export const PUBLICATION_SCHEMA = `
|
|
11
14
|
CREATE TABLE IF NOT EXISTS supercode_publications (
|
|
@@ -28,13 +31,22 @@ CREATE TABLE IF NOT EXISTS supercode_card_agent_scopes (
|
|
|
28
31
|
-- keyless: a writing statement's own conflict clause (an upsert, OR REPLACE, OR ABORT) governs its triggers' inserts
|
|
29
32
|
-- too, so a key here would fail the writer's statement; readers take the distinct ids
|
|
30
33
|
CREATE TABLE IF NOT EXISTS supercode_publication_dirty (task_id TEXT NOT NULL);
|
|
34
|
+
-- the snapshot shape the live heads were published in (SNAPSHOT_SHAPE): a newer shape republishes every card once
|
|
35
|
+
CREATE TABLE IF NOT EXISTS supercode_publication_shape (version INTEGER NOT NULL);
|
|
31
36
|
CREATE INDEX IF NOT EXISTS supercode_publication_dirty_task ON supercode_publication_dirty(task_id);
|
|
32
37
|
CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_publication_heads(sequence);`;
|
|
38
|
+
// Columns the publication tables gained after their first layout; store.mjs layOut adds each one a store lacks. A head's
|
|
39
|
+
// fingerprint is the cheap reading of its card's rows the head was last compared at (fingerprintOf); the log's prune_from
|
|
40
|
+
// is where a prune under way resumes, null when none is (prunePublications).
|
|
41
|
+
export const PUBLICATION_COLUMNS = { supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
|
|
33
42
|
|
|
43
|
+
// The snapshot's shape: 1 names the card as a person does (`ref`, VO-856).
|
|
44
|
+
const SNAPSHOT_SHAPE = 4;
|
|
45
|
+
const SNAPSHOT_RELEASE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
34
46
|
const REVIEW_KIND_LIST = ['review_requested', 'approved', 'review_approved', 'changes_requested', 'escalated'];
|
|
35
47
|
export const AGENT_KIND_LIST = ['agent_declared', 'agent_assignment_ended', 'agent_assignment_reassigned', 'agent_assignment_adopted'];
|
|
36
48
|
// The tables a card's snapshot reads (cardSnapshot), each with the column naming its card; a link names two.
|
|
37
|
-
const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comments', ['task_id']], ['task_runs', ['task_id']], ['task_links', ['parent_id', 'child_id']], ['task_events', ['task_id']], ['supercode_card_agent_facts',['task_id']], ['supercode_card_agent_scopes',['task_id']]];
|
|
49
|
+
const MARKED = [['tasks', ['id']], ['supercode_cards', ['task_id']], ['task_comments', ['task_id']], ['task_runs', ['task_id']], ['task_links', ['parent_id', 'child_id']], ['task_events', ['task_id']], ['supercode_card_agent_facts',['task_id']], ['supercode_card_agent_scopes',['task_id']], ['supercode_card_numbers', ['task_id']]];
|
|
38
50
|
/**
|
|
39
51
|
* The triggers that mark a changed card, on a store that has these tables (CREATE … IF NOT EXISTS: laid out on every
|
|
40
52
|
* open). A card already marked is not marked again, so a writer's many writes while the owner is away leave one mark.
|
|
@@ -47,23 +59,31 @@ export function publicationTriggers(db) {
|
|
|
47
59
|
const snapshotEvents = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST].map((kind) => `'${kind}'`).join(',')})`;
|
|
48
60
|
const agentMigration = tables.has('task_events') && !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_events_insert'").get()?.sql.includes("'agent_assignment_adopted'");
|
|
49
61
|
const scopeMigration = !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_agent_scopes_insert'").get();
|
|
62
|
+
// A snapshot names its card's number: a store that numbers its cards republishes every card once, when that is watched.
|
|
63
|
+
const numberMigration = tables.has('supercode_card_numbers') && !db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_supercode_card_numbers_insert'").get();
|
|
64
|
+
// A snapshot of a newer shape (SNAPSHOT_SHAPE) than the heads were published in: every card is published once again.
|
|
65
|
+
const shapeMigration = Number(db.prepare('SELECT max(version) AS version FROM supercode_publication_shape').get()?.version ?? 0) < SNAPSHOT_SHAPE;
|
|
66
|
+
const contentMigration = !db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='supercode_publish_task_comments_update'").get()?.sql.includes('fingerprint=NULL');
|
|
50
67
|
db.exec('SAVEPOINT supercode_publication_marking');
|
|
51
68
|
try {
|
|
52
69
|
// Existing event triggers only watched reviews. Replace that filter once, preserving the savepoint rollback.
|
|
70
|
+
if(contentMigration)for(const table of ['task_comments','task_events'])for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_${table}_${verb}`);
|
|
53
71
|
if(agentMigration)for(const verb of ['insert','update','delete'])db.exec(`DROP TRIGGER IF EXISTS supercode_publish_task_events_${verb}`);
|
|
54
72
|
for (const [table, columns] of MARKED) {
|
|
55
73
|
if (!tables.has(table)) continue;
|
|
56
74
|
// Reviews and agent assignment events change a snapshot: an update marks when the
|
|
57
75
|
// row was one before or is one after
|
|
58
76
|
const when = (rows) => table === 'task_events' ? ` WHEN ${rows.map((row) => `${row}.kind IN ${snapshotEvents}`).join(' OR ')}` : '';
|
|
59
|
-
const
|
|
77
|
+
const invalidate = row => ['task_comments','task_events'].includes(table) ? `UPDATE supercode_publication_heads SET fingerprint=NULL WHERE resource_id=${row}.task_id;` : '';
|
|
78
|
+
const mark = (row) => invalidate(row) + columns.map((column) => `INSERT INTO supercode_publication_dirty (task_id) SELECT ${row}.${column} WHERE ${row}.${column} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM supercode_publication_dirty WHERE task_id = ${row}.${column});`).join(' ');
|
|
60
79
|
db.exec(`CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_insert AFTER INSERT ON ${table}${when(['NEW'])} BEGIN ${mark('NEW')} END;
|
|
61
80
|
CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
|
|
62
81
|
CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
|
|
63
82
|
}
|
|
64
|
-
if ((first || agentMigration || scopeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
|
|
83
|
+
if ((first || contentMigration || agentMigration || scopeMigration || numberMigration || shapeMigration) && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
|
|
65
84
|
SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
|
|
66
85
|
EXCEPT SELECT task_id FROM supercode_publication_dirty`);
|
|
86
|
+
if (shapeMigration) { db.exec('DELETE FROM supercode_publication_shape'); db.prepare('INSERT INTO supercode_publication_shape (version) VALUES (?)').run(SNAPSHOT_SHAPE); }
|
|
67
87
|
db.exec('RELEASE supercode_publication_marking');
|
|
68
88
|
} catch (error) { db.exec('ROLLBACK TO supercode_publication_marking; RELEASE supercode_publication_marking'); throw error; }
|
|
69
89
|
}
|
|
@@ -75,9 +95,13 @@ export function markRemovedHeads(db) {
|
|
|
75
95
|
SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed' AND resource_id NOT IN (SELECT id FROM tasks)
|
|
76
96
|
EXCEPT SELECT task_id FROM supercode_publication_dirty`);
|
|
77
97
|
}
|
|
78
|
-
// Every card's head stays; older changes beyond this many are pruned
|
|
98
|
+
// Every card's head stays; older changes beyond this many are pruned once twice as many accumulate.
|
|
79
99
|
// A reader whose cursor falls below the pruned floor takes a new snapshot.
|
|
80
100
|
const RETAINED_CHANGES = 1024;
|
|
101
|
+
// The changes one prune transaction deletes: a change's snapshot averaged about 190 KB and reached 3.7 MB, and deleting
|
|
102
|
+
// every pruned change in one statement held the owner's event loop for 291 s (2026-10-21).
|
|
103
|
+
const PRUNE_BATCH = 200;
|
|
104
|
+
const PRUNE_BYTES = 8 * 1024 * 1024;
|
|
81
105
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
82
106
|
const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
|
|
83
107
|
const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
|
|
@@ -160,17 +184,51 @@ export function syncCardAgentScope(db,id,transition){
|
|
|
160
184
|
/** Organization scope stays internal; the publication contract has exactly these five fields. */
|
|
161
185
|
export function cardAgents(db,id){return cardAgentFacts(db,id).map(({org,...agent})=>agent);}
|
|
162
186
|
/** A card's rows read one card at a time. */
|
|
163
|
-
const perCard=db=>
|
|
187
|
+
const perCard=db=>{
|
|
188
|
+
// A card's short sequential number (store.mjs CARD_NUMBERS, named here as store.mjs imports this module), in a store
|
|
189
|
+
// that has numbered its cards.
|
|
190
|
+
const numbered=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_card_numbers'").get();
|
|
191
|
+
return {
|
|
192
|
+
number:id=>numbered?db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id=?').get(id)?.number??null:null,
|
|
164
193
|
own:id=>db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(id),
|
|
165
194
|
comments:id=>db.prepare('SELECT id,author,body,created_at FROM task_comments WHERE task_id=? ORDER BY id').all(id),
|
|
195
|
+
archiveReason:id=>parse(db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='archived' ORDER BY id DESC LIMIT 1").get(id)?.payload,{}).reason??null,
|
|
166
196
|
reviews:id=>db.prepare(`SELECT id,kind,payload,created_at FROM task_events WHERE task_id=? AND kind IN ${REVIEW_KINDS} ORDER BY id`).all(id),
|
|
167
197
|
agents:id=>cardAgents(db,id),
|
|
168
198
|
attempts:id=>db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id').all(id),
|
|
169
199
|
parents:id=>db.prepare('SELECT parent_id FROM task_links WHERE child_id=? ORDER BY parent_id').all(id).map(link=>link.parent_id),
|
|
170
200
|
children:id=>db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(id).map(link=>link.child_id),
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
// The columns of a task row that change while its card runs and that no snapshot reads (cardSnapshot).
|
|
204
|
+
const UNREAD_TASK_COLUMNS = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at']);
|
|
205
|
+
// The events a snapshot reads: its reviews, its agents and its archive reason.
|
|
206
|
+
const SNAPSHOT_EVENT_KINDS = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
|
|
207
|
+
const fingerprintReads=db=>({
|
|
208
|
+
// comments and events are only ever added or removed by supercode's writers; their bytes catch a document edit that
|
|
209
|
+
// rewrites one in place; SQLite answers octet_length from a row's header, without reading its text
|
|
210
|
+
comments:db.prepare('SELECT count(*) AS n,max(id) AS last,total(octet_length(body)) AS bytes FROM task_comments WHERE task_id=?'),
|
|
211
|
+
events:db.prepare(`SELECT count(*) AS n,max(id) AS last,total(octet_length(payload)) AS bytes FROM task_events WHERE task_id=? AND kind IN ${SNAPSHOT_EVENT_KINDS}`),
|
|
212
|
+
facts:db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?'),
|
|
213
|
+
scope:db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?'),
|
|
214
|
+
// a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
|
|
215
|
+
runs:db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id'),
|
|
171
216
|
});
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
/**
|
|
218
|
+
* A card's fingerprint: what its snapshot is built from, read cheaply (no comment text, review or agent event payloads,
|
|
219
|
+
* and no snapshot built), with the snapshot's shape, its board's key and the options it is built with. Equal
|
|
220
|
+
* fingerprints build equal snapshots, but for a run's session observation passing its freshness (executionOf), which a
|
|
221
|
+
* snapshot's readers apply themselves.
|
|
222
|
+
*/
|
|
223
|
+
function fingerprintOf(reads,rows,row,key,options){
|
|
224
|
+
const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
|
|
225
|
+
return hash(JSON.stringify([SNAPSHOT_SHAPE,SNAPSHOT_RELEASE,key,Boolean(options.nativeHolds),task,rows.own(row.id)??null,rows.number(row.id),rows.parents(row.id),rows.children(row.id),
|
|
226
|
+
reads.comments.get(row.id),reads.events.get(row.id),reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
|
|
227
|
+
}
|
|
228
|
+
/** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
|
|
229
|
+
* (`VO-856`, store.mjs cardRef), null for a card with no number. */
|
|
230
|
+
export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
|
|
231
|
+
const own=rows.own(row.id)??{},number=rows.number(row.id);
|
|
174
232
|
const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
|
|
175
233
|
const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
|
|
176
234
|
const reviews=rows.reviews(row.id).map(event=>{const payload=parse(event.payload,{});return {id:event.id,verdict:verdicts[event.kind],by:payload.reviewer??payload.by??payload.profile,reason:payload.reason,at:at(event.created_at)};});
|
|
@@ -182,10 +240,11 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
182
240
|
return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
|
|
183
241
|
});
|
|
184
242
|
const lane=own.stage??row.status;
|
|
185
|
-
|
|
243
|
+
const archived=Boolean(own.archived || lane==='archived');
|
|
244
|
+
return {id:row.id,readable_id:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,blocked_by:own.blocked_by},options).status,lane,
|
|
186
245
|
execution:executionOf(runs.find(run=>run.id===row.current_run_id)),
|
|
187
246
|
priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
|
|
188
|
-
tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),
|
|
247
|
+
tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived,archive_reason:archived?rows.archiveReason(row.id):null,
|
|
189
248
|
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
|
|
190
249
|
workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
|
|
191
250
|
parents:rows.parents(row.id),
|
|
@@ -195,60 +254,88 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
195
254
|
/**
|
|
196
255
|
* Publish the cards this transaction (or a native writer before it) marked changed, inside the backing's own
|
|
197
256
|
* transaction: each marked card's snapshot when it differs from its published head, a removal when the card is gone.
|
|
198
|
-
*
|
|
257
|
+
* A marked card whose fingerprint is the one its head was last compared at builds no snapshot: a write that marks a card
|
|
258
|
+
* without changing what its snapshot reads (a run's heartbeat, a rewrite of the same values) costs the fingerprint's
|
|
259
|
+
* reads. The head keeps the fingerprint with the digest it was compared against, so a head another release moved is
|
|
260
|
+
* compared again. The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
|
|
199
261
|
*/
|
|
200
|
-
export function recordCardPublications(db,actor,root) {
|
|
262
|
+
export function recordCardPublications(db,actor,root,key) {
|
|
263
|
+
const options={nativeHolds:Boolean(workflowFor(root,{optional:true})?.workflow.flags?.paused)};
|
|
201
264
|
const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
|
|
202
265
|
if(!marked.length)return 0;
|
|
203
266
|
db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
|
|
204
|
-
const rows=perCard(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id FROM supercode_publication_heads WHERE resource_id=?');
|
|
267
|
+
const rows=perCard(db),reads=fingerprintReads(db),task=db.prepare('SELECT * FROM tasks WHERE id=?'),head=db.prepare('SELECT resource_id,digest,last_event_id,fingerprint FROM supercode_publication_heads WHERE resource_id=?');
|
|
205
268
|
const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
|
|
206
269
|
const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)');
|
|
207
|
-
const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id) VALUES (
|
|
270
|
+
const upsert=db.prepare('INSERT INTO supercode_publication_heads(resource_id,digest,sequence,last_event_id,fingerprint) VALUES (?,?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id,fingerprint=excluded.fingerprint');
|
|
271
|
+
const compared=db.prepare('UPDATE supercode_publication_heads SET fingerprint=? WHERE resource_id=?');
|
|
208
272
|
let changed=0;
|
|
209
273
|
for(const id of marked){
|
|
210
274
|
const row=task.get(id),previous=head.get(id);
|
|
211
275
|
if(row){
|
|
212
276
|
const transition=cardAgentScopeTransition(root,id);
|
|
213
277
|
if(transition)syncCardAgentScope(db,id,transition);else compactCardAgentEvents(db,id);
|
|
214
|
-
const
|
|
215
|
-
if(previous
|
|
278
|
+
const fingerprint=fingerprintOf(reads,rows,row,key,options);
|
|
279
|
+
if(previous&&previous.digest!=='removed'&&previous.fingerprint===`${fingerprint}:${previous.digest}`)continue;
|
|
280
|
+
const snapshot=JSON.stringify(cardSnapshot(db,row,rows,key,options)),digest=hash(snapshot);
|
|
281
|
+
if(previous?.digest===digest){compared.run(`${fingerprint}:${digest}`,id);continue;}
|
|
216
282
|
const last=lastEvent.get(id);
|
|
217
283
|
const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
|
|
218
284
|
const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
|
|
219
|
-
upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
|
|
285
|
+
upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
|
|
220
286
|
}else if(previous&&previous.digest!=='removed'){
|
|
221
287
|
const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',JSON.stringify({id,removed:true}),actor,Date.now());
|
|
222
|
-
db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence
|
|
288
|
+
db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=?,fingerprint=NULL WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
|
|
223
289
|
}
|
|
224
290
|
}
|
|
225
291
|
db.prepare('DELETE FROM supercode_publication_dirty').run();
|
|
226
292
|
return changed;
|
|
227
293
|
}
|
|
228
294
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
*
|
|
233
|
-
*/
|
|
234
|
-
/** Whether retention has work: an upper bound on the older changes kept (sequences above the floor that are no card's
|
|
235
|
-
* head), from the log's ends and the heads above the floor, in indexed reads. */
|
|
295
|
+
// An older change: one no card's head is (the heads' sequence index answers it per change).
|
|
296
|
+
const OLDER='NOT EXISTS (SELECT 1 FROM supercode_publication_heads h WHERE h.sequence=p.sequence)';
|
|
297
|
+
/** Whether retention has work: a prune under way, or an upper bound on the older changes kept (sequences above the floor
|
|
298
|
+
* that are no card's head), from the log's ends and the heads above the floor, in indexed reads. */
|
|
236
299
|
export function pruneDue(db) {
|
|
237
|
-
const log=db.prepare('SELECT pruned_through FROM supercode_publication_log').get();
|
|
300
|
+
const log=db.prepare('SELECT pruned_through,prune_from FROM supercode_publication_log').get();
|
|
238
301
|
if(!log)return false;
|
|
302
|
+
if(log.prune_from!=null)return true;
|
|
239
303
|
const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
|
|
240
304
|
// the heads above the floor are not older changes; those below it were already left out by subtracting the floor
|
|
241
305
|
const heads=Number(db.prepare('SELECT count(*) AS n FROM supercode_publication_heads WHERE sequence>?').get(Number(log.pruned_through)).n);
|
|
242
306
|
return high-Number(log.pruned_through)-heads>2*RETAINED_CHANGES;
|
|
243
307
|
}
|
|
244
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Bounded retention, the publication owner's maintenance (never a write's): every card's head stays, and once more than
|
|
311
|
+
* twice RETAINED_CHANGES older changes accumulate, all but the newest RETAINED_CHANGES of them go. A prune's first
|
|
312
|
+
* transaction raises the floor to the newest change it removes, so from then a reader whose cursor is below the floor
|
|
313
|
+
* takes a new snapshot and never reads the older changes still held; each transaction (this one and every later call)
|
|
314
|
+
* deletes at most PRUNE_BATCH of them, oldest first, recording where the next resumes (prune_from), and the last clears
|
|
315
|
+
* it. The owner runs one transaction per wake while pruneDue answers true. Answers the changes removed.
|
|
316
|
+
*/
|
|
245
317
|
export function prunePublications(db) {
|
|
246
318
|
if(!pruneDue(db))return 0;
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
319
|
+
const log=db.prepare('SELECT pruned_through,prune_from FROM supercode_publication_log').get();
|
|
320
|
+
let from=log.prune_from==null?null:Number(log.prune_from);
|
|
321
|
+
if(from==null){
|
|
322
|
+
const older=db.prepare(`SELECT sequence FROM supercode_publications p WHERE ${OLDER} ORDER BY sequence DESC LIMIT 1 OFFSET ?`);
|
|
323
|
+
if(!older.get(2*RETAINED_CHANGES))return 0;
|
|
324
|
+
const cut=Number(older.get(RETAINED_CHANGES).sequence);
|
|
325
|
+
db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
|
|
326
|
+
from=0;
|
|
327
|
+
}
|
|
328
|
+
const floor=Number(db.prepare('SELECT pruned_through FROM supercode_publication_log').get().pruned_through);
|
|
329
|
+
const candidates=db.prepare(`SELECT sequence,octet_length(snapshot) AS bytes FROM supercode_publications p WHERE sequence>? AND sequence<=? AND ${OLDER} ORDER BY sequence LIMIT ?`).all(from,floor,PRUNE_BATCH);
|
|
330
|
+
const batch=[];let bytes=0;
|
|
331
|
+
for(const row of candidates){
|
|
332
|
+
// A single oversized row still makes progress, in a transaction of its own.
|
|
333
|
+
if(batch.length&&bytes+Number(row.bytes)>PRUNE_BYTES)break;
|
|
334
|
+
batch.push(Number(row.sequence));bytes+=Number(row.bytes);
|
|
335
|
+
if(bytes>=PRUNE_BYTES)break;
|
|
336
|
+
}
|
|
337
|
+
const remove=db.prepare('DELETE FROM supercode_publications WHERE sequence=?');
|
|
338
|
+
for(const sequence of batch)remove.run(sequence);
|
|
339
|
+
db.prepare('UPDATE supercode_publication_log SET prune_from=?').run(candidates.length<PRUNE_BATCH&&batch.length===candidates.length?null:batch.at(-1));
|
|
340
|
+
return batch.length;
|
|
254
341
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A mutable name for people, separate from the immutable t_* identity and sequential number.
|
|
2
|
+
export function validateReadableId(value) {
|
|
3
|
+
if (typeof value !== 'string' || value.length > 32 || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value)) {
|
|
4
|
+
throw new Error('readable_id is kebab-case, starts with a letter and has at most 32 characters');
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readableIdFromTitle(title) {
|
|
10
|
+
const text = String(title).replace(/^(?:\[[^\]]+\]\s*)+/, '');
|
|
11
|
+
const named = /^([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\s*:/.exec(text)?.[1];
|
|
12
|
+
const words = (named ?? text).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
14
|
+
return (words.replace(/^[^a-z]+/, '') || 'card').slice(0, 32).replace(/-+$/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function allocateReadableId(db, title, requested = null, except = '') {
|
|
18
|
+
const base = requested == null ? readableIdFromTitle(title) : validateReadableId(requested);
|
|
19
|
+
const occupied = value => db.prepare("SELECT id FROM tasks WHERE readable_id=? AND id<>? AND status NOT IN ('done','cancelled','archived') LIMIT 1").get(value, except);
|
|
20
|
+
if (requested != null && occupied(base)) throw new Error(`readable_id ${base} belongs to another open card`);
|
|
21
|
+
let value = base;
|
|
22
|
+
for (let n = 2; occupied(value); n++) {
|
|
23
|
+
const suffix = `-${n}`;
|
|
24
|
+
value = `${base.slice(0, 32 - suffix.length).replace(/-+$/, '')}${suffix}`;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Runs inside the backing's transaction, after native/document imports and before publication/rendering.
|
|
30
|
+
export function fillReadableIds(db) {
|
|
31
|
+
const missing = db.prepare('SELECT id,title FROM tasks WHERE readable_id IS NULL ORDER BY created_at,id').all();
|
|
32
|
+
for (const card of missing) db.prepare('UPDATE tasks SET readable_id=? WHERE id=?')
|
|
33
|
+
.run(allocateReadableId(db, card.title, null, card.id), card.id);
|
|
34
|
+
}
|
package/board/record-view.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Resident local consumer of the daemon's committed record rows. Disconnects
|
|
2
2
|
// preserve the last applied cursor, but held data is stale until caught up.
|
|
3
|
+
import { jsonObjectReader } from '@volter/teams/lines';
|
|
4
|
+
|
|
3
5
|
export class RecordView {
|
|
4
6
|
constructor(open, { progressMs = 10_000 } = {}) {
|
|
5
7
|
this.open = open; this.records = {}; this.positions = new Map();
|
|
@@ -27,9 +29,16 @@ export class RecordView {
|
|
|
27
29
|
if (this.stream) return Promise.resolve();
|
|
28
30
|
return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor })).then((stream) => {
|
|
29
31
|
this.stream = stream; this.ready = false; this.heardAt = Date.now();
|
|
32
|
+
const readObject = jsonObjectReader();
|
|
30
33
|
stream.on('data', (frame) => {
|
|
31
34
|
if (this.stream !== stream) return;
|
|
32
|
-
try {
|
|
35
|
+
try {
|
|
36
|
+
const value = readObject(frame);
|
|
37
|
+
if (frame.json_part !== undefined) {
|
|
38
|
+
this.ready = false; this.heardAt = Date.now(); this.watchProgress();
|
|
39
|
+
}
|
|
40
|
+
if (value) this.apply(value);
|
|
41
|
+
}
|
|
33
42
|
catch (error) { this.fail(error); stream.close(error.message); }
|
|
34
43
|
});
|
|
35
44
|
stream.on('receive-progress', ({ bytes, type }) => {
|
package/board/request-mail.mjs
CHANGED
|
@@ -43,14 +43,20 @@ export function pendingRequestMail(db, taskId = null) {
|
|
|
43
43
|
nextAt = Math.min(nextAt, due * 1000);
|
|
44
44
|
continue;
|
|
45
45
|
}
|
|
46
|
+
// A batch of questions (state requests) is answered under its first id; a batch of tells (the board's own news, such
|
|
47
|
+
// as review waits only an act clears) is one mail listing each card's line, with nothing to answer.
|
|
46
48
|
const envelope = {
|
|
47
49
|
to: first.mail.to,
|
|
48
50
|
message_id: first.mail.message_id,
|
|
49
51
|
requester: first.mail.batch.requester,
|
|
52
|
+
title: first.mail.batch.title ?? null,
|
|
50
53
|
members: group.map(row => ({ id: row.id, task_id: row.task_id, message_id: row.mail.message_id })),
|
|
51
|
-
text:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
text: first.mail.message_id
|
|
55
|
+
? `State requests from ${first.mail.batch.requester}: ${group.length} request(s) for ${new Set(group.map(row => row.task_id)).size} card(s).\n\n` +
|
|
56
|
+
group.map(row => `${row.task_id} [${row.mail.message_id}]\n${row.mail.text}`).join('\n\n---\n\n') +
|
|
57
|
+
`\n\nAnswer with reply_to ${first.mail.message_id}.`
|
|
58
|
+
: `${first.mail.batch.title ?? first.mail.batch.requester}: ${new Set(group.map(row => row.task_id)).size} card(s).\n\n` +
|
|
59
|
+
group.map(row => row.mail.text).join('\n\n'),
|
|
54
60
|
};
|
|
55
61
|
event(db, first.task_id, 'board_message_batch', envelope);
|
|
56
62
|
ready.push(envelope);
|
package/board/store.mjs
CHANGED
|
@@ -13,8 +13,9 @@ import { current } from './call.mjs';
|
|
|
13
13
|
import { fenced } from './fence.mjs';
|
|
14
14
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
15
15
|
import { redactValue } from '@volter/teams/redact';
|
|
16
|
+
import { boardLabel } from '@volter/teams/board-name';
|
|
16
17
|
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from 'node:fs';
|
|
18
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
18
19
|
import { hostname } from 'node:os';
|
|
19
20
|
import { dirname, join } from 'node:path';
|
|
20
21
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -22,7 +23,9 @@ import { openZtrackBoard } from './ztrack.mjs';
|
|
|
22
23
|
import { byOwner, LIST_CONNECTION, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
23
24
|
import { isFilesBoard, openFilesBoard } from './files.mjs';
|
|
24
25
|
import { signalBoardWrite } from './wake.mjs';
|
|
25
|
-
import { PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
|
|
26
|
+
import { PUBLICATION_COLUMNS, PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
|
|
27
|
+
|
|
28
|
+
import { fillReadableIds } from './readable-id.mjs';
|
|
26
29
|
|
|
27
30
|
export const DEFAULT_BOARD = 'default';
|
|
28
31
|
|
|
@@ -87,11 +90,11 @@ CREATE TABLE IF NOT EXISTS supercode_session_operations (
|
|
|
87
90
|
);`;
|
|
88
91
|
// `stage`: the card's workflow status where kanban.db stores another (a `reviewing` card is `running` to Hermes)
|
|
89
92
|
// `since`: when the card entered its workflow status; `after_fired`: which of the status's `after` moves have run since
|
|
90
|
-
// `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `
|
|
93
|
+
// `tags`: the card's ordered tags (Hermes's `tenant` holds the first); `requested_by_unverified`: C4, who asked
|
|
91
94
|
// An effect's attempts: how many were made, when the next may be, and, once its attempts are spent, when it failed and
|
|
92
95
|
// why (engine.mjs runEffects). A failed effect is kept as the record of its terminal state and never tried again.
|
|
93
96
|
const EFFECT_COLUMNS = { attempts: 'INTEGER NOT NULL DEFAULT 0', next_at: 'INTEGER NOT NULL DEFAULT 0', failed_at: 'INTEGER', last_error: 'TEXT' };
|
|
94
|
-
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT',
|
|
97
|
+
const OURS_COLUMNS = { workspace_anchor: 'TEXT', workspace_root: 'TEXT', subtask_of: 'TEXT', stage: 'TEXT', since: 'INTEGER', after_fired: 'TEXT', blocked_by: 'TEXT', paused: 'TEXT', detached: 'TEXT', archived: 'INTEGER DEFAULT 0', every: 'TEXT', next_run_at: 'INTEGER', tags: 'TEXT', requested_by_unverified: 'TEXT' };
|
|
95
98
|
|
|
96
99
|
// ------------------------------------------------------------------ backings
|
|
97
100
|
//
|
|
@@ -137,6 +140,67 @@ export const logsDir = (root, slug = DEFAULT_BOARD) => join(boardDirOf(root, slu
|
|
|
137
140
|
* the board's logs and removed with them. */
|
|
138
141
|
export const briefPath = (root, slug, id, run) => join(logsDir(root, slug), `${id}-r${run}.brief.md`);
|
|
139
142
|
|
|
143
|
+
// A board's key: the capitals its card numbers read with (`VO-856`), as Jira and Linear key an issue. It is the board's
|
|
144
|
+
// name (its label, which its address carries: `volter-org`, `volter-org/gtm`) said short: the first letter of each of
|
|
145
|
+
// the label's words (`volter-org` is VO, `volter-org/gtm` is VOG), and at least two letters (`hookline` is HO). Two
|
|
146
|
+
// boards of one home with the same key are refused: a key names one board.
|
|
147
|
+
/** The key a board label reads its card numbers with. */
|
|
148
|
+
export function keyOfLabel(label) {
|
|
149
|
+
const words = String(label).split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.replace(/^[0-9]+/, '')).filter(Boolean);
|
|
150
|
+
let key = words.map((word) => word[0]).join('').toUpperCase();
|
|
151
|
+
if (key.length < 2) key = (words[0] ?? 'board').slice(0, 2).toUpperCase().padEnd(2, 'B');
|
|
152
|
+
return key;
|
|
153
|
+
}
|
|
154
|
+
// Identity is kept beside the board, outside a rebuildable projection. A hard link claims each key once across
|
|
155
|
+
// processes; a second hard link installs the winning identity on the board. Both survive home/board moves.
|
|
156
|
+
// Reservations remain after removal, including archived boards. Reads of an assigned key never re-derive it.
|
|
157
|
+
export function boardKey(root, slug = DEFAULT_BOARD, { creating = false, key: requested = null } = {}) {
|
|
158
|
+
const own = slug || DEFAULT_BOARD, file = join(boardDirOf(root, own), 'board-key.json');
|
|
159
|
+
if (existsSync(file)) return JSON.parse(readFileSync(file, 'utf8')).key;
|
|
160
|
+
if (creating) {
|
|
161
|
+
const peers = new Set(listBoards(root));
|
|
162
|
+
for (const peer of [...peers].sort()) if (peer !== own) boardKey(root, peer);
|
|
163
|
+
}
|
|
164
|
+
const base = requested ?? keyOfLabel(boardLabel(root, own));
|
|
165
|
+
if (!/^[A-Z][A-Z0-9]+$/.test(base)) throw new Error(`invalid board key ${base}`);
|
|
166
|
+
const reservations = join(root, 'kanban', 'board-keys');
|
|
167
|
+
mkdirSync(reservations, { recursive: true });
|
|
168
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
169
|
+
for (let n = 1; ; n++) {
|
|
170
|
+
const key = n === 1 ? base : `${base}${n}`;
|
|
171
|
+
const claim = join(reservations, `${key}.json`);
|
|
172
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
173
|
+
const identity = { key, board: own, id: randomBytes(16).toString('hex') };
|
|
174
|
+
writeFileSync(tmp, `${JSON.stringify(identity)}\n`, { flag: 'wx', mode: 0o600 });
|
|
175
|
+
try {
|
|
176
|
+
try { linkSync(tmp, claim); }
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'EEXIST') throw error;
|
|
179
|
+
if (existsSync(file)) return JSON.parse(readFileSync(file, 'utf8')).key;
|
|
180
|
+
if (JSON.parse(readFileSync(claim, 'utf8')).board === own) {
|
|
181
|
+
try { linkSync(claim, file); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
182
|
+
return JSON.parse(readFileSync(file, 'utf8')).key;
|
|
183
|
+
}
|
|
184
|
+
if (creating || requested) throw new Error(`board key ${key} is already assigned in this home; choose a different board slug`);
|
|
185
|
+
continue; // migrate a pre-existing collision without disabling either board
|
|
186
|
+
}
|
|
187
|
+
try { linkSync(tmp, file); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
188
|
+
return JSON.parse(readFileSync(file, 'utf8')).key;
|
|
189
|
+
} finally { unlinkSync(tmp); }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** A card as a person names it: its board's key and its number (`VO-856`). */
|
|
193
|
+
export const cardRef = (key, number) => (number ? `${key}-${number}` : null);
|
|
194
|
+
// Sessions and worktree folders retain the card's immutable keyed number (`vo-856`).
|
|
195
|
+
// Branches also carry its readable id; card agents use role-readable-id.
|
|
196
|
+
/** A card's handle (`vo-856`), or null for a card with no number. */
|
|
197
|
+
export const cardHandle = (key, number) => (number ? `${String(key).toLowerCase()}-${number}` : null);
|
|
198
|
+
/** The handle of card `id` on `board` (`vo-856`), or null before the card is filed. */
|
|
199
|
+
export const cardHandleOf = (board, id) => id == null ? null : cardHandle(boardKey(board.root, board.slug), board.db.prepare(`SELECT number FROM ${CARD_NUMBERS} WHERE task_id = ?`).get(id)?.number);
|
|
200
|
+
/** A branch keeps the unique number and the card's readable id. */
|
|
201
|
+
export const cardBranch = (handle, readableId) => readableId ? `${handle}-${readableId}` : handle;
|
|
202
|
+
export const readableIdOf = (board, id) => board.db.prepare('SELECT readable_id FROM tasks WHERE id=?').get(id)?.readable_id ?? null;
|
|
203
|
+
|
|
140
204
|
/** The home's boards that exist: `default` when it does, and every named one. */
|
|
141
205
|
export function listBoards(root) {
|
|
142
206
|
const out = boardExists(root) ? [DEFAULT_BOARD] : [];
|
|
@@ -276,17 +340,62 @@ function layOutHistoryIndexes(db) {
|
|
|
276
340
|
}
|
|
277
341
|
}
|
|
278
342
|
|
|
343
|
+
// A card's number (#42): short and sequential, given once when the card is made, never changed and never given to
|
|
344
|
+
// another card, as an issue tracker numbers its issues. The store gives it, whoever makes the card (a verb, a document
|
|
345
|
+
// import, Hermes's own writer): a trigger on `tasks` numbers each new card id. AUTOINCREMENT never hands a number out
|
|
346
|
+
// twice, and a number stays with its card id while the card's row is replaced or removed. The trigger inserts only
|
|
347
|
+
// where the id has no number yet, so no conflict arises in it (a writing statement's own conflict clause would govern
|
|
348
|
+
// the trigger's insert: published.mjs). Its first installation numbers the cards already there, in the order they were
|
|
349
|
+
// made (created_at, then id), in the same savepoint.
|
|
350
|
+
export const CARD_NUMBERS = 'supercode_card_numbers';
|
|
351
|
+
function layOutNumbers(db) {
|
|
352
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_number_cards'").get()) return;
|
|
353
|
+
db.exec('SAVEPOINT supercode_card_numbering');
|
|
354
|
+
try {
|
|
355
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${CARD_NUMBERS} (number INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL UNIQUE);
|
|
356
|
+
INSERT INTO ${CARD_NUMBERS} (task_id) SELECT id FROM tasks WHERE id NOT IN (SELECT task_id FROM ${CARD_NUMBERS}) ORDER BY created_at, id;
|
|
357
|
+
CREATE TRIGGER IF NOT EXISTS supercode_number_cards AFTER INSERT ON tasks BEGIN
|
|
358
|
+
INSERT INTO ${CARD_NUMBERS} (task_id) SELECT NEW.id WHERE NOT EXISTS (SELECT 1 FROM ${CARD_NUMBERS} WHERE task_id = NEW.id);
|
|
359
|
+
END;`);
|
|
360
|
+
db.exec('RELEASE supercode_card_numbering');
|
|
361
|
+
} catch (error) { db.exec('ROLLBACK TO supercode_card_numbering; RELEASE supercode_card_numbering'); throw error; }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Raise the numbers' high-water mark to the highest number held, and to `floor`: AUTOINCREMENT hands out only numbers
|
|
365
|
+
* above it. For a store whose numbers were given or copied explicitly. */
|
|
366
|
+
export function raiseCardNumbers(db, floor = 0) {
|
|
367
|
+
db.prepare(`INSERT INTO sqlite_sequence (name, seq) SELECT ?, 0 WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)`).run(CARD_NUMBERS, CARD_NUMBERS);
|
|
368
|
+
db.prepare(`UPDATE sqlite_sequence SET seq = MAX(seq, ?, (SELECT COALESCE(MAX(number), 0) FROM ${CARD_NUMBERS})) WHERE name = ?`).run(Number(floor) || 0, CARD_NUMBERS);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Another opener may add a column after our schema read. Re-read only a duplicate-column refusal.
|
|
372
|
+
function addColumns(db, table, columns) {
|
|
373
|
+
const names = () => new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));
|
|
374
|
+
const held = names();
|
|
375
|
+
for (const [column, type] of Object.entries(columns)) if (!held.has(column)) {
|
|
376
|
+
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); }
|
|
377
|
+
catch (error) { if (!/duplicate column name/i.test(error.message) || !names().has(column)) throw error; }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
279
381
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
280
382
|
export function layOut(db, { fresh }) {
|
|
281
383
|
if (fresh) db.exec(SCHEMA);
|
|
282
384
|
db.exec(OURS);
|
|
283
385
|
db.exec(PUBLICATION_SCHEMA);
|
|
284
|
-
|
|
285
|
-
for (const [
|
|
386
|
+
addColumns(db, 'tasks', { readable_id: 'TEXT' });
|
|
387
|
+
for (const [table, added] of Object.entries(PUBLICATION_COLUMNS)) {
|
|
388
|
+
addColumns(db, table, added);
|
|
389
|
+
}
|
|
390
|
+
addColumns(db, 'supercode_cards', OURS_COLUMNS);
|
|
391
|
+
db.exec('CREATE INDEX IF NOT EXISTS supercode_readable_ids ON tasks(readable_id)');
|
|
392
|
+
fillReadableIds(db);
|
|
393
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS supercode_open_readable_ids ON tasks(readable_id) WHERE status NOT IN ('done','cancelled','archived')");
|
|
286
394
|
// A card's subtasks are read by the arc they name, for every scope built (workflow.mjs scopeOf): without this index
|
|
287
395
|
// each read walked every card's row. Laid out here, after the column it names exists on a store of any age and before
|
|
288
396
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
289
397
|
db.exec('CREATE INDEX IF NOT EXISTS idx_supercode_cards_subtask_of ON supercode_cards(subtask_of)');
|
|
398
|
+
layOutNumbers(db);
|
|
290
399
|
layOutEventIndex(db);
|
|
291
400
|
layOutHistoryIndexes(db);
|
|
292
401
|
// A covering summary index keeps human listings out of task records' body overflow pages.
|
|
@@ -314,14 +423,13 @@ export function layOut(db, { fresh }) {
|
|
|
314
423
|
current().err(`board: warning: ${refused.length > 1 ? 'indexes' : 'index'} ${names} on task_runs ${refused.length > 1 ? 'were' : 'was'} not created (${refused[0][1]}); the board is open without ${refused.length > 1 ? 'them' : 'it'}: each session's verb reads every run for its caller, and finds its cards from the cards. The runs that refuse ${refused.length > 1 ? 'them' : 'it'}: SELECT id FROM task_runs WHERE metadata IS NOT NULL AND NOT json_valid(metadata, 6)\n`);
|
|
315
424
|
}
|
|
316
425
|
}
|
|
317
|
-
|
|
318
|
-
for (const [column, type] of Object.entries(EFFECT_COLUMNS)) if (!effectColumns.has(column)) db.exec(`ALTER TABLE supercode_effects ADD COLUMN ${column} ${type}`);
|
|
426
|
+
addColumns(db, 'supercode_effects', EFFECT_COLUMNS);
|
|
319
427
|
// what changed is marked by the store itself, whoever writes it (published.mjs)
|
|
320
428
|
publicationTriggers(db);
|
|
321
429
|
}
|
|
322
430
|
|
|
323
431
|
/**
|
|
324
|
-
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, path, backing, tx, close }`. `db` answers
|
|
432
|
+
* Open (creating when `create`, in `backing`) board `slug`: `{ db, root, slug, key, path, backing, tx, close }`. `db` answers
|
|
325
433
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
326
434
|
*/
|
|
327
435
|
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
@@ -456,9 +564,11 @@ function openBoardNow(root, slug, { create = false, backing = null, document = n
|
|
|
456
564
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
|
457
565
|
const use = kept ?? backing ?? 'sqlite';
|
|
458
566
|
if (!BACKINGS.includes(use)) throw new Error(`a board's backing is ${BACKINGS.join(' | ')}, not ${use}`);
|
|
567
|
+
// Its key names its cards in what it publishes (`ref`, VO-856): assigned once, never re-derived.
|
|
568
|
+
const key = boardKey(root, slug, { creating: !kept });
|
|
459
569
|
const path = backingPaths(root, slug)[use];
|
|
460
|
-
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug, held }), root, slug, path, backing: use };
|
|
461
|
-
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut, held }), root, slug, path, backing: use };
|
|
570
|
+
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug, held }), root, slug, key, path, backing: use };
|
|
571
|
+
if (use === 'files') return { ...openFilesBoard(path, { create, init: layOut, held }), root, slug, key, path, backing: use };
|
|
462
572
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
463
573
|
const db = new DatabaseSync(path);
|
|
464
574
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
@@ -481,7 +591,7 @@ function openBoardNow(root, slug, { create = false, backing = null, document = n
|
|
|
481
591
|
if (!contention(error)) throw error;
|
|
482
592
|
}
|
|
483
593
|
}
|
|
484
|
-
return { db, root, slug, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
|
|
594
|
+
return { db, root, slug, key, path, backing: use, tx: (fn) => sqliteTx(db, fn), close: () => db.close() };
|
|
485
595
|
}
|
|
486
596
|
|
|
487
597
|
/**
|
|
@@ -494,7 +604,7 @@ export const tx = (board, fn) => {
|
|
|
494
604
|
const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
|
|
495
605
|
const started = performance.now();
|
|
496
606
|
try {
|
|
497
|
-
const result = board.tx(db => {const result=fn(db);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
|
|
607
|
+
const result = board.tx(db => {const result=fn(db);fillReadableIds(db);const began=performance.now();try{recordCardPublications(db,actorOf(),board.root,board.key);}finally{noteStep('board tx publications',performance.now()-began);}return result;});
|
|
498
608
|
signalBoardWrite(board.root, actorOf());
|
|
499
609
|
return result;
|
|
500
610
|
} finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
|
|
@@ -514,7 +624,7 @@ export function publishMarked(root) {
|
|
|
514
624
|
// a mark in the store, or a backing edit not yet imported into its tables (a ztrack document): the transaction
|
|
515
625
|
// imports the edit, which marks its cards, and publishes them
|
|
516
626
|
const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
|
|
517
|
-
if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf(),board.root)));
|
|
627
|
+
if (marked) published += step('publish', () => board.tx((db) => recordCardPublications(db, actorOf(), board.root, board.key)));
|
|
518
628
|
if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
|
|
519
629
|
} finally { board?.close(); }
|
|
520
630
|
}
|