@volter/supercode-orchestrator 0.5.207 → 0.5.209
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 +125 -91
- package/board/codex-pane-inputs.mjs +44 -0
- package/board/dispatch.mjs +354 -84
- package/board/engine.mjs +77 -50
- package/board/event-stream.mjs +6 -1
- package/board/files.mjs +3 -1
- package/board/listing.mjs +13 -11
- package/board/machine-fleet.mjs +7 -5
- package/board/notices.mjs +2 -2
- package/board/published.mjs +124 -33
- 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 +95 -14
- package/board/tools.mjs +10 -8
- package/board/visibility.mjs +11 -0
- package/board/workflow.mjs +58 -16
- package/board/workspace.mjs +21 -9
- package/board/ztrack.mjs +119 -48
- package/codex-pane-resume.mjs +1 -0
- package/connection.mjs +108 -30
- 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 +2 -2
- package/source-lock.mjs +7 -2
- 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();
|
|
@@ -101,7 +125,11 @@ function agentFacts(db,taskId){
|
|
|
101
125
|
const agents=new Map(parse(checkpoint?.facts_json,[]).map(agent=>[JSON.stringify([agent.org,agent.assignment,agent.id]),agent]));
|
|
102
126
|
let scope=db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?').get(taskId)??null;
|
|
103
127
|
let receipt=checkpoint?.receipt_sequence??0,declared=checkpoint?.declared_event_id??0;
|
|
104
|
-
|
|
128
|
+
// Match idx_events_task_kind's order. ORDER BY id alone lets SQLite choose idx_events_task_id and fetch every
|
|
129
|
+
// event's row just to reject its kind (including cards with no agent events). Those synchronous page reads held
|
|
130
|
+
// the owner's loop inside synchronizeAgents and expired unrelated manager lookups. Sort only the matching events.
|
|
131
|
+
const events=db.prepare(`SELECT id,kind,payload FROM task_events WHERE task_id=? AND kind IN ${AGENT_KINDS} ORDER BY kind,id`).all(taskId);
|
|
132
|
+
for(const event of events.sort((a,b)=>a.id-b.id)){
|
|
105
133
|
if(event.kind==='agent_declared')declared=Math.max(declared,event.id);
|
|
106
134
|
const value=parse(event.payload,{});
|
|
107
135
|
if(Number.isSafeInteger(value.receipt))receipt=Math.max(receipt,value.receipt);
|
|
@@ -160,17 +188,51 @@ export function syncCardAgentScope(db,id,transition){
|
|
|
160
188
|
/** Organization scope stays internal; the publication contract has exactly these five fields. */
|
|
161
189
|
export function cardAgents(db,id){return cardAgentFacts(db,id).map(({org,...agent})=>agent);}
|
|
162
190
|
/** A card's rows read one card at a time. */
|
|
163
|
-
const perCard=db=>
|
|
191
|
+
const perCard=db=>{
|
|
192
|
+
// A card's short sequential number (store.mjs CARD_NUMBERS, named here as store.mjs imports this module), in a store
|
|
193
|
+
// that has numbered its cards.
|
|
194
|
+
const numbered=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_card_numbers'").get();
|
|
195
|
+
return {
|
|
196
|
+
number:id=>numbered?db.prepare('SELECT number FROM supercode_card_numbers WHERE task_id=?').get(id)?.number??null:null,
|
|
164
197
|
own:id=>db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(id),
|
|
165
198
|
comments:id=>db.prepare('SELECT id,author,body,created_at FROM task_comments WHERE task_id=? ORDER BY id').all(id),
|
|
199
|
+
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
200
|
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
201
|
agents:id=>cardAgents(db,id),
|
|
168
202
|
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
203
|
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
204
|
children:id=>db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(id).map(link=>link.child_id),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
// The columns of a task row that change while its card runs and that no snapshot reads (cardSnapshot).
|
|
208
|
+
const UNREAD_TASK_COLUMNS = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at']);
|
|
209
|
+
// The events a snapshot reads: its reviews, its agents and its archive reason.
|
|
210
|
+
const SNAPSHOT_EVENT_KINDS = `(${[...REVIEW_KIND_LIST,...AGENT_KIND_LIST,'archived'].map((kind) => `'${kind}'`).join(',')})`;
|
|
211
|
+
const fingerprintReads=db=>({
|
|
212
|
+
// comments and events are only ever added or removed by supercode's writers; their bytes catch a document edit that
|
|
213
|
+
// rewrites one in place; SQLite answers octet_length from a row's header, without reading its text
|
|
214
|
+
comments:db.prepare('SELECT count(*) AS n,max(id) AS last,total(octet_length(body)) AS bytes FROM task_comments WHERE task_id=?'),
|
|
215
|
+
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}`),
|
|
216
|
+
facts:db.prepare('SELECT facts_json,receipt_sequence,declared_event_id FROM supercode_card_agent_facts WHERE task_id=?'),
|
|
217
|
+
scope:db.prepare('SELECT from_org,org,changed_at FROM supercode_card_agent_scopes WHERE task_id=?'),
|
|
218
|
+
// a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
|
|
219
|
+
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
220
|
});
|
|
172
|
-
|
|
173
|
-
|
|
221
|
+
/**
|
|
222
|
+
* A card's fingerprint: what its snapshot is built from, read cheaply (no comment text, review or agent event payloads,
|
|
223
|
+
* and no snapshot built), with the snapshot's shape, its board's key and the options it is built with. Equal
|
|
224
|
+
* fingerprints build equal snapshots, but for a run's session observation passing its freshness (executionOf), which a
|
|
225
|
+
* snapshot's readers apply themselves.
|
|
226
|
+
*/
|
|
227
|
+
function fingerprintOf(reads,rows,row,key,options){
|
|
228
|
+
const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
|
|
229
|
+
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),
|
|
230
|
+
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)]));
|
|
231
|
+
}
|
|
232
|
+
/** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
|
|
233
|
+
* (`VO-856`, store.mjs cardRef), null for a card with no number. */
|
|
234
|
+
export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
|
|
235
|
+
const own=rows.own(row.id)??{},number=rows.number(row.id);
|
|
174
236
|
const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
|
|
175
237
|
const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
|
|
176
238
|
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 +244,11 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
182
244
|
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
245
|
});
|
|
184
246
|
const lane=own.stage??row.status;
|
|
185
|
-
|
|
247
|
+
const archived=Boolean(own.archived || lane==='archived');
|
|
248
|
+
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
249
|
execution:executionOf(runs.find(run=>run.id===row.current_run_id)),
|
|
187
250
|
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]:[]),
|
|
251
|
+
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
252
|
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
|
|
190
253
|
workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
|
|
191
254
|
parents:rows.parents(row.id),
|
|
@@ -195,60 +258,88 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
|
|
|
195
258
|
/**
|
|
196
259
|
* Publish the cards this transaction (or a native writer before it) marked changed, inside the backing's own
|
|
197
260
|
* transaction: each marked card's snapshot when it differs from its published head, a removal when the card is gone.
|
|
198
|
-
*
|
|
261
|
+
* A marked card whose fingerprint is the one its head was last compared at builds no snapshot: a write that marks a card
|
|
262
|
+
* without changing what its snapshot reads (a run's heartbeat, a rewrite of the same values) costs the fingerprint's
|
|
263
|
+
* reads. The head keeps the fingerprint with the digest it was compared against, so a head another release moved is
|
|
264
|
+
* compared again. The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
|
|
199
265
|
*/
|
|
200
|
-
export function recordCardPublications(db,actor,root) {
|
|
266
|
+
export function recordCardPublications(db,actor,root,key) {
|
|
267
|
+
const options={nativeHolds:Boolean(workflowFor(root,{optional:true})?.workflow.flags?.paused)};
|
|
201
268
|
const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
|
|
202
269
|
if(!marked.length)return 0;
|
|
203
270
|
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=?');
|
|
271
|
+
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
272
|
const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
|
|
206
273
|
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 (
|
|
274
|
+
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');
|
|
275
|
+
const compared=db.prepare('UPDATE supercode_publication_heads SET fingerprint=? WHERE resource_id=?');
|
|
208
276
|
let changed=0;
|
|
209
277
|
for(const id of marked){
|
|
210
278
|
const row=task.get(id),previous=head.get(id);
|
|
211
279
|
if(row){
|
|
212
280
|
const transition=cardAgentScopeTransition(root,id);
|
|
213
281
|
if(transition)syncCardAgentScope(db,id,transition);else compactCardAgentEvents(db,id);
|
|
214
|
-
const
|
|
215
|
-
if(previous
|
|
282
|
+
const fingerprint=fingerprintOf(reads,rows,row,key,options);
|
|
283
|
+
if(previous&&previous.digest!=='removed'&&previous.fingerprint===`${fingerprint}:${previous.digest}`)continue;
|
|
284
|
+
const snapshot=JSON.stringify(cardSnapshot(db,row,rows,key,options)),digest=hash(snapshot);
|
|
285
|
+
if(previous?.digest===digest){compared.run(`${fingerprint}:${digest}`,id);continue;}
|
|
216
286
|
const last=lastEvent.get(id);
|
|
217
287
|
const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
|
|
218
288
|
const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
|
|
219
|
-
upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
|
|
289
|
+
upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null,`${fingerprint}:${digest}`);changed++;
|
|
220
290
|
}else if(previous&&previous.digest!=='removed'){
|
|
221
291
|
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
|
|
292
|
+
db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=?,fingerprint=NULL WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
|
|
223
293
|
}
|
|
224
294
|
}
|
|
225
295
|
db.prepare('DELETE FROM supercode_publication_dirty').run();
|
|
226
296
|
return changed;
|
|
227
297
|
}
|
|
228
298
|
|
|
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. */
|
|
299
|
+
// An older change: one no card's head is (the heads' sequence index answers it per change).
|
|
300
|
+
const OLDER='NOT EXISTS (SELECT 1 FROM supercode_publication_heads h WHERE h.sequence=p.sequence)';
|
|
301
|
+
/** Whether retention has work: a prune under way, or an upper bound on the older changes kept (sequences above the floor
|
|
302
|
+
* that are no card's head), from the log's ends and the heads above the floor, in indexed reads. */
|
|
236
303
|
export function pruneDue(db) {
|
|
237
|
-
const log=db.prepare('SELECT pruned_through FROM supercode_publication_log').get();
|
|
304
|
+
const log=db.prepare('SELECT pruned_through,prune_from FROM supercode_publication_log').get();
|
|
238
305
|
if(!log)return false;
|
|
306
|
+
if(log.prune_from!=null)return true;
|
|
239
307
|
const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
|
|
240
308
|
// the heads above the floor are not older changes; those below it were already left out by subtracting the floor
|
|
241
309
|
const heads=Number(db.prepare('SELECT count(*) AS n FROM supercode_publication_heads WHERE sequence>?').get(Number(log.pruned_through)).n);
|
|
242
310
|
return high-Number(log.pruned_through)-heads>2*RETAINED_CHANGES;
|
|
243
311
|
}
|
|
244
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Bounded retention, the publication owner's maintenance (never a write's): every card's head stays, and once more than
|
|
315
|
+
* twice RETAINED_CHANGES older changes accumulate, all but the newest RETAINED_CHANGES of them go. A prune's first
|
|
316
|
+
* transaction raises the floor to the newest change it removes, so from then a reader whose cursor is below the floor
|
|
317
|
+
* takes a new snapshot and never reads the older changes still held; each transaction (this one and every later call)
|
|
318
|
+
* deletes at most PRUNE_BATCH of them, oldest first, recording where the next resumes (prune_from), and the last clears
|
|
319
|
+
* it. The owner runs one transaction per wake while pruneDue answers true. Answers the changes removed.
|
|
320
|
+
*/
|
|
245
321
|
export function prunePublications(db) {
|
|
246
322
|
if(!pruneDue(db))return 0;
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
323
|
+
const log=db.prepare('SELECT pruned_through,prune_from FROM supercode_publication_log').get();
|
|
324
|
+
let from=log.prune_from==null?null:Number(log.prune_from);
|
|
325
|
+
if(from==null){
|
|
326
|
+
const older=db.prepare(`SELECT sequence FROM supercode_publications p WHERE ${OLDER} ORDER BY sequence DESC LIMIT 1 OFFSET ?`);
|
|
327
|
+
if(!older.get(2*RETAINED_CHANGES))return 0;
|
|
328
|
+
const cut=Number(older.get(RETAINED_CHANGES).sequence);
|
|
329
|
+
db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
|
|
330
|
+
from=0;
|
|
331
|
+
}
|
|
332
|
+
const floor=Number(db.prepare('SELECT pruned_through FROM supercode_publication_log').get().pruned_through);
|
|
333
|
+
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);
|
|
334
|
+
const batch=[];let bytes=0;
|
|
335
|
+
for(const row of candidates){
|
|
336
|
+
// A single oversized row still makes progress, in a transaction of its own.
|
|
337
|
+
if(batch.length&&bytes+Number(row.bytes)>PRUNE_BYTES)break;
|
|
338
|
+
batch.push(Number(row.sequence));bytes+=Number(row.bytes);
|
|
339
|
+
if(bytes>=PRUNE_BYTES)break;
|
|
340
|
+
}
|
|
341
|
+
const remove=db.prepare('DELETE FROM supercode_publications WHERE sequence=?');
|
|
342
|
+
for(const sequence of batch)remove.run(sequence);
|
|
343
|
+
db.prepare('UPDATE supercode_publication_log SET prune_from=?').run(candidates.length<PRUNE_BATCH&&batch.length===candidates.length?null:batch.at(-1));
|
|
344
|
+
return batch.length;
|
|
254
345
|
}
|
|
@@ -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] : [];
|
|
@@ -304,13 +368,29 @@ export function raiseCardNumbers(db, floor = 0) {
|
|
|
304
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);
|
|
305
369
|
}
|
|
306
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
|
+
|
|
307
381
|
/** Hermes's tables on a fresh board, supercode's own table and columns on every one. */
|
|
308
382
|
export function layOut(db, { fresh }) {
|
|
309
383
|
if (fresh) db.exec(SCHEMA);
|
|
310
384
|
db.exec(OURS);
|
|
311
385
|
db.exec(PUBLICATION_SCHEMA);
|
|
312
|
-
|
|
313
|
-
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')");
|
|
314
394
|
// A card's subtasks are read by the arc they name, for every scope built (workflow.mjs scopeOf): without this index
|
|
315
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
|
|
316
396
|
// any statement is prepared; no statement names it, so a store without it is only slower.
|
|
@@ -343,14 +423,13 @@ export function layOut(db, { fresh }) {
|
|
|
343
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`);
|
|
344
424
|
}
|
|
345
425
|
}
|
|
346
|
-
|
|
347
|
-
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);
|
|
348
427
|
// what changed is marked by the store itself, whoever writes it (published.mjs)
|
|
349
428
|
publicationTriggers(db);
|
|
350
429
|
}
|
|
351
430
|
|
|
352
431
|
/**
|
|
353
|
-
* 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
|
|
354
433
|
* SQLite's statement API (`prepare(sql).get|all|run`, `exec`) whatever the backing.
|
|
355
434
|
*/
|
|
356
435
|
// A home records where it was made (`.board-origin` at its root). A copy of the home carries that record elsewhere and
|
|
@@ -485,9 +564,11 @@ function openBoardNow(root, slug, { create = false, backing = null, document = n
|
|
|
485
564
|
if (kept && backing && kept !== backing) throw new Error(`board ${slug} in ${root} is kept by the ${kept} backing, not ${backing}`);
|
|
486
565
|
const use = kept ?? backing ?? 'sqlite';
|
|
487
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 });
|
|
488
569
|
const path = backingPaths(root, slug)[use];
|
|
489
|
-
if (use === 'ztrack') return { ...openZtrackBoard(path, { create, document, declared, init: layOut, root, slug, held }), root, slug, path, backing: use };
|
|
490
|
-
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 };
|
|
491
572
|
if (!kept) mkdirSync(dirname(path), { recursive: true });
|
|
492
573
|
const db = new DatabaseSync(path);
|
|
493
574
|
db.exec('PRAGMA foreign_keys = OFF');
|
|
@@ -510,7 +591,7 @@ function openBoardNow(root, slug, { create = false, backing = null, document = n
|
|
|
510
591
|
if (!contention(error)) throw error;
|
|
511
592
|
}
|
|
512
593
|
}
|
|
513
|
-
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() };
|
|
514
595
|
}
|
|
515
596
|
|
|
516
597
|
/**
|
|
@@ -523,7 +604,7 @@ export const tx = (board, fn) => {
|
|
|
523
604
|
const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
|
|
524
605
|
const started = performance.now();
|
|
525
606
|
try {
|
|
526
|
-
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;});
|
|
527
608
|
signalBoardWrite(board.root, actorOf());
|
|
528
609
|
return result;
|
|
529
610
|
} finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
|
|
@@ -543,7 +624,7 @@ export function publishMarked(root) {
|
|
|
543
624
|
// a mark in the store, or a backing edit not yet imported into its tables (a ztrack document): the transaction
|
|
544
625
|
// imports the edit, which marks its cards, and publishes them
|
|
545
626
|
const marked = step('marks', () => board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get()) || step('unimported', () => board.unimported?.());
|
|
546
|
-
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)));
|
|
547
628
|
if (step('prune due', () => pruneDue(board.db))) step('prune', () => board.tx((db) => prunePublications(db)));
|
|
548
629
|
} finally { board?.close(); }
|
|
549
630
|
}
|