@volter/supercode-orchestrator 0.5.207 → 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.
@@ -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 mark = (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(' ');
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 in one batch once twice as many accumulate.
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
- export function cardSnapshot(db,row,rows=perCard(db)) {
173
- const own=rows.own(row.id)??{};
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
- return {id:row.id,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),lane,
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]:[]),sponsor:own.sponsor??null,sponsor_evidence:parse(own.sponsor_evidence),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived:!!own.archived,
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
- * The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
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 (?,?,?,?) ON CONFLICT(resource_id) DO UPDATE SET digest=excluded.digest,sequence=excluded.sequence,last_event_id=excluded.last_event_id');
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 snapshot=JSON.stringify(cardSnapshot(db,row,rows)),digest=hash(snapshot);
215
- if(previous?.digest===digest)continue;
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=? WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
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
- * Bounded retention, the publication owner's maintenance (never a write's): every card's head stays, and once more than
231
- * twice RETAINED_CHANGES older changes accumulate, all but the newest RETAINED_CHANGES of them go in one batch, the
232
- * floor recording where. A reader whose cursor is below the floor takes a new snapshot. Answers the changes removed.
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 history=Number(db.prepare('SELECT count(*) AS n FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads)').get().n);
248
- if(history<=2*RETAINED_CHANGES)return 0;
249
- const cut=Number(db.prepare('SELECT sequence FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads) ORDER BY sequence DESC LIMIT 1 OFFSET ?').get(RETAINED_CHANGES).sequence);
250
- const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
251
- const removed=db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut).changes;
252
- db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
253
- return Number(removed);
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
+ }
@@ -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 { this.apply(frame); }
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 }) => {
@@ -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: `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` +
52
- group.map(row => `${row.task_id} [${row.mail.message_id}]\n${row.mail.text}`).join('\n\n---\n\n') +
53
- `\n\nAnswer with reply_to ${first.mail.message_id}.`,
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); `sponsor`, `sponsor_evidence`, `requested_by_unverified`: C4
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', sponsor: 'TEXT', sponsor_evidence: 'TEXT', requested_by_unverified: '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
- const have = new Set(db.prepare('PRAGMA table_info(supercode_cards)').all().map((c) => c.name));
313
- for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
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
- const effectColumns = new Set(db.prepare('PRAGMA table_info(supercode_effects)').all().map((c) => c.name));
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
  }