@volter/supercode-orchestrator 0.4.88 → 0.4.90
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/engine.mjs +7 -4
- package/board/rows.mjs +82 -0
- package/board/workflow.mjs +16 -10
- package/board/ztrack.mjs +157 -52
- package/package.json +3 -3
package/board/engine.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, rmSync } from 'node:fs';
|
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
13
13
|
import { DatabaseSync } from 'node:sqlite';
|
|
14
14
|
import { claimer, event, json, newTaskId, now, tx } from './store.mjs';
|
|
15
|
-
import { eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
15
|
+
import { refreshCardInputs, eventTo, fire, loadWorkflow, setStatus, settle, startNew, statusOf, runEvent, workflowFor } from './workflow.mjs';
|
|
16
16
|
|
|
17
17
|
export const STATUSES = ['triage', 'todo', 'scheduled', 'ready', 'running', 'blocked', 'review', 'done', 'archived'];
|
|
18
18
|
export const WORKSPACE_KINDS = ['scratch', 'dir', 'worktree'];
|
|
@@ -321,6 +321,7 @@ export function setCaller(fn) { callerRoles = fn; }
|
|
|
321
321
|
|
|
322
322
|
export async function act(board, id, name, { roles = null, payload = {}, extra = {} } = {}) {
|
|
323
323
|
const entry = await loadWorkflow(board.root);
|
|
324
|
+
refreshCardInputs(board, id, { commits: ['complete', 'request_review'].includes(name) });
|
|
324
325
|
const result = tx(board, (db) => {
|
|
325
326
|
roles ??= callerRoles(db, id);
|
|
326
327
|
const r = fire(db, board, entry, id, name, { roles, payload, extra });
|
|
@@ -337,10 +338,12 @@ export async function act(board, id, name, { roles = null, payload = {}, extra =
|
|
|
337
338
|
/** Take card `id` to `status` by the event the workflow says goes there for this caller (`goto`). */
|
|
338
339
|
export async function goto(board, id, status, { roles = null, payload = {} } = {}) {
|
|
339
340
|
const entry = await loadWorkflow(board.root);
|
|
340
|
-
|
|
341
|
+
refreshCardInputs(board, id, { commits: true });
|
|
342
|
+
const { name, refusal, from } = (() => {
|
|
343
|
+
const db=board.db;
|
|
341
344
|
roles ??= callerRoles(db, id);
|
|
342
345
|
return { ...eventTo(db, board, entry, id, status, { roles, payload }), from: statusOf(db, getTask(db, id)) };
|
|
343
|
-
});
|
|
346
|
+
})();
|
|
344
347
|
if (from === status) return { from, to: status, moved: false, event: null };
|
|
345
348
|
if (!name) throw new Error(refusal ?? `${id} is ${from}; no event you may send takes it to ${status}`);
|
|
346
349
|
return { ...(await act(board, id, name, { roles, payload })), event: name };
|
|
@@ -348,7 +351,7 @@ export async function goto(board, id, status, { roles = null, payload = {} } = {
|
|
|
348
351
|
|
|
349
352
|
/** The I/O a committed move asked for. */
|
|
350
353
|
export async function runEffects(board, effects = []) {
|
|
351
|
-
const pending =
|
|
354
|
+
const pending = board.db.prepare('SELECT id, effect FROM supercode_effects ORDER BY id').all();
|
|
352
355
|
for (const e of [...pending.map((p) => ({ ...json(p.effect, {}), durableId: p.id })), ...effects]) {
|
|
353
356
|
if (e.kind === 'cleanup_workspace') cleanupWorkspace(board, e.id);
|
|
354
357
|
else if (e.kind === 'invalidate_descendants') tx(board, (db) => invalidateDescendants(db, board, e.id));
|
package/board/rows.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Normalized runtime rows. Card text remains in the document; this stores only runtime
|
|
2
|
+
// tables, run fields and small indexing metadata. No application locks or snapshot rewrites.
|
|
3
|
+
import { json } from './store.mjs';
|
|
4
|
+
const quote = s => `"${s.replaceAll('"', '""')}"`;
|
|
5
|
+
const RUNTIME = new Set(['claim_lock','claim_expires','worker_pid','last_heartbeat_at','current_run_id','consecutive_failures','last_failure_error','block_recurrences','session_id']);
|
|
6
|
+
const OMIT = new Set(['tasks','supercode_cards','task_links','sqlite_sequence']);
|
|
7
|
+
export class RuntimeRows {
|
|
8
|
+
constructor(db) { this.db=db; this.revision=null; this.tables=new Map(); this.runtime={}; this.clocks={}; this.cachedTasks={}; this.cachedCards={}; }
|
|
9
|
+
normalized() { return Boolean(this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='runtime_rows'").get()); }
|
|
10
|
+
watch(mem) {
|
|
11
|
+
mem.exec('CREATE TEMP TABLE IF NOT EXISTS _runtime_dirty (name TEXT, key TEXT, PRIMARY KEY(name,key))');
|
|
12
|
+
for (const {name,sql} of mem.prepare("SELECT name,sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
13
|
+
const columns=mem.prepare(`PRAGMA table_info(${quote(name)})`).all();
|
|
14
|
+
const keys=columns.filter(c=>c.pk).sort((a,b)=>a.pk-b.pk).map(c=>c.name);
|
|
15
|
+
if (!keys.length) keys.push('rowid');
|
|
16
|
+
this.tables.set(name,{name,sql,keys});
|
|
17
|
+
const key=prefix=>`json_array(${keys.map(k=>`${prefix}.${quote(k)}`).join(',')})`;
|
|
18
|
+
for(const [suffix,verb,prefix] of [['i','INSERT','NEW'],['u','UPDATE','NEW'],['d','DELETE','OLD']])
|
|
19
|
+
mem.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(`_runtime_${name}_${suffix}`)} AFTER ${verb} ON main.${quote(name)} BEGIN INSERT OR IGNORE INTO _runtime_dirty VALUES ('${name}',${key(prefix)}); END`);
|
|
20
|
+
}
|
|
21
|
+
mem.exec('DELETE FROM _runtime_dirty');
|
|
22
|
+
}
|
|
23
|
+
insert(mem,name,row,key=null) {
|
|
24
|
+
if(row===null) {
|
|
25
|
+
const table=this.tables.get(name);if(!table)return;
|
|
26
|
+
mem.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key));return;
|
|
27
|
+
}
|
|
28
|
+
const keys=Object.keys(row);
|
|
29
|
+
mem.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote).join(',')}) VALUES (${keys.map(()=>'?').join(',')})`).run(...keys.map(k=>row[k]??null));
|
|
30
|
+
}
|
|
31
|
+
load(mem,{fresh=false}={}) {
|
|
32
|
+
if(!this.normalized())return false;
|
|
33
|
+
if(fresh) {
|
|
34
|
+
for(const row of this.db.prepare('SELECT name,sql FROM runtime_schema').all()) mem.exec(row.sql.replace('CREATE TABLE ','CREATE TABLE IF NOT EXISTS '));
|
|
35
|
+
this.watch(mem);
|
|
36
|
+
}
|
|
37
|
+
const query=this.revision===null?'SELECT * FROM runtime_rows':'SELECT * FROM runtime_rows WHERE revision>?';
|
|
38
|
+
const rows=this.revision===null?this.db.prepare(query).all():this.db.prepare(query).all(this.revision);
|
|
39
|
+
for(const row of rows) {
|
|
40
|
+
const value=json(row.value,null);
|
|
41
|
+
if(row.name==='@runtime') { if(value)this.runtime[row.key]=value;else delete this.runtime[row.key]; }
|
|
42
|
+
else if(row.name==='@clock') { if(value)this.clocks[row.key]=value;else delete this.clocks[row.key]; }
|
|
43
|
+
else if(row.name==='@task_cache') { if(value)this.cachedTasks[row.key]=value;else delete this.cachedTasks[row.key]; }
|
|
44
|
+
else if(row.name==='@card_cache') { if(value)this.cachedCards[row.key]=value;else delete this.cachedCards[row.key]; }
|
|
45
|
+
else this.insert(mem,row.name,value,row.key);
|
|
46
|
+
}
|
|
47
|
+
this.revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
changes(mem,{all=false}={}) {
|
|
51
|
+
const dirty=all?[...this.tables.values()].flatMap(t=>mem.prepare(`SELECT ${t.keys.map(quote).join(',')} FROM ${quote(t.name)}`).all().map(row=>({name:t.name,key:JSON.stringify(t.keys.map(k=>row[k]))}))):mem.prepare('SELECT * FROM _runtime_dirty').all();
|
|
52
|
+
return dirty.map(({name,key})=>{
|
|
53
|
+
const table=this.tables.get(name);
|
|
54
|
+
const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
|
|
55
|
+
if(name==='tasks')return {name:'@runtime',key:JSON.parse(key)[0],value:row&&Object.fromEntries(Object.entries(row).filter(([k])=>RUNTIME.has(k))),source:{name,key,row}};
|
|
56
|
+
if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
|
|
57
|
+
return {name,key,value:row,source:{name,key,row}};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
write(changes) {
|
|
61
|
+
this.db.exec('CREATE TABLE IF NOT EXISTS runtime_schema(name TEXT PRIMARY KEY,sql TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runtime_rows(name TEXT,key TEXT,value TEXT,revision INTEGER NOT NULL,PRIMARY KEY(name,key)); CREATE INDEX IF NOT EXISTS runtime_rows_revision ON runtime_rows(revision)');
|
|
62
|
+
const revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0)+1;
|
|
63
|
+
const statement=this.db.prepare('INSERT INTO runtime_rows VALUES(?,?,?,?) ON CONFLICT(name,key) DO UPDATE SET value=excluded.value,revision=excluded.revision');
|
|
64
|
+
for(const {name,key,value,source} of changes) {
|
|
65
|
+
if (OMIT.has(name)) continue;
|
|
66
|
+
const encoded=value===null?null:JSON.stringify(value);
|
|
67
|
+
if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(name,key)?.value !== encoded) statement.run(name,key,encoded,revision);
|
|
68
|
+
if(name==='@runtime' || name==='@clock') {
|
|
69
|
+
const cacheName=name==='@runtime'?'@task_cache':'@card_cache';
|
|
70
|
+
const cache=name==='@runtime'?this.cachedTasks:this.cachedCards;
|
|
71
|
+
const encodedCache=source?.row?JSON.stringify(source.row):null;
|
|
72
|
+
if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(cacheName,key)?.value !== encodedCache) statement.run(cacheName,key,encodedCache,revision);
|
|
73
|
+
if(source?.row)cache[key]=source.row;else delete cache[key];
|
|
74
|
+
}
|
|
75
|
+
if(name==='@runtime') { if(value)this.runtime[key]=value;else delete this.runtime[key]; }
|
|
76
|
+
if(name==='@clock') { if(value)this.clocks[key]=value;else delete this.clocks[key]; }
|
|
77
|
+
}
|
|
78
|
+
for(const {name,sql} of this.tables.values())if(!OMIT.has(name))this.db.prepare('INSERT OR IGNORE INTO runtime_schema VALUES(?,?)').run(name,sql);
|
|
79
|
+
this.db.prepare("INSERT INTO state VALUES('rows_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
|
|
80
|
+
this.revision=revision;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/board/workflow.mjs
CHANGED
|
@@ -80,7 +80,7 @@ function pattern(p) {
|
|
|
80
80
|
// the time of the latest commit that names `id` in its message, on any ref of the repository at `path` (a worktree's
|
|
81
81
|
// pushes land on the shared remote-tracking refs), or null (asked only when an expression does)
|
|
82
82
|
const commitTimes = new Map();
|
|
83
|
-
function
|
|
83
|
+
function observeLastCommit(path, id) {
|
|
84
84
|
if (!path) return null;
|
|
85
85
|
const key = `${path}:${id}`;
|
|
86
86
|
const cached = commitTimes.get(key);
|
|
@@ -108,6 +108,18 @@ function lastCommitAt(path, id) {
|
|
|
108
108
|
return value;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function lastCommitAt(path, id) { return path ? commitTimes.get(`${path}:${id}`)?.value ?? null : null; }
|
|
112
|
+
/** Observe external facts before entering an optimistic board mutation. */
|
|
113
|
+
export function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
114
|
+
const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
115
|
+
if (!task)return;
|
|
116
|
+
if (commits) {
|
|
117
|
+
const own=task.workspace_kind!=='scratch'?task.workspace_path:null;
|
|
118
|
+
const parent=!own && task.subtask_of?board.db.prepare('SELECT workspace_path FROM tasks WHERE id=?').get(task.subtask_of)?.workspace_path:null;
|
|
119
|
+
observeLastCommit(own??parent??null,id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
// Mailbox I/O is prefetched by the dispatcher, outside every board write.
|
|
112
124
|
const mailboxAnswers = new Map();
|
|
113
125
|
const execFileAsync = promisify(execFile);
|
|
@@ -124,7 +136,7 @@ export async function refreshRelationAnswers(board) {
|
|
|
124
136
|
const { stdout } = await execFileAsync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', '--replies', id, '--json'], { encoding: 'utf8', timeout: 20_000, maxBuffer: 1024 * 1024 });
|
|
125
137
|
const rows = JSON.parse(stdout);
|
|
126
138
|
if (!Array.isArray(rows)) throw new Error('message door returned no message list');
|
|
127
|
-
mailboxAnswers.set(id, { answer: rows[0]?.replies?.[0]?.id ?? null });
|
|
139
|
+
mailboxAnswers.set(id, { answer: rows[0]?.replies?.[0]?.id ?? null, askedOf: rows[0]?.message?.session ?? null });
|
|
128
140
|
} catch (error) { mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${error.message.slice(0, 200)}` }); }
|
|
129
141
|
}));
|
|
130
142
|
}
|
|
@@ -173,14 +185,7 @@ function timeOf(db, task) {
|
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
/** The session message `id` was filed for (`supercode message show`): who was asked; or null. */
|
|
176
|
-
function askedOf(id) {
|
|
177
|
-
if (!id) return null;
|
|
178
|
-
const r = spawnSync(process.env.SUPERCODE_BIN || 'supercode', ['message', 'show', id, '--json'], { encoding: 'utf8', timeout: 20_000 });
|
|
179
|
-
let rows = null;
|
|
180
|
-
try { rows = JSON.parse(r.stdout); } catch { /* read below */ }
|
|
181
|
-
if (!Array.isArray(rows)) throw new Error(`who ${id} was sent to could not be read (supercode message show): ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim().slice(-200)}`);
|
|
182
|
-
return rows[0]?.message?.session ?? null;
|
|
183
|
-
}
|
|
188
|
+
function askedOf(id) { return mailboxAnswers.get(id)?.askedOf ?? null; }
|
|
184
189
|
|
|
185
190
|
/** The scope an expression is evaluated in: the card (with its relations), the event, params, time, extras. */
|
|
186
191
|
export function scopeOf(db, board, task, { params, event = {}, extra = {} } = {}) {
|
|
@@ -300,6 +305,7 @@ export function transitionsFor(workflow, status, name) {
|
|
|
300
305
|
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
301
306
|
*/
|
|
302
307
|
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
308
|
+
if (db.boardErrors?.has(id)) throw new Error(`${id}: ${db.boardErrors.get(id)}`);
|
|
303
309
|
const { workflow, params } = entry;
|
|
304
310
|
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
305
311
|
const task = cardOf(db, id);
|
package/board/ztrack.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, resolve } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import { RuntimeRows } from './rows.mjs';
|
|
8
8
|
import { DatabaseSync } from 'node:sqlite';
|
|
9
9
|
import { actorOf, event, json, now, setActor } from './store.mjs';
|
|
10
10
|
import { fire, projectStatus, statusOf, workflowFor } from './workflow.mjs';
|
|
@@ -40,16 +40,18 @@ class ZtrackBoard {
|
|
|
40
40
|
this.preset = require('@volter/ztrack/kanban-preset');
|
|
41
41
|
this.runtime = new DatabaseSync(`${marker}.runtime.db`);
|
|
42
42
|
if (!this.runtime.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='state'").get()) this.runtime.exec('CREATE TABLE state (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
43
|
-
this.
|
|
43
|
+
this.errors = new Map(); this.preserve = new Map();
|
|
44
|
+
this.mem = null; this.active = false; this.rows = new RuntimeRows(this.runtime);
|
|
44
45
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
45
46
|
const self = this;
|
|
46
47
|
this.db = {
|
|
48
|
+
boardErrors: self.errors,
|
|
47
49
|
prepare(sql) { return Object.fromEntries(['get', 'all', 'run', 'iterate'].map((method) => [method, (...args) => self.access(() => method === 'iterate' ? self.mem.prepare(sql).all(...args)[Symbol.iterator]() : self.mem.prepare(sql)[method](...args), method !== 'run')])); },
|
|
48
50
|
exec(sql) { return self.access(() => self.mem.exec(sql)); },
|
|
49
51
|
close() { self.close(); },
|
|
50
52
|
};
|
|
51
53
|
if (seed) this.tx(() => {});
|
|
52
|
-
else { this.load(); this.loadedStamp = this.stamp(); }
|
|
54
|
+
else this.readOnly(()=>{ this.load(); this.loadedStamp = this.stamp(); });
|
|
53
55
|
}
|
|
54
56
|
get(key, fallback = null) { return this.computing && this.pendingValues.has(key) ? this.pendingValues.get(key) : json(this.runtime.prepare('SELECT value FROM state WHERE key = ?').get(key)?.value, fallback); }
|
|
55
57
|
put(key, value) { if (this.computing) { this.pendingValues.set(key, value); return; } this.runtime.prepare('INSERT INTO state VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value)); }
|
|
@@ -57,7 +59,9 @@ class ZtrackBoard {
|
|
|
57
59
|
const fileStamp = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return 'missing'; } };
|
|
58
60
|
return [fileStamp(this.path), fileStamp(this.archive), fileStamp(this.compatPath), this.get('version', '')].join('|');
|
|
59
61
|
}
|
|
62
|
+
readOnly(fn) { for (;;) { try { return fn(); } catch(error) { if(!/busy|locked/i.test(error.message))throw error; } } }
|
|
60
63
|
access(fn, reading = false) {
|
|
64
|
+
if (reading && !this.reading) { this.reading=true; try {return this.readOnly(()=>this.access(fn,true));}finally{this.reading=false;} }
|
|
61
65
|
if (this.active || (reading && this.mem && this.loadedStamp === this.stamp())) return fn();
|
|
62
66
|
if (reading) { this.load(); this.loadedStamp = this.stamp(); return fn(); }
|
|
63
67
|
return this.tx(fn);
|
|
@@ -67,13 +71,33 @@ class ZtrackBoard {
|
|
|
67
71
|
recover() {
|
|
68
72
|
const pending = this.get('pending');
|
|
69
73
|
if (!pending) return;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
const conflicts = new Set(), documentConflicts = new Set();
|
|
75
|
+
const observed = pending.files.map(file=>({...file,current:read(file.path)}));
|
|
76
|
+
for (const file of observed) {
|
|
77
|
+
const current=hash(file.current);
|
|
78
|
+
if(current===hash(file.text)||current===file.before)continue;
|
|
79
|
+
const ids=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path).conflicts:this.codec.inspectBoardDocument(file.text,file.path).records.map(r=>r.id);
|
|
80
|
+
for(const id of ids){conflicts.add(nativeId(id));documentConflicts.add(id);}
|
|
81
|
+
}
|
|
82
|
+
for (const file of observed) {
|
|
83
|
+
const merged=file.base!=null?this.codec.mergeBoardDocument(file.base,file.current,file.text,file.path,[...documentConflicts]).text:(hash(file.current)===file.before?file.text:file.current);
|
|
84
|
+
if (hash(read(file.path))!==hash(file.current)) throw new Error('document changed during recovery; retry');
|
|
85
|
+
if (merged!==file.current)atomic(file.path,merged);
|
|
86
|
+
}
|
|
87
|
+
const changes = (pending.changes ?? []).filter(c => !conflicts.has(c.source?.row?.task_id ?? c.source?.row?.id ?? c.key));
|
|
88
|
+
if (pending.changes) { this.rows.write(changes); this.put('export_pending', [...this.get('export_pending', []), ...changes]); }
|
|
89
|
+
if (conflicts.size) {
|
|
90
|
+
const issues = this.get('recovery_conflicts', {});
|
|
91
|
+
for (const id of conflicts) issues[id] = {intent:pending.intent,reason:'A concurrent document edit superseded an unfinished operation on this card.'};
|
|
92
|
+
this.put('recovery_conflicts', issues);
|
|
93
|
+
const old = this.get('snapshot', {});
|
|
94
|
+
for (const id of conflicts) {
|
|
95
|
+
if (old.fingerprints?.[id]) pending.snapshot.fingerprints[id] = old.fingerprints[id];
|
|
96
|
+
else delete pending.snapshot.fingerprints?.[id];
|
|
97
|
+
}
|
|
74
98
|
}
|
|
75
99
|
this.put('snapshot', pending.snapshot);
|
|
76
|
-
if (pending.intent) this.put(`applied:${pending.intent}`,
|
|
100
|
+
if (pending.intent) this.put(`applied:${pending.intent}`, {conflicts:[...conflicts]});
|
|
77
101
|
this.put('version', randomBytes(8).toString('hex'));
|
|
78
102
|
this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
|
|
79
103
|
}
|
|
@@ -84,7 +108,7 @@ class ZtrackBoard {
|
|
|
84
108
|
|
|
85
109
|
transaction(fn) {
|
|
86
110
|
for (;;) {
|
|
87
|
-
try { this.runtime.exec('BEGIN
|
|
111
|
+
try { this.runtime.exec('BEGIN'); }
|
|
88
112
|
catch (error) { if (!/busy|locked/i.test(error.message)) throw error; continue; }
|
|
89
113
|
this.active = true;
|
|
90
114
|
let committed = false;
|
|
@@ -98,48 +122,59 @@ class ZtrackBoard {
|
|
|
98
122
|
if (this.seed) this.seedIntoMemory();
|
|
99
123
|
else this.lift();
|
|
100
124
|
const result = fn(this.db);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
125
|
+
const changes = this.rows.changes(this.mem, { all: !this.rows.normalized() });
|
|
126
|
+
const canonical = (row, omitted) => row && Object.fromEntries(Object.entries(row).filter(([key])=>!omitted.has(key)));
|
|
127
|
+
const cardDirty = this.seed || changes.some(({source})=>{
|
|
128
|
+
if(source.name==='tasks')return JSON.stringify(canonical(source.row,new Set([...RUNTIME,'status'])))!==JSON.stringify(canonical(this.rows.cachedTasks[JSON.parse(source.key)[0]],new Set([...RUNTIME,'status'])));
|
|
129
|
+
if(source.name==='supercode_cards')return JSON.stringify(canonical(source.row,new Set(['since','after_fired','next_run_at'])))!==JSON.stringify(canonical(this.rows.cachedCards[JSON.parse(source.key)[0]],new Set(['since','after_fired','next_run_at'])));
|
|
130
|
+
return source.name==='task_links';
|
|
131
|
+
});
|
|
132
|
+
const documents = cardDirty ? this.documents() : {open:this.text, archived:this.archiveText};
|
|
133
|
+
const files = [{ path: this.path, before: hash(this.text), base: this.text, text: documents.open }, { path: this.archive, before: hash(this.archiveText), base: this.archiveText, text: documents.archived }];
|
|
103
134
|
const snapshot = this.snapshot();
|
|
104
135
|
this.computing = false;
|
|
105
|
-
this.runtime.exec('BEGIN
|
|
136
|
+
this.runtime.exec('BEGIN');
|
|
106
137
|
this.recover();
|
|
107
|
-
if (this.get('version') !== version || files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); continue; }
|
|
138
|
+
if (this.get('version') !== version || files.some((f) => hash(read(f.path)) !== f.before)) { this.runtime.exec('ROLLBACK'); this.invalidate(); continue; }
|
|
108
139
|
for (const [key, value] of this.pendingValues) this.put(key, value);
|
|
109
140
|
// A durable intent precedes filesystem writes; recovery completes exactly this version.
|
|
110
141
|
const intent = randomBytes(16).toString('hex');
|
|
111
|
-
this.put('pending', { intent, files, snapshot });
|
|
142
|
+
this.put('pending', { intent, files, snapshot, changes });
|
|
112
143
|
this.runtime.exec('COMMIT'); committed = true;
|
|
113
144
|
// A concurrent writer may recover this intent and advance the runtime. Re-read
|
|
114
145
|
// its identity before touching documents, retrying from any newer version.
|
|
115
|
-
this.runtime.exec('BEGIN
|
|
146
|
+
this.runtime.exec('BEGIN');
|
|
116
147
|
if (this.get('pending')?.intent !== intent) {
|
|
117
148
|
const applied = this.get(`applied:${intent}`, false);
|
|
118
149
|
this.runtime.exec('ROLLBACK');
|
|
119
|
-
if (applied) { this.loadedStamp = null; this.seed = null; return result; }
|
|
120
|
-
continue;
|
|
150
|
+
if (applied) { this.loadedStamp = null; this.seed = null; if (applied.conflicts?.length) throw new Error(`Concurrent edits superseded this operation on ${applied.conflicts.join(', ')}`); return result; }
|
|
151
|
+
this.invalidate(); continue;
|
|
121
152
|
}
|
|
122
153
|
// Detect an editor racing the journal commit before replacing either document.
|
|
123
154
|
if (files.some((f) => hash(read(f.path)) !== f.before)) {
|
|
124
155
|
this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run(); this.runtime.exec('COMMIT');
|
|
125
|
-
continue;
|
|
156
|
+
this.invalidate(); continue;
|
|
126
157
|
}
|
|
127
158
|
for (const f of files) if (hash(f.text) !== f.before) atomic(f.path, f.text);
|
|
159
|
+
this.rows.write(changes);
|
|
128
160
|
this.put('snapshot', snapshot); this.put(`applied:${intent}`, true); this.runtime.prepare("DELETE FROM state WHERE key = 'pending'").run();
|
|
129
|
-
this.
|
|
161
|
+
this.put('export_pending', [...this.get('export_pending', []), ...changes]);
|
|
130
162
|
this.put('version', randomBytes(8).toString('hex'));
|
|
131
163
|
this.runtime.exec('COMMIT');
|
|
164
|
+
try { this.flushExport(); } catch (error) { if (!/busy|locked|Hermes wrote/i.test(error.message)) throw error; }
|
|
132
165
|
this.loadedStamp = this.stamp();
|
|
133
166
|
this.seed = null;
|
|
134
167
|
return result;
|
|
135
168
|
} catch (error) {
|
|
136
169
|
try { this.runtime.exec('ROLLBACK'); } catch { /* intent already durable */ }
|
|
137
|
-
|
|
170
|
+
this.invalidate();
|
|
171
|
+
if (/busy|locked|Hermes wrote|document changed during recovery/i.test(error.message)) continue;
|
|
138
172
|
if (committed) error.message += ' (the runtime journal retains any unfinished document write)';
|
|
139
173
|
throw error;
|
|
140
174
|
} finally { this.active = false; this.computing = false; this.pendingValues = null; }
|
|
141
175
|
}
|
|
142
176
|
}
|
|
177
|
+
invalidate() { this.mem?.close(); this.mem = null; this.rows = new RuntimeRows(this.runtime); this.loadedStamp = null; }
|
|
143
178
|
seedIntoMemory() {
|
|
144
179
|
const source = this.seed;
|
|
145
180
|
for (const table of this.mem.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) this.mem.exec(`DELETE FROM ${quote(table.name)}`);
|
|
@@ -172,6 +207,7 @@ class ZtrackBoard {
|
|
|
172
207
|
this.mem.prepare('UPDATE task_runs SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), task.current_run_id);
|
|
173
208
|
}
|
|
174
209
|
}
|
|
210
|
+
this.rows.watch(this.mem);
|
|
175
211
|
this.put('hermes_cursor', Number(source.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM task_events').get().id));
|
|
176
212
|
}
|
|
177
213
|
insert(table, row) {
|
|
@@ -181,15 +217,49 @@ class ZtrackBoard {
|
|
|
181
217
|
this.mem.prepare(`INSERT OR REPLACE INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...keys.map((k) => row[k] ?? null));
|
|
182
218
|
}
|
|
183
219
|
load({ observe = false } = {}) {
|
|
184
|
-
|
|
185
|
-
|
|
220
|
+
const text = read(this.path), archiveText = read(this.archive);
|
|
221
|
+
const fresh = !this.mem;
|
|
222
|
+
const changed = fresh || text !== this.text || archiveText !== this.archiveText;
|
|
223
|
+
this.text = text; this.archiveText = archiveText;
|
|
224
|
+
if (fresh) { this.mem = new DatabaseSync(':memory:'); this.init(this.mem, { fresh: true }); this.mem.boardErrors=this.errors; }
|
|
186
225
|
this.saved = this.get('snapshot', { tables: {}, runtime: {}, clocks: {}, fingerprints: {} });
|
|
187
|
-
|
|
226
|
+
const normalized = this.rows.load(this.mem, { fresh });
|
|
227
|
+
if (normalized) { this.saved.runtime = this.rows.runtime; this.saved.clocks = this.rows.clocks; }
|
|
228
|
+
for (const item of Object.values(!normalized && fresh ? this.saved.tables ?? {} : {})) {
|
|
188
229
|
if (item.sql) this.mem.exec(item.sql.replace('CREATE TABLE ', 'CREATE TABLE IF NOT EXISTS '));
|
|
189
230
|
for (const row of item.rows ?? []) this.insert(item.name, row);
|
|
190
231
|
}
|
|
191
|
-
|
|
192
|
-
|
|
232
|
+
if (!fresh && !changed) {
|
|
233
|
+
for (const [id, row] of Object.entries(this.saved.runtime ?? {})) {
|
|
234
|
+
const keys = Object.keys(row);
|
|
235
|
+
if (keys.length) this.mem.prepare(`UPDATE tasks SET ${keys.map(k => `${quote(k)}=?`).join(',')} WHERE id=?`).run(...keys.map(k => row[k]), id);
|
|
236
|
+
}
|
|
237
|
+
for (const [id, row] of Object.entries(this.saved.clocks ?? {})) this.mem.prepare('UPDATE supercode_cards SET since=?,after_fired=?,next_run_at=? WHERE task_id=?').run(row.since??null,row.after_fired??null,row.next_run_at??null,id);
|
|
238
|
+
this.rows.watch(this.mem);
|
|
239
|
+
if (observe) this.documentEvents();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.rows.watch(this.mem);
|
|
243
|
+
this.mem.exec('DELETE FROM tasks; DELETE FROM supercode_cards; DELETE FROM task_links');
|
|
244
|
+
this.errors.clear(); this.preserve.clear();
|
|
245
|
+
const records = [];
|
|
246
|
+
for (const [text,path] of [[this.text,this.path],[this.archiveText,this.archive]]) {
|
|
247
|
+
const result = this.codec.inspectBoardDocument(text,path);
|
|
248
|
+
const kept = new Set(result.errors.map(e=>e.id));
|
|
249
|
+
for(const error of result.errors)this.errors.set(nativeId(error.id),error.reason);
|
|
250
|
+
for(const record of result.records) {
|
|
251
|
+
try { records.push(this.preset.KanbanRootSchema.parse(this.preset.parseKanban([record])).issues[0]); }
|
|
252
|
+
catch(error) { this.errors.set(nativeId(record.id),error.message); kept.add(record.id); }
|
|
253
|
+
}
|
|
254
|
+
this.preserve.set(path,kept);
|
|
255
|
+
}
|
|
256
|
+
const counts=new Map(); for(const card of records)counts.set(nativeId(card.id),(counts.get(nativeId(card.id))??0)+1);
|
|
257
|
+
const parsed = {issues:records.filter(card=>{
|
|
258
|
+
const id=nativeId(card.id); if(counts.get(id)===1)return true;
|
|
259
|
+
this.errors.set(id,'Duplicate card identity across board documents');
|
|
260
|
+
for(const kept of this.preserve.values())kept.add(card.id);
|
|
261
|
+
return false;
|
|
262
|
+
})};
|
|
193
263
|
this.originals = new Map(); this.fileIds = new Map(); this.taskKeys = new Map();
|
|
194
264
|
for (const card of parsed.issues) {
|
|
195
265
|
const id = nativeId(card.id);
|
|
@@ -209,6 +279,22 @@ class ZtrackBoard {
|
|
|
209
279
|
this.loadCard(tid, taskCard, id);
|
|
210
280
|
}
|
|
211
281
|
}
|
|
282
|
+
// Keep the last readable projection only for the affected malformed cards.
|
|
283
|
+
// Their raw document spans remain untouched and their verbs refuse with this reason.
|
|
284
|
+
for (const [id] of this.errors) {
|
|
285
|
+
for (const [tid,row] of Object.entries(this.rows.cachedTasks)) {
|
|
286
|
+
const state=this.rows.cachedCards[tid];
|
|
287
|
+
if (tid!==id && state?.subtask_of!==id)continue;
|
|
288
|
+
this.insert('tasks',row); if(state)this.insert('supercode_cards',state);
|
|
289
|
+
if(tid!==id)this.errors.set(tid,`Parent ${id} has an invalid document edit`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const [id,runtime] of Object.entries(this.saved.runtime??{})) {
|
|
293
|
+
if (!runtime.current_run_id || this.mem.prepare('SELECT 1 FROM tasks WHERE id=?').get(id))continue;
|
|
294
|
+
const task=this.rows.cachedTasks[id], state=this.rows.cachedCards[id];
|
|
295
|
+
if (task) {this.insert('tasks',task);if(state)this.insert('supercode_cards',state);}
|
|
296
|
+
this.errors.set(id,'Document removed a card with a live run; restore it and cancel its run through the workflow.');
|
|
297
|
+
}
|
|
212
298
|
// Resolve local task refs only after all subtask identities are available.
|
|
213
299
|
const taskIds = new Map([...this.taskKeys].map(([id, key]) => [key, id]));
|
|
214
300
|
for (const row of this.mem.prepare('SELECT task_id, blocked_by FROM supercode_cards').all()) {
|
|
@@ -241,6 +327,7 @@ class ZtrackBoard {
|
|
|
241
327
|
if (!Object.keys(previous).length) return;
|
|
242
328
|
const by = this.committer();
|
|
243
329
|
for (const [id, value] of Object.entries(current)) {
|
|
330
|
+
if (this.errors.has(id)) continue;
|
|
244
331
|
const old = previous[id];
|
|
245
332
|
if (old?.hash === value.hash) continue;
|
|
246
333
|
let entry; try { entry = workflowFor(this.root); } catch {}
|
|
@@ -252,21 +339,20 @@ class ZtrackBoard {
|
|
|
252
339
|
event(this.mem, id, kind, { by, source: 'document', status: value.status, assignee: value.assignee, author: by });
|
|
253
340
|
if (!old && value.parent) event(this.mem, value.parent, 'subtask_added', { by, subtask: id, title: this.mem.prepare('SELECT title FROM tasks WHERE id = ?').get(id).title });
|
|
254
341
|
}
|
|
255
|
-
for (const id of Object.keys(previous)) if (!current[id]) {
|
|
256
|
-
if (this.saved.runtime?.[id]?.current_run_id)
|
|
342
|
+
for (const id of Object.keys(previous)) if (!current[id] && !this.errors.has(id)) {
|
|
343
|
+
if (this.saved.runtime?.[id]?.current_run_id) { this.errors.set(id,`Document removed a card with a live run; restore it and cancel it through its workflow first`); continue; }
|
|
257
344
|
event(this.mem, id, 'removed', { by, source: 'document' });
|
|
258
345
|
}
|
|
259
346
|
}
|
|
260
347
|
committer() {
|
|
261
|
-
|
|
262
|
-
return r.status === 0 && r.stdout.trim() ? `git:${r.stdout.trim()}` : 'document-editor';
|
|
348
|
+
return 'document-editor';
|
|
263
349
|
}
|
|
264
350
|
documents() {
|
|
265
351
|
const rows = this.mem.prepare('SELECT * FROM tasks ORDER BY created_at, id').all();
|
|
266
352
|
const states = new Map(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, c]));
|
|
267
353
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
268
354
|
const cards = new Map();
|
|
269
|
-
for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of)) {
|
|
355
|
+
for (const row of rows.filter((r) => !states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
|
|
270
356
|
const c = states.get(row.id) ?? {};
|
|
271
357
|
const parsed = this.preset.KanbanRootSchema.parse(this.preset.parseKanban([{ id: this.fileIds.get(row.id) ?? row.id, title: row.title, status: c.stage ?? row.status, assignee: row.assignee || undefined, body: row.body ?? '' }])).issues[0];
|
|
272
358
|
const metadata = Object.fromEntries(Object.entries(row).filter(([k, v]) => !RUNTIME.has(k) && !CARD_MAIN.has(k) && v != null));
|
|
@@ -284,7 +370,7 @@ class ZtrackBoard {
|
|
|
284
370
|
if (key) { const set = occupied.get(parent) ?? new Set(); set.add(key); occupied.set(parent, set); }
|
|
285
371
|
}
|
|
286
372
|
const renderedKeys = new Set();
|
|
287
|
-
for (const row of rows.filter((r) => states.get(r.id)?.subtask_of)) {
|
|
373
|
+
for (const row of rows.filter((r) => states.get(r.id)?.subtask_of && !this.errors.has(r.id))) {
|
|
288
374
|
const c = states.get(row.id); const parent = cards.get(c.subtask_of);
|
|
289
375
|
if (!parent) throw new Error(`subtask ${row.id} has no arc ${c.subtask_of}`);
|
|
290
376
|
const taken = occupied.get(c.subtask_of) ?? new Set(); occupied.set(c.subtask_of, taken);
|
|
@@ -306,17 +392,12 @@ class ZtrackBoard {
|
|
|
306
392
|
const record = { id: card.id, title: card.title, status: card.status, ...(card.assignee ? { assignee: card.assignee } : {}), body: serialized.body };
|
|
307
393
|
(states.get(id)?.archived || ['done', 'cancelled'].includes(card.status) ? archived : open).push(record);
|
|
308
394
|
}
|
|
309
|
-
return { open: this.codec.renderBoardDocument(this.text, open, this.path), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive) };
|
|
395
|
+
return { open: this.codec.renderBoardDocument(this.text, open, this.path, [...(this.preserve.get(this.path)??[])]), archived: this.codec.renderBoardDocument(this.archiveText, archived, this.archive, [...(this.preserve.get(this.archive)??[])]) };
|
|
310
396
|
}
|
|
311
397
|
snapshot() {
|
|
312
|
-
|
|
313
|
-
for (const table of this.mem.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all()) {
|
|
314
|
-
if (!EPHEMERAL.has(table.name)) tables[table.name] = { ...table, rows: this.mem.prepare(`SELECT * FROM ${quote(table.name)}`).all() };
|
|
315
|
-
}
|
|
316
|
-
const runtime = Object.fromEntries(this.mem.prepare('SELECT * FROM tasks').all().map((t) => [t.id, Object.fromEntries(Object.entries(t).filter(([k]) => RUNTIME.has(k)))]));
|
|
317
|
-
const clocks = Object.fromEntries(this.mem.prepare('SELECT * FROM supercode_cards').all().map((c) => [c.task_id, { since: c.since, after_fired: c.after_fired, next_run_at: c.next_run_at }]));
|
|
318
|
-
return { tables, runtime, clocks, fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
|
|
398
|
+
return { fingerprints: this.fingerprints(), taskIds: Object.fromEntries([...this.taskKeys].map(([id, key]) => [key, id])) };
|
|
319
399
|
}
|
|
400
|
+
|
|
320
401
|
lift() {
|
|
321
402
|
if (!existsSync(this.compatPath)) return;
|
|
322
403
|
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
@@ -331,6 +412,8 @@ class ZtrackBoard {
|
|
|
331
412
|
if (payload.__supercode_export) continue;
|
|
332
413
|
const task = this.mem.prepare('SELECT * FROM tasks WHERE id = ?').get(row.task_id);
|
|
333
414
|
if (!task) continue;
|
|
415
|
+
this.mem.exec('SAVEPOINT hermes_card');
|
|
416
|
+
try {
|
|
334
417
|
const roles = ['manager', 'reviewer', 'platform', 'hermes'];
|
|
335
418
|
setActor(payload.by ?? 'hermes');
|
|
336
419
|
let name = null; let words = { ...payload, reason: payload.reason ?? 'unblocked in Hermes' };
|
|
@@ -347,34 +430,56 @@ class ZtrackBoard {
|
|
|
347
430
|
for (const effect of result.effects) this.mem.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
348
431
|
}
|
|
349
432
|
event(this.mem, task.id, 'hermes_lifted', { by: 'hermes', source_event: row.id, verb: name, kind: row.kind });
|
|
433
|
+
this.mem.exec('RELEASE hermes_card');
|
|
434
|
+
} catch(error) {
|
|
435
|
+
this.mem.exec('ROLLBACK TO hermes_card; RELEASE hermes_card');
|
|
436
|
+
event(this.mem, task.id, 'hermes_refused', {by:'hermes',source_event:row.id,kind:row.kind,reason:error.message});
|
|
437
|
+
}
|
|
350
438
|
}
|
|
351
439
|
} finally { setActor(before); }
|
|
352
440
|
if (rows.length) this.put('hermes_cursor', rows.at(-1).id);
|
|
353
441
|
} finally { compat.close(); }
|
|
354
442
|
}
|
|
355
|
-
|
|
443
|
+
flushExport() {
|
|
444
|
+
const changes = this.get('export_pending', []);
|
|
445
|
+
if (!changes.length) return;
|
|
446
|
+
this.export(changes);
|
|
447
|
+
// Remove only the prefix this export consumed; another writer may append meanwhile.
|
|
448
|
+
this.runtime.exec('BEGIN');
|
|
449
|
+
try {
|
|
450
|
+
const pending = this.get('export_pending', []);
|
|
451
|
+
if (JSON.stringify(pending.slice(0, changes.length)) === JSON.stringify(changes)) this.put('export_pending', pending.slice(changes.length));
|
|
452
|
+
this.runtime.exec('COMMIT');
|
|
453
|
+
} catch (error) { this.runtime.exec('ROLLBACK'); throw error; }
|
|
454
|
+
}
|
|
455
|
+
export(changes) {
|
|
356
456
|
let entry; try { entry = workflowFor(this.root); } catch { return; }
|
|
357
457
|
for (const { id } of this.mem.prepare('SELECT id FROM tasks').all()) projectStatus(this.mem, this.board(), entry, id);
|
|
358
458
|
const compat = new DatabaseSync(this.compatPath);
|
|
359
|
-
compat.exec('PRAGMA busy_timeout = 10000');
|
|
360
459
|
try {
|
|
361
|
-
compat.exec('BEGIN
|
|
460
|
+
compat.exec('BEGIN'); this.init(compat, { fresh: true });
|
|
362
461
|
// Every import is event-based. Existing unconsumed acts must never be erased by an export race.
|
|
363
462
|
const cursor = this.get('hermes_cursor', 0);
|
|
364
463
|
const unseen = compat.prepare('SELECT payload FROM task_events WHERE id > ?').all(cursor).some((r) => !json(r.payload, {})?.__supercode_export);
|
|
365
464
|
if (unseen) throw new Error('Hermes wrote during board export; retained its events for the next read');
|
|
366
|
-
const
|
|
367
|
-
for (const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
465
|
+
const sources = new Map(changes.map(c => [`${c.source.name}:${c.source.key}`, c.source]));
|
|
466
|
+
for (const source of [...sources.values()]) {
|
|
467
|
+
const id = source.name === 'tasks' ? source.row?.id : source.name === 'supercode_cards' ? source.row?.task_id : null;
|
|
468
|
+
if (id) sources.set(`tasks:${JSON.stringify([id])}`, {name:'tasks',key:JSON.stringify([id]),row:this.mem.prepare('SELECT * FROM tasks WHERE id=?').get(id)});
|
|
469
|
+
}
|
|
470
|
+
for (const {name,key,row:source} of sources.values()) {
|
|
471
|
+
if (!compat.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name)) continue;
|
|
472
|
+
const table = this.rows.tables.get(name);
|
|
473
|
+
if (!source) { compat.prepare(`DELETE FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key)); continue; }
|
|
474
|
+
const row = {...source};
|
|
475
|
+
if (name === 'task_events') row.payload = JSON.stringify({...json(row.payload,{}),__supercode_export:true});
|
|
476
|
+
const keys=Object.keys(row);
|
|
477
|
+
const previous=compat.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key));
|
|
478
|
+
if (previous && keys.every(k=>previous[k]===row[k]))continue;
|
|
479
|
+
compat.prepare(`INSERT OR REPLACE INTO ${quote(name)} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]));
|
|
375
480
|
}
|
|
376
481
|
compat.exec('COMMIT');
|
|
377
|
-
|
|
482
|
+
// Exported events carry an origin marker and never advance the Hermes input cursor.
|
|
378
483
|
} catch (error) { try { compat.exec('ROLLBACK'); } catch {} throw error; }
|
|
379
484
|
finally { compat.close(); }
|
|
380
485
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.90",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"yaml": "^2.9.0",
|
|
28
27
|
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
29
|
-
"@volter/ztrack": "^2.4.
|
|
28
|
+
"@volter/ztrack": "^2.4.15",
|
|
29
|
+
"yaml": "^2.9.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@chat-adapter/state-memory": "4.39.0",
|