@volter/supercode-orchestrator 0.5.69 → 0.5.71

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/board/files.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // runs, retries if that hash changed, and writes the cards it touched (temporary triggers name them), `_board.json` last. A read
10
10
  // outside a transaction reloads first when `_board.json` changed, as a SQLite reader sees the last commit.
11
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { fenced } from './fence.mjs';
13
+ import { markRemovedHeads } from './published.mjs';
12
14
  import { createHash } from 'node:crypto';
13
15
  import { join } from 'node:path';
14
16
  import { DatabaseSync } from 'node:sqlite';
@@ -81,6 +83,8 @@ class FilesBoard {
81
83
  load() {
82
84
  const stamp = this.stampOf();
83
85
  if (this.mem && stamp === this.stamp) return;
86
+ const marked = this.mem && this.mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_dirty'").get()
87
+ ? this.mem.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map((r) => r.task_id) : [];
84
88
  this.mem?.close();
85
89
  this.statements.clear();
86
90
  const mem = new DatabaseSync(':memory:');
@@ -96,6 +100,7 @@ class FilesBoard {
96
100
  mem.prepare(`INSERT INTO ${quote(table)} (${keys.map(quote).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k]));
97
101
  }
98
102
  };
103
+ const previous = this.texts;
99
104
  this.texts = new Map();
100
105
  for (const name of readdirSync(this.dir)) {
101
106
  if (!name.endsWith('.json') || name.startsWith('_')) continue;
@@ -108,6 +113,19 @@ class FilesBoard {
108
113
  for (const { name, seq } of state?.sequence ?? []) {
109
114
  if (mem.prepare('UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?').run(seq, name).changes === 0) mem.prepare('INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(name, seq);
110
115
  }
116
+ // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
117
+ // those marks: its next write compares each card with its published head once (a named limit: each projection opened
118
+ // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
119
+ // published and adds only the cards whose files changed since (a hand edit, a card another writer added or removed);
120
+ // this process's own writes are already the texts it knows (persist), so they are not marked again.
121
+ if (previous && mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_dirty'").get()) {
122
+ mem.exec('DELETE FROM supercode_publication_dirty');
123
+ const mark = mem.prepare('INSERT INTO supercode_publication_dirty (task_id) VALUES (?)');
124
+ const changed = [...new Set([...previous.keys(), ...this.texts.keys()])].filter((id) => previous.get(id) !== this.texts.get(id));
125
+ for (const id of new Set([...marked, ...changed])) mark.run(id);
126
+ }
127
+ // a card whose file is gone has no row to fire its delete trigger: its live head is marked, so it is published removed
128
+ if (mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_heads'").get()) markRemovedHeads(mem);
111
129
  this.mem = mem;
112
130
  this.watch();
113
131
  this.stamp = stamp;
@@ -169,21 +187,22 @@ class FilesBoard {
169
187
  }
170
188
  const file = join(this.dir, `${String(id).replace(/[^A-Za-z0-9_.-]/g, '_')}.json`);
171
189
  if (!Object.keys(tables).length) {
172
- if (this.texts.has(id)) { writes.push({ file, text: null }); wrote = true; }
190
+ if (this.texts.has(id)) { writes.push({ file, text: null, id }); wrote = true; }
173
191
  continue;
174
192
  }
175
193
  const text = `${JSON.stringify({ id, tables }, null, 2)}\n`;
176
194
  if (this.texts.get(id) === text) continue;
177
- writes.push({ file, text });
195
+ writes.push({ file, text, id });
178
196
  wrote = true;
179
197
  }
180
198
  if (wrote || stateText !== this.stateText) {
181
199
  writes.push({ file: join(this.dir, STATE), text: stateText });
182
200
  }
183
201
  if (this.stampOf() !== this.stamp) return false;
184
- for (const { file, text } of writes) {
202
+ for (const { file, text, id } of writes) {
185
203
  if (text === null) { try { unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
186
204
  else writeAtomic(file, text);
205
+ if (id !== undefined) { if (text === null) this.texts.delete(id); else this.texts.set(id, text); }
187
206
  }
188
207
  this.stamp = null; // next read reloads the committed file hashes and card texts
189
208
  return true;
@@ -216,6 +235,7 @@ class FilesBoard {
216
235
  const before = this.schema();
217
236
  this.mem.exec('BEGIN');
218
237
  this.inTx = true;
238
+ fenced(this.mem);
219
239
  let out;
220
240
  try {
221
241
  out = fn(this.db);
@@ -6,6 +6,7 @@
6
6
  // the two texts of a board document merged by ztrack's own document merge, in which a hand edit wins its cards. Nothing
7
7
  // either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
8
8
  // checkout's history.
9
+ import { current } from './call.mjs';
9
10
  import { execFileSync, spawn } from 'node:child_process';
10
11
  import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
12
  import { basename, dirname, relative } from 'node:path';
@@ -50,7 +51,7 @@ export class GitDocument {
50
51
  /** Said once per process: a checkout on no branch keeps its commits here (every write tried to push `HEAD:HEAD`
51
52
  * three times, with a fetch and a merge between, and failed: seconds of network on each write, t_23e47b73). */
52
53
  detached() {
53
- if (!this.warnedDetached) process.stderr.write(`board: the board documents' checkout ${this.root} is on no branch; writes are committed there and not pushed until it is on one\n`);
54
+ if (!this.warnedDetached) current().err(`board: the board documents' checkout ${this.root} is on no branch; writes are committed there and not pushed until it is on one\n`);
54
55
  this.warnedDetached = true;
55
56
  }
56
57
 
@@ -0,0 +1,49 @@
1
+ // The board owner's one-per-home lock, held by a process of its own. A POSIX record lock belongs to the process that took
2
+ // it, and any open and close of the same file in that process drops it; no reader in the owner can reach a lock another
3
+ // process holds. The holder takes SQLite's exclusive lock on `board.lock` (the default VFS), says so, and exits when its
4
+ // standard input closes, which the kernel does when the owner ends however it ends, so the lock is released with the
5
+ // owner. One spawn per owner lifetime.
6
+ import { spawn } from 'node:child_process';
7
+
8
+ const HOLDER = `
9
+ const { DatabaseSync } = require('node:sqlite');
10
+ let lock;
11
+ try {
12
+ lock = new DatabaseSync(process.argv[1]);
13
+ lock.exec('PRAGMA busy_timeout = 0; PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE');
14
+ } catch (error) { process.stdout.write('held ' + String(error.message).replace(/\\n/g, ' ') + '\\n'); process.exit(1); }
15
+ process.stdout.write('ready\\n');
16
+ process.stdin.on('data', () => {});
17
+ process.stdin.on('end', () => process.exit(0));
18
+ process.stdin.on('error', () => process.exit(0));
19
+ `;
20
+
21
+ /**
22
+ * Hold `path` for this process's life. Resolves once the holder has the lock; rejects with its reason when another
23
+ * owner holds it. `onLost` is told if the holder ends while this process still runs (the lock is then gone).
24
+ * Returns `release()`, which ends the holder.
25
+ */
26
+ export function holdLock(path, { onLost = () => {} } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(process.execPath, ['--no-warnings', '-e', HOLDER, path], { stdio: ['pipe', 'pipe', 'ignore'] });
29
+ let said = '';
30
+ let ready = false;
31
+ let released = false;
32
+ child.stdout.setEncoding('utf8');
33
+ child.stdout.on('data', (chunk) => {
34
+ said += chunk;
35
+ if (ready || !said.includes('\n')) return;
36
+ const line = said.slice(0, said.indexOf('\n'));
37
+ if (line === 'ready') {
38
+ ready = true;
39
+ child.unref(); child.stdout.unref?.(); child.stdin.unref?.();
40
+ resolve({ release: () => { released = true; child.stdin.end(); } });
41
+ } else reject(new Error(line.replace(/^held /, '')));
42
+ });
43
+ child.on('error', (error) => { if (!ready) reject(error); });
44
+ child.on('exit', (code, signal) => {
45
+ if (!ready) { if (!said.includes('\n')) reject(new Error(`the lock holder ended before it held the lock (${signal ?? code})`)); return; }
46
+ if (!released) onLost(new Error(`the lock holder for ${path} ended (${signal ?? code})`));
47
+ });
48
+ });
49
+ }
@@ -0,0 +1,19 @@
1
+ // The owner never takes its own runtime files (`<home>/board.lock`, `<home>/board.sock`) as a path from outside: a verb's
2
+ // input (cli.mjs callerPath) or a file the workflow names (workflow.mjs, a run's prompt_file). One real-path check.
3
+ import { realpathSync } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+
6
+ const RUNTIME = new Set(['board.lock', 'board.sock']);
7
+ let home = null;
8
+
9
+ /** The home this process owns (the owner's, set once when it binds). */
10
+ export function ownHome(root) { try { home = realpathSync(root); } catch { home = resolve(root); } }
11
+
12
+ /** `path`, unless its real path is one of the owned home's runtime files. */
13
+ export function notRuntime(path) {
14
+ if (home === null) return path;
15
+ let real;
16
+ try { real = realpathSync(path); } catch { try { real = join(realpathSync(dirname(resolve(path))), basename(path)); } catch { return path; } }
17
+ if (dirname(real) === home && RUNTIME.has(basename(real))) throw new Error(`${path} is the board owner's own runtime file; no verb takes it`);
18
+ return path;
19
+ }
@@ -1,4 +1,9 @@
1
- // The card writer commits the full snapshot and a retained delivery obligation together.
1
+ // The card writer commits the changed cards' snapshots and a retained delivery obligation together (ADR 0003: a write is
2
+ // proportional to the cards it changed; ADR 0008: a source change and its publication are one transaction).
3
+ //
4
+ // What changed is marked in the store itself: triggers on every table a card's snapshot reads note the card's id in
5
+ // supercode_publication_dirty, whoever writes (this process, Hermes's native writer, a document import). A transaction
6
+ // publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
2
7
  import { createHash, randomUUID } from 'node:crypto';
3
8
  export const PUBLICATION_SCHEMA = `
4
9
  CREATE TABLE IF NOT EXISTS supercode_publications (
@@ -10,7 +15,52 @@ CREATE TABLE IF NOT EXISTS supercode_publication_heads (
10
15
  );
11
16
  CREATE TABLE IF NOT EXISTS supercode_publication_log (
12
17
  id TEXT PRIMARY KEY, pruned_through INTEGER NOT NULL DEFAULT 0
13
- );`;
18
+ );
19
+ -- keyless: a writing statement's own conflict clause (an upsert, OR REPLACE, OR ABORT) governs its triggers' inserts
20
+ -- too, so a key here would fail the writer's statement; readers take the distinct ids
21
+ CREATE TABLE IF NOT EXISTS supercode_publication_dirty (task_id TEXT NOT NULL);
22
+ CREATE INDEX IF NOT EXISTS supercode_publication_dirty_task ON supercode_publication_dirty(task_id);
23
+ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_publication_heads(sequence);`;
24
+
25
+ const REVIEW_KIND_LIST = ['review_requested', 'approved', 'review_approved', 'changes_requested', 'escalated'];
26
+ // The tables a card's snapshot reads (cardSnapshot), each with the column naming its card; a link names two.
27
+ 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']]];
28
+ /**
29
+ * The triggers that mark a changed card, on a store that has these tables (CREATE … IF NOT EXISTS: laid out on every
30
+ * open). A card already marked is not marked again, so a writer's many writes while the owner is away leave one mark.
31
+ * Their first installation reconciles once, in the same savepoint: every card and every live head is marked, so a
32
+ * change a native writer committed before any trigger existed (an edit, a new card, a deletion) is compared once.
33
+ */
34
+ export function publicationTriggers(db) {
35
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name));
36
+ const first = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'supercode_publish_tasks_insert'").get();
37
+ const reviews = `(${REVIEW_KIND_LIST.map((kind) => `'${kind}'`).join(',')})`;
38
+ db.exec('SAVEPOINT supercode_publication_marking');
39
+ try {
40
+ for (const [table, columns] of MARKED) {
41
+ if (!tables.has(table)) continue;
42
+ // a task event changes a snapshot only when it is a review (the snapshot's `reviews`): an update marks when the
43
+ // row was one before or is one after
44
+ const when = (rows) => table === 'task_events' ? ` WHEN ${rows.map((row) => `${row}.kind IN ${reviews}`).join(' OR ')}` : '';
45
+ 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(' ');
46
+ db.exec(`CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_insert AFTER INSERT ON ${table}${when(['NEW'])} BEGIN ${mark('NEW')} END;
47
+ CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_update AFTER UPDATE ON ${table}${when(['OLD', 'NEW'])} BEGIN ${mark('NEW')} ${mark('OLD')} END;
48
+ CREATE TRIGGER IF NOT EXISTS supercode_publish_${table}_delete AFTER DELETE ON ${table}${when(['OLD'])} BEGIN ${mark('OLD')} END;`);
49
+ }
50
+ if (first && tables.has('tasks')) db.exec(`INSERT INTO supercode_publication_dirty (task_id)
51
+ SELECT id FROM tasks UNION SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed'
52
+ EXCEPT SELECT task_id FROM supercode_publication_dirty`);
53
+ db.exec('RELEASE supercode_publication_marking');
54
+ } catch (error) { db.exec('ROLLBACK TO supercode_publication_marking; RELEASE supercode_publication_marking'); throw error; }
55
+ }
56
+
57
+ /** Mark the live heads whose card is gone: a backing that loads cards from files (files.mjs) has no row to fire a delete
58
+ * trigger when a card's file is removed outside it. */
59
+ export function markRemovedHeads(db) {
60
+ db.exec(`INSERT INTO supercode_publication_dirty (task_id)
61
+ SELECT resource_id FROM supercode_publication_heads WHERE digest != 'removed' AND resource_id NOT IN (SELECT id FROM tasks)
62
+ EXCEPT SELECT task_id FROM supercode_publication_dirty`);
63
+ }
14
64
  // Every card's head stays; older changes beyond this many are pruned in one batch once twice as many accumulate.
15
65
  // A reader whose cursor falls below the pruned floor takes a new snapshot.
16
66
  const RETAINED_CHANGES = 1024;
@@ -30,7 +80,7 @@ export function taskStatusOf(lane, completedAt) {
30
80
  return 'todo';
31
81
  }
32
82
 
33
- const REVIEW_KINDS="('review_requested','approved','review_approved','changes_requested','escalated')";
83
+ const REVIEW_KINDS=`(${REVIEW_KIND_LIST.map((kind) => `'${kind}'`).join(',')})`;
34
84
  /** A card's rows read one card at a time. */
35
85
  const perCard=db=>({
36
86
  own:id=>db.prepare('SELECT * FROM supercode_cards WHERE task_id=?').get(id),
@@ -40,21 +90,6 @@ const perCard=db=>({
40
90
  parents:id=>db.prepare('SELECT parent_id FROM task_links WHERE child_id=? ORDER BY parent_id').all(id).map(link=>link.parent_id),
41
91
  children:id=>db.prepare('SELECT child_id FROM task_links WHERE parent_id=? ORDER BY child_id').all(id).map(link=>link.child_id),
42
92
  });
43
- /** Every card's rows read in one query per table: what a write that snapshots the whole board reads (one query per card
44
- * and table was most of each write's transaction, 470 ms for this board's 1,553 cards, t_23e47b73). */
45
- function wholeBoard(db){
46
- const grouped=(sql,pick)=>{const by=new Map();for(const row of db.prepare(sql).all()){const list=by.get(row.task_id);const value=pick(row);if(list)list.push(value);else by.set(row.task_id,[value]);}return id=>by.get(id)??[];};
47
- const own=new Map(db.prepare('SELECT * FROM supercode_cards').all().map(row=>[row.task_id,row]));
48
- return {
49
- own:id=>own.get(id),
50
- comments:grouped('SELECT task_id,id,author,body,created_at FROM task_comments ORDER BY id',({id,author,body,created_at})=>({id,author,body,created_at})),
51
- reviews:grouped(`SELECT task_id,id,kind,payload,created_at FROM task_events WHERE kind IN ${REVIEW_KINDS} ORDER BY id`,({id,kind,payload,created_at})=>({id,kind,payload,created_at})),
52
- attempts:grouped('SELECT task_id,id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs ORDER BY id',({task_id:_,...run})=>run),
53
- parents:grouped('SELECT child_id AS task_id,parent_id FROM task_links ORDER BY parent_id',link=>link.parent_id),
54
- children:grouped('SELECT parent_id AS task_id,child_id FROM task_links ORDER BY child_id',link=>link.child_id),
55
- };
56
- }
57
-
58
93
  export function cardSnapshot(db,row,rows=perCard(db)) {
59
94
  const own=rows.own(row.id)??{};
60
95
  const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
@@ -76,31 +111,61 @@ export function cardSnapshot(db,row,rows=perCard(db)) {
76
111
  reviews,children:rows.children(row.id),comments,attempts};
77
112
  }
78
113
 
79
- /** Runs inside the backing's native transaction, including files and ztrack projections. */
114
+ /**
115
+ * Publish the cards this transaction (or a native writer before it) marked changed, inside the backing's own
116
+ * transaction: each marked card's snapshot when it differs from its published head, a removal when the card is gone.
117
+ * The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
118
+ */
80
119
  export function recordCardPublications(db,actor) {
81
- const rows=db.prepare('SELECT * FROM tasks ORDER BY id').all(),present=new Set(),board=wholeBoard(db);let changed=0;
82
- const heads=new Map(db.prepare('SELECT resource_id,digest,last_event_id FROM supercode_publication_heads').all().map(head=>[head.resource_id,head]));
83
- for(const row of rows){
84
- present.add(row.id);const snapshot=JSON.stringify(cardSnapshot(db,row,board)),digest=hash(snapshot);
85
- const previous=heads.get(row.id);
86
- if(previous?.digest===digest)continue;
87
- const last=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1').get(row.id);
88
- const kind=`task.${previous?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
89
- const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,row.id,kind,snapshot,actor,Date.now());
90
- 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').run(row.id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
91
- }
92
- for(const old of db.prepare('SELECT resource_id,digest FROM supercode_publication_heads').all()){
93
- if(present.has(old.resource_id)||old.digest==='removed')continue;
94
- const inserted=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)').run(`event_${randomUUID()}`,old.resource_id,'task.changed',JSON.stringify({id:old.resource_id,removed:true}),actor,Date.now());
95
- db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=? WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),old.resource_id);changed++;
96
- }
120
+ const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
121
+ if(!marked.length)return 0;
97
122
  db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
98
- const history=Number(db.prepare('SELECT count(*) AS n FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads)').get().n);
99
- if(history>2*RETAINED_CHANGES){
100
- 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);
101
- const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
102
- db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut);
103
- db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
123
+ 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=?');
124
+ const lastEvent=db.prepare('SELECT id,kind FROM task_events WHERE task_id=? ORDER BY id DESC LIMIT 1');
125
+ const insert=db.prepare('INSERT INTO supercode_publications(id,resource_id,kind,snapshot,actor,created_at) VALUES (?,?,?,?,?,?)');
126
+ 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');
127
+ let changed=0;
128
+ for(const id of marked){
129
+ const row=task.get(id),previous=head.get(id);
130
+ if(row){
131
+ const snapshot=JSON.stringify(cardSnapshot(db,row,rows)),digest=hash(snapshot);
132
+ if(previous?.digest===digest)continue;
133
+ const last=lastEvent.get(id);
134
+ const kind=`task.${previous&&previous.digest!=='removed'?(last?.id!==previous.last_event_id?kinds[last?.kind]??'changed':'changed'):'created'}`;
135
+ const inserted=insert.run(`event_${randomUUID()}`,id,kind,snapshot,actor,Date.now());
136
+ upsert.run(id,digest,Number(inserted.lastInsertRowid),last?.id??null);changed++;
137
+ }else if(previous&&previous.digest!=='removed'){
138
+ const inserted=insert.run(`event_${randomUUID()}`,id,'task.changed',JSON.stringify({id,removed:true}),actor,Date.now());
139
+ db.prepare('UPDATE supercode_publication_heads SET digest=?,sequence=? WHERE resource_id=?').run('removed',Number(inserted.lastInsertRowid),id);changed++;
140
+ }
104
141
  }
142
+ db.prepare('DELETE FROM supercode_publication_dirty').run();
105
143
  return changed;
106
144
  }
145
+
146
+ /**
147
+ * Bounded retention, the publication owner's maintenance (never a write's): every card's head stays, and once more than
148
+ * twice RETAINED_CHANGES older changes accumulate, all but the newest RETAINED_CHANGES of them go in one batch, the
149
+ * floor recording where. A reader whose cursor is below the floor takes a new snapshot. Answers the changes removed.
150
+ */
151
+ /** Whether retention has work: an upper bound on the older changes kept (sequences above the floor that are no card's
152
+ * head), from the log's ends and the heads above the floor, in indexed reads. */
153
+ export function pruneDue(db) {
154
+ const log=db.prepare('SELECT pruned_through FROM supercode_publication_log').get();
155
+ if(!log)return false;
156
+ const high=Number(db.prepare('SELECT MAX(sequence) AS n FROM supercode_publications').get()?.n??0);
157
+ // the heads above the floor are not older changes; those below it were already left out by subtracting the floor
158
+ const heads=Number(db.prepare('SELECT count(*) AS n FROM supercode_publication_heads WHERE sequence>?').get(Number(log.pruned_through)).n);
159
+ return high-Number(log.pruned_through)-heads>2*RETAINED_CHANGES;
160
+ }
161
+
162
+ export function prunePublications(db) {
163
+ if(!pruneDue(db))return 0;
164
+ const history=Number(db.prepare('SELECT count(*) AS n FROM supercode_publications WHERE sequence NOT IN (SELECT sequence FROM supercode_publication_heads)').get().n);
165
+ if(history<=2*RETAINED_CHANGES)return 0;
166
+ 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);
167
+ const old='SELECT id FROM supercode_publications WHERE sequence<=? AND sequence NOT IN (SELECT sequence FROM supercode_publication_heads)';
168
+ const removed=db.prepare(`DELETE FROM supercode_publications WHERE id IN (${old})`).run(cut).changes;
169
+ db.prepare('UPDATE supercode_publication_log SET pruned_through=MAX(pruned_through,?)').run(cut);
170
+ return Number(removed);
171
+ }
package/board/store.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // This module opens a board in its backing and gives its callers one primitive, a write transaction; the transitions
10
10
  // are in `engine.mjs`. Hermes's own tools may write a `kanban.db` at the same time: every write there takes SQLite's
11
11
  // native transaction boundary and retries an optimistic conflict.
12
+ import { current } from './call.mjs';
13
+ import { fenced } from './fence.mjs';
12
14
  import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
13
15
  import { redactValue } from '@volter/teams/redact';
14
16
  import { randomBytes } from 'node:crypto';
@@ -19,7 +21,7 @@ import { DatabaseSync } from 'node:sqlite';
19
21
  import { openZtrackBoard } from './ztrack.mjs';
20
22
  import { isFilesBoard, openFilesBoard } from './files.mjs';
21
23
  import { signalBoardWrite } from './wake.mjs';
22
- import { PUBLICATION_SCHEMA, recordCardPublications } from './published.mjs';
24
+ import { PUBLICATION_SCHEMA, pruneDue, prunePublications, publicationTriggers, recordCardPublications } from './published.mjs';
23
25
 
24
26
  export const DEFAULT_BOARD = 'default';
25
27
 
@@ -146,6 +148,8 @@ export function layOut(db, { fresh }) {
146
148
  for (const [column, type] of Object.entries(OURS_COLUMNS)) if (!have.has(column)) db.exec(`ALTER TABLE supercode_cards ADD COLUMN ${column} ${type}`);
147
149
  const effectColumns = new Set(db.prepare('PRAGMA table_info(supercode_effects)').all().map((c) => c.name));
148
150
  for (const [column, type] of Object.entries(EFFECT_COLUMNS)) if (!effectColumns.has(column)) db.exec(`ALTER TABLE supercode_effects ADD COLUMN ${column} ${type}`);
151
+ // what changed is marked by the store itself, whoever writes it (published.mjs)
152
+ publicationTriggers(db);
149
153
  }
150
154
 
151
155
  /**
@@ -192,6 +196,7 @@ export function openBoard(root, slug = DEFAULT_BOARD, { create = false, backing
192
196
  const fresh = !db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").get();
193
197
  if (fresh && !create) { db.exec('ROLLBACK'); db.close(); throw new Error(`${path} is not a board`); }
194
198
  layOut(db, { fresh });
199
+ fenced(db);
195
200
  db.exec('COMMIT');
196
201
  break;
197
202
  } catch (error) {
@@ -212,11 +217,32 @@ export const tx = (board, fn) => {
212
217
  const caller = /at (?:async )?([\w.$<>]+) /.exec(String(new Error().stack).split('\n')[2] ?? '')?.[1] ?? 'unknown';
213
218
  const started = performance.now();
214
219
  try {
215
- const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actor);return result;});
216
- signalBoardWrite(board.root, actor);
220
+ const result = board.tx(db => {const result=fn(db);recordCardPublications(db,actorOf());return result;});
221
+ signalBoardWrite(board.root, actorOf());
217
222
  return result;
218
223
  } finally { noteStep(`board tx from ${caller}`, performance.now() - started); }
219
224
  };
225
+ /**
226
+ * The board owner's publication work on a wake: each board's cards that a writer outside this process marked changed
227
+ * (Hermes's native writer, a hand-edited files board) are published in a transaction of their own, and the retained
228
+ * history is bounded (prunePublications). A board with no marks is one read. Answers the cards published.
229
+ */
230
+ export function publishMarked(root) {
231
+ let published = 0;
232
+ for (const slug of listBoards(root)) {
233
+ let board;
234
+ try {
235
+ board = openBoard(root, slug);
236
+ // a mark in the store, or a backing edit not yet imported into its tables (a ztrack document): the transaction
237
+ // imports the edit, which marks its cards, and publishes them
238
+ const marked = board.db.prepare('SELECT 1 FROM supercode_publication_dirty LIMIT 1').get() || board.unimported?.();
239
+ if (marked) published += board.tx((db) => recordCardPublications(db, actorOf()));
240
+ if (pruneDue(board.db)) board.tx((db) => prunePublications(db));
241
+ } finally { board?.close(); }
242
+ }
243
+ return published;
244
+ }
245
+
220
246
  // SQLite's own contention (BUSY, LOCKED, and their extended codes) and nothing else: a refusal whose words happen to
221
247
  // say "blocked" or "busy" reaches its caller at once instead of being retried forever.
222
248
  export const contention = (error) => [5, 6].includes(Number(error?.errcode) & 255)
@@ -246,6 +272,7 @@ function sqliteTx(db, fn) {
246
272
  try {
247
273
  db.exec('BEGIN');
248
274
  inTx.add(db);
275
+ fenced(db);
249
276
  const out=fn(db);
250
277
  db.exec('COMMIT');
251
278
  return out;
@@ -295,16 +322,15 @@ export function json(text, fallback = null) {
295
322
  /** Append one event to a card's thread (Hermes's `task_events`); answers its id. */
296
323
  // the session whose command this process runs (its address), set by the CLI: each event it records says who acted
297
324
  // (`by`), so the session is not told of its own act
298
- let actor = null;
299
- export const actorOf = () => actor;
300
- export function setActor(address) { actor = address || null; }
325
+ // (each call's own: call.mjs)
326
+ export const actorOf = () => current().actor;
327
+ export function setActor(address) { current().actor = address || null; }
301
328
  // C4: the principal the caller acts as (`agent:<id>`, `automation:<name>`, `person:<id>`, `session:<address>`): what a
302
329
  // card's `created_by` records, and what a relaunched agent still matches when it acts on the cards it created.
303
- let principal = null;
304
- export const principalOf = () => principal;
305
- export function setPrincipal(ref) { principal = ref || null; }
330
+ export const principalOf = () => current().principal;
331
+ export function setPrincipal(ref) { current().principal = ref || null; }
306
332
  /** Whether a recorded creator is the caller: its principal, its session address, or that session as an actor. */
307
- export const isCaller = (ref) => Boolean(ref) && [principal, actor, actor ? `session:${actor}` : null].includes(ref);
333
+ export const isCaller = (ref) => { const { principal, actor } = current(); return Boolean(ref) && [principal, actor, actor ? `session:${actor}` : null].includes(ref); };
308
334
 
309
335
  export function event(db, taskId, kind, payload = null, runId = null) {
310
336
  // Session observations belong to the run machine. Legacy runs without a machine state retain
@@ -313,11 +339,12 @@ export function event(db, taskId, kind, payload = null, runId = null) {
313
339
  const row = db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(runId);
314
340
  const meta = json(row?.metadata, {}) ?? {};
315
341
  if (meta.supercode?.state) {
316
- appendRunObservation(meta, { kind, payload, by: actor, at: now() });
342
+ appendRunObservation(meta, { kind, payload, by: actorOf(), at: now() });
317
343
  db.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), runId);
318
344
  return 0;
319
345
  }
320
346
  }
347
+ const actor = actorOf();
321
348
  const said = actor && !(payload && 'by' in payload) ? { ...(payload ?? {}), by: actor } : payload;
322
349
  // An event carries what the board observed (a command's stderr, a session's screen), which a verb's text check never
323
350
  // saw: a secret in it is stored as its marker (@volter/teams/redact), never as its value.
@@ -4,10 +4,12 @@
4
4
  // must hold and what happens; this module applies it to the board file and keeps no rule of its own. Its actions are
5
5
  // the engine's primitives (the rows Hermes's tables hold); the I/O an action asks for (stopping a worker, closing a
6
6
  // pane, sending a session a message, cleaning a workspace) is answered as effects the caller runs after commit.
7
+ import { current } from './call.mjs';
8
+ import { notRuntime } from './owner-files.mjs';
7
9
  import { spawnSync, execFile } from 'node:child_process';
8
10
  import { promisify } from 'node:util';
9
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
- import { resolve, relative, isAbsolute } from 'node:path';
11
+ import { existsSync, readdirSync, realpathSync, watch, readFileSync } from 'node:fs';
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
11
13
  import { randomBytes } from 'node:crypto';
12
14
  import { actorOf, appendRunObservation, event as record, json, now } from './store.mjs';
13
15
  import { evaluate, holds, render } from './expr.mjs';
@@ -16,18 +18,111 @@ import { isWorkspacePath } from './workspace.mjs';
16
18
  import { identifyNotice } from './notices.mjs';
17
19
 
18
20
  const loaded = new Map();
21
+ const homes = new Map(); // root → Promise<orchestration>: the home as the IR reads it, held until it changes
19
22
 
20
- /** Read the home's workflow through the IR (and the params its `kanban:` keys override); cached per home. */
21
- export async function loadWorkflow(root, { fresh = false } = {}) {
22
- if (!fresh && loaded.has(root)) return loaded.get(root);
23
- const { read } = await import('../orchestration.mjs');
24
- const orchestration = await read(root);
25
- const entry = workflowEntry(orchestration);
26
- entry.root = root;
27
- loaded.set(root, entry);
23
+ /**
24
+ * The home as the IR reads it, read once and held: the board's owner holds it for its lifetime and reads it again only
25
+ * when a file it is decoded from changes (followHome). One read for every verb and round, not one each.
26
+ */
27
+ export async function readHome(root) {
28
+ if (!homes.has(root)) {
29
+ const reading = import('../orchestration.mjs').then(({ read }) => read(root));
30
+ homes.set(root, reading);
31
+ reading.catch(() => { if (homes.get(root) === reading) homes.delete(root); });
32
+ }
33
+ return homes.get(root);
34
+ }
35
+
36
+ /**
37
+ * Read the home's workflow through the IR (and the params its `kanban:` keys override): the held home's. The call that
38
+ * reads it keeps it as its basis (call.mjs `workflows`), so a reload while it runs changes only the next call's.
39
+ */
40
+ export async function loadWorkflow(root) {
41
+ // a call keeps the workflow it first read (its basis), whatever a later load in it would read
42
+ const pinned = current().workflows.get(root);
43
+ if (pinned) return pinned;
44
+ const orchestration = await readHome(root);
45
+ let entry = loaded.get(root);
46
+ if (entry?.orchestration !== orchestration) {
47
+ entry = workflowEntry(orchestration);
48
+ entry.root = root;
49
+ entry.orchestration = orchestration;
50
+ loaded.set(root, entry);
51
+ }
52
+ current().workflows.set(root, entry);
28
53
  return entry;
29
54
  }
30
55
 
56
+ // What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
57
+ // layer, and which profiles there are. A session store or a run log changing is no change to it.
58
+ const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
59
+
60
+ /**
61
+ * Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
62
+ * file events from the home, never an interval. Installed before the home is first read (the view held is never older
63
+ * than the watch). An event that names no file is taken as a change. A watch that fails is installed again with the
64
+ * view dropped; if it cannot be, `onLost` says so and the view is no longer trusted. Throws when it cannot start.
65
+ * Answers a stop.
66
+ */
67
+ export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}) {
68
+ let watcher = null;
69
+ let stopped = false;
70
+ // A decoded file that is a link (a workflow kept in another checkout) is watched at its real target too, in the
71
+ // target's own folder: an edit there replaces the target, which the home's own tree never reports. The links are read
72
+ // again after every change, so a relinked file is followed to its new target.
73
+ const linked = new Map(); // target folder → watcher
74
+ const followLinks = () => {
75
+ const targets = new Map();
76
+ const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json'];
77
+ const watchIn = (folder, name) => { if (!targets.has(folder)) targets.set(folder, new Set()); targets.get(folder).add(name); };
78
+ let realRoot = root;
79
+ try { realRoot = realpathSync(root); } catch { /* the home's own watch reports it */ }
80
+ let profiles = [];
81
+ try { profiles = readdirSync(join(root, 'profiles')).filter((name) => !name.startsWith('.')).map((name) => join('profiles', name)); } catch { /* no profiles */ }
82
+ // Each decoded file and the folder it sits in, by their real paths: a file that is a link, or one in a folder that
83
+ // is (a linked profile, a linked parent), is watched where it really is, which the home's own tree does not cover.
84
+ for (const base of ['', ...profiles]) {
85
+ let realDir;
86
+ try { realDir = realpathSync(join(root, base)); } catch { continue; }
87
+ if (realDir !== join(realRoot, base)) for (const file of decoded) watchIn(realDir, file);
88
+ for (const file of decoded) {
89
+ let real;
90
+ try { real = realpathSync(join(root, base, file)); } catch { continue; }
91
+ if (real !== join(realRoot, base, file)) watchIn(dirname(real), basename(real));
92
+ }
93
+ }
94
+ // A linked profiles/ registry is watched at its real folder for any entry: a profile added or removed there is a
95
+ // change to which profiles there are, which the home's own tree never reports.
96
+ try { const registry = realpathSync(join(root, 'profiles')); if (registry !== join(realRoot, 'profiles')) watchIn(registry, '*'); } catch { /* no profiles */ }
97
+ for (const [folder, handle] of linked) if (!targets.has(folder)) { handle.close(); linked.delete(folder); }
98
+ for (const [folder, names] of targets) {
99
+ linked.get(folder)?.close();
100
+ try {
101
+ const handle = watch(folder, (_kind, name) => { if (!name || names.has('*') || names.has(String(name))) changed(`${folder}/${name ?? '(unnamed)'}`); });
102
+ handle.on('error', () => { handle.close(); linked.delete(folder); changed(`(the watch on ${folder} failed)`); });
103
+ linked.set(folder, handle);
104
+ } catch (error) { onLost(error); }
105
+ }
106
+ };
107
+ const changed = (name) => { homes.delete(root); onChange(name); if (!stopped) followLinks(); };
108
+ const install = () => {
109
+ watcher = watch(root, { recursive: true }, (_kind, name) => {
110
+ if (name && !DECODED.test(String(name).split(sep).join('/'))) return;
111
+ changed(name ? String(name) : '(a change the event did not name)');
112
+ });
113
+ watcher.on('error', (error) => {
114
+ try { watcher.close(); } catch { /* already unusable */ }
115
+ if (stopped) return;
116
+ changed(`(the watch failed: ${error.message})`);
117
+ try { install(); } catch (again) { homes.delete(root); onLost(again); }
118
+ });
119
+ };
120
+ homes.delete(root);
121
+ install();
122
+ followLinks();
123
+ return () => { stopped = true; try { watcher?.close(); } catch { /* closed */ } for (const handle of linked.values()) handle.close(); };
124
+ }
125
+
31
126
  /** The workflow entry for an orchestration value already read. */
32
127
  export function workflowEntry(orchestration) {
33
128
  const workflow = orchestration?.workflow;
@@ -36,9 +131,9 @@ export function workflowEntry(orchestration) {
36
131
  return { workflow, params: { ...workflow.params, ...kanban }, profiles: orchestration.profiles ?? {} };
37
132
  }
38
133
 
39
- /** The workflow loaded for `root` (loadWorkflow first). */
134
+ /** The workflow loaded for `root` (loadWorkflow first): the running call's basis, else the latest read. */
40
135
  export function workflowFor(root) {
41
- const entry = loaded.get(root);
136
+ const entry = current().workflows.get(root) ?? loaded.get(root);
42
137
  if (!entry) throw new Error(`the board workflow of ${root} is not loaded`);
43
138
  return entry;
44
139
  }
@@ -47,6 +142,7 @@ export function workflowFor(root) {
47
142
  export function useWorkflow(root, entry) {
48
143
  entry.root = root;
49
144
  loaded.set(root, entry);
145
+ current().workflows.set(root, entry);
50
146
  return entry;
51
147
  }
52
148
 
@@ -1037,7 +1133,7 @@ export function runSpec(entry, status, card = null) {
1037
1133
  const rel = relative(entry.root, file);
1038
1134
  if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('prompt file must live in the orchestration home');
1039
1135
  if (!existsSync(file) && spec.prompt) return spec; // embedded prompts of the built-in instance
1040
- return { ...spec, prompt: readFileSync(file, 'utf8') };
1136
+ return { ...spec, prompt: readFileSync(notRuntime(file), 'utf8') };
1041
1137
  }
1042
1138
 
1043
1139
  export function runEvent(db, id, kind, payload) {