@volter/supercode-orchestrator 0.4.148 → 0.4.150
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/ci.mjs +147 -56
- package/board/cli.mjs +68 -61
- package/board/dispatch.mjs +129 -58
- package/board/engine.mjs +69 -25
- package/board/store.mjs +6 -3
- package/board/tools.mjs +6 -3
- package/board/workflow.mjs +30 -45
- package/board/workspace.mjs +32 -0
- package/board/ztrack.mjs +802 -542
- package/cron.mjs +8 -2
- package/hermes-locks.mjs +1 -20
- package/loop.mjs +23 -2
- package/mcp/hermes-kanban-tools.json +47 -18
- package/package.json +2 -2
- package/board/rows.mjs +0 -132
package/cron.mjs
CHANGED
|
@@ -750,18 +750,24 @@ function firePrompt(job) {
|
|
|
750
750
|
return job.prompt ?? '';
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
// supercode's normal machine name (session addresses spell a machine this way)
|
|
754
|
+
const machineName = (name) => String(name ?? '').split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
|
|
755
|
+
|
|
753
756
|
function startFire(state, profile, job, now, effects) {
|
|
754
757
|
const fire = claimFire(profile, job, now);
|
|
755
758
|
if (!fire) return;
|
|
756
759
|
effects.push({ type: 'save' }); // the claim is durable before anything runs (at-most-once)
|
|
757
|
-
|
|
760
|
+
// A job that names another machine runs each fire in a session there: the loop launches it on that machine, and the
|
|
761
|
+
// launch's receipt finishes the fire as a host's would.
|
|
762
|
+
const remote = job.machine && state.runtime?.machine && machineName(job.machine) !== state.runtime.machine ? machineName(job.machine) : null;
|
|
763
|
+
if (state.runtime?.hostedJobs || remote) {
|
|
758
764
|
fire.started_at = now;
|
|
759
765
|
fire.status = 'running';
|
|
760
766
|
fire.session_id = null; // a host callback is not a fabricated native worker session
|
|
761
767
|
fire.residue = { execution: 'host_task' };
|
|
762
768
|
state.runtime.hostedFires[`${profile.name}:${fire.id}`] = true;
|
|
763
769
|
effects.push({ type: 'save' });
|
|
764
|
-
effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id });
|
|
770
|
+
effects.push({ type: 'execute_job', profile: profile.name, job_id: job.id, fire_id: fire.id, ...(remote ? { machine: remote } : {}) });
|
|
765
771
|
return;
|
|
766
772
|
}
|
|
767
773
|
// A gate (a script, a monitor source) runs before any model, off the event
|
package/hermes-locks.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeSync } from 'node:fs';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
|
-
import {
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
17
|
|
|
18
18
|
/** Hermes's gateway kind, the `kind` of every record it writes. */
|
|
19
19
|
export const GATEWAY_KIND = 'hermes-gateway';
|
|
@@ -143,25 +143,6 @@ export function holdGatewayLock(home, { python = 'python3' } = {}) {
|
|
|
143
143
|
return holdFileLock(join(resolve(home), 'gateway.lock'), home, { python, what: `${resolve(home)} is served by`, tail: 'one runtime serves a home at a time' });
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
/**
|
|
147
|
-
* Hold the board's one dispatcher lock, `<root>/kanban/.dispatcher.lock` (Hermes's
|
|
148
|
-
* `_acquire_singleton_lock`, which every Hermes gateway takes before it dispatches). Resolves
|
|
149
|
-
* `{ release }`; rejects, naming the holder, when another dispatcher holds it.
|
|
150
|
-
*/
|
|
151
|
-
export function holdDispatcherLock(root, { python = 'python3' } = {}) {
|
|
152
|
-
const path = join(resolve(root), 'kanban', '.dispatcher.lock');
|
|
153
|
-
mkdirSync(join(resolve(root), 'kanban'), { recursive: true });
|
|
154
|
-
return holdFileLock(path, root, { python, what: `the board at ${resolve(root)} is dispatched by`, tail: 'one dispatcher serves a board' });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Hold one board's per-tick lock, `<kanban.db>.dispatch.lock` (Hermes's `_dispatch_tick_lock`), for one tick: a
|
|
159
|
-
* one-shot `dispatch` never ticks a board beside another dispatcher. Rejects, naming the holder, when it is held.
|
|
160
|
-
*/
|
|
161
|
-
export function holdBoardTickLock(dbPath, { python = 'python3' } = {}) {
|
|
162
|
-
return holdFileLock(`${dbPath}.dispatch.lock`, dirname(dbPath), { python, what: `the board ${dbPath} is being ticked by`, tail: 'one tick at a time' });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
146
|
function holdFileLock(path, home, { python, what, tail }) {
|
|
166
147
|
return new Promise((done, fail) => {
|
|
167
148
|
let child;
|
package/loop.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import { performRemoveProfileDir } from './operator.mjs';
|
|
|
23
23
|
import { harnessHomes } from './mcp/verbs.mjs';
|
|
24
24
|
import { checkMonitor, runJobScript } from './gates.mjs';
|
|
25
25
|
import { startBoard } from './board.mjs';
|
|
26
|
+
import { localMachine } from './board/dispatch.mjs';
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
|
|
@@ -54,6 +57,7 @@ export class Orchestrator {
|
|
|
54
57
|
this.hostedTasks = new Set();
|
|
55
58
|
state.runtime ??= {};
|
|
56
59
|
state.runtime.hostedJobs = !!executeJob;
|
|
60
|
+
state.runtime.machine = localMachine(); // a job naming another machine fires there (cron.mjs startFire)
|
|
57
61
|
state.runtime.hostedFires = {};
|
|
58
62
|
this.vault = vault;
|
|
59
63
|
this.client = client;
|
|
@@ -263,12 +267,12 @@ export class Orchestrator {
|
|
|
263
267
|
}
|
|
264
268
|
}
|
|
265
269
|
|
|
266
|
-
executeHostedJob({ profile: name, job_id, fire_id }) {
|
|
270
|
+
executeHostedJob({ profile: name, job_id, fire_id, machine = null }) {
|
|
267
271
|
const profile = this.state.profiles[name];
|
|
268
272
|
const job = structuredClone(profile.jobs[job_id]);
|
|
269
273
|
const fire = structuredClone(profile.fires.find((f) => f.id === fire_id));
|
|
270
274
|
// Host work never blocks the state queue; completion returns as an event.
|
|
271
|
-
const task = Promise.resolve().then(() => this.executeJob({ profile: name, job, fire }))
|
|
275
|
+
const task = Promise.resolve().then(() => machine ? this.launchRemoteFire({ profile, job, fire, machine }) : this.executeJob({ profile: name, job, fire }))
|
|
272
276
|
.then((receipt) => {
|
|
273
277
|
if (!receipt || typeof receipt.output !== 'string' || receipt.accepted !== true)
|
|
274
278
|
throw new Error('The host did not confirm this scheduled task');
|
|
@@ -279,6 +283,23 @@ export class Orchestrator {
|
|
|
279
283
|
this.hostedTasks.add(task);
|
|
280
284
|
}
|
|
281
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A fire of a job pinned to another machine: a session of the profile's worker harness on that machine, in a window
|
|
288
|
+
* there, given the job's prompt (`supercode open --new --on`, keyed by the fire so a retry never opens a second).
|
|
289
|
+
* The launch is the fire's receipt; the session does the job's work and reports through its own doors.
|
|
290
|
+
*/
|
|
291
|
+
async launchRemoteFire({ profile, job, fire, machine }) {
|
|
292
|
+
const harness = profile.worker?.harness;
|
|
293
|
+
const program = { 'claude-code': 'claude', codex: 'codex' }[harness];
|
|
294
|
+
if (!program) throw new Error(`profile ${profile.name}'s worker (${harness ?? 'none'}) cannot run a fire on ${machine}`);
|
|
295
|
+
const args = ['open', '--new', program, '--detach', '--window=true', '--on', machine, '--key', `cron-${fire.id}`, '--input', job.prompt ?? ''];
|
|
296
|
+
if (job.workdir && job.workdir.startsWith('/')) args.push('--cwd', job.workdir);
|
|
297
|
+
if (job.model) args.push('--', '--model', job.model);
|
|
298
|
+
const { stdout } = await promisify(execFile)(process.env.SUPERCODE_BIN || 'supercode', args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
|
|
299
|
+
const pane = /"pane"\s*:\s*"([^"]+)"/.exec(stdout)?.[1] ?? null;
|
|
300
|
+
return { accepted: true, output: `Fire ${fire.id} of ${job.id} launched on ${machine}${pane ? ` in pane ${pane}` : ''}.`, machine, pane };
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
/**
|
|
283
304
|
* A fire's gate, off the queue (a script may run for Hermes's hour): the
|
|
284
305
|
* monitor source first, then the pre-run script unless the monitor already
|
|
@@ -183,17 +183,25 @@
|
|
|
183
183
|
"type": "string",
|
|
184
184
|
"description": "Exact pushed worktree branch head for independent review."
|
|
185
185
|
},
|
|
186
|
-
"branch": {
|
|
187
|
-
"type": "string",
|
|
188
|
-
"description": "The worktree branch whose pushed head is reviewed before merge."
|
|
189
|
-
},
|
|
190
186
|
"pr": {
|
|
191
|
-
"type":
|
|
192
|
-
|
|
187
|
+
"type": [
|
|
188
|
+
"string",
|
|
189
|
+
"array"
|
|
190
|
+
],
|
|
191
|
+
"items": {
|
|
192
|
+
"type": "string"
|
|
193
|
+
},
|
|
194
|
+
"description": "Every opened PR the arc changed, one URL each (its own repository's and any other repository it changed)."
|
|
193
195
|
},
|
|
194
196
|
"pr_head": {
|
|
195
|
-
"type":
|
|
196
|
-
|
|
197
|
+
"type": [
|
|
198
|
+
"string",
|
|
199
|
+
"array"
|
|
200
|
+
],
|
|
201
|
+
"items": {
|
|
202
|
+
"type": "string"
|
|
203
|
+
},
|
|
204
|
+
"description": "The full 40-character pushed head SHA of each PR, in the same order as pr."
|
|
197
205
|
}
|
|
198
206
|
},
|
|
199
207
|
"required": [
|
|
@@ -218,6 +226,13 @@
|
|
|
218
226
|
"board": {
|
|
219
227
|
"type": "string",
|
|
220
228
|
"description": "Kanban board slug to target. When omitted, the call resolves the active board the usual way: HERMES_KANBAN_DB env \u2192 HERMES_KANBAN_BOARD env \u2192 the 'current' symlink under the kanban home \u2192 'default'. Pass an explicit slug only when the caller (e.g. a Telegram routing layer) needs to override the env-pinned active board for this one call."
|
|
229
|
+
},
|
|
230
|
+
"findings": {
|
|
231
|
+
"type": "array",
|
|
232
|
+
"items": {
|
|
233
|
+
"type": "string"
|
|
234
|
+
},
|
|
235
|
+
"description": "Each finding, filed as a task on the arc before the card returns; an arc with no open task is refused."
|
|
221
236
|
}
|
|
222
237
|
},
|
|
223
238
|
"required": [
|
|
@@ -361,10 +376,6 @@
|
|
|
361
376
|
"inputSchema": {
|
|
362
377
|
"type": "object",
|
|
363
378
|
"properties": {
|
|
364
|
-
"review_only": {
|
|
365
|
-
"type": "boolean",
|
|
366
|
-
"description": "Declare a standalone independent review job, where permitted by the home's manager policy. Its verdict is accepted without another review of the review; implementation arcs keep their independent review requirement."
|
|
367
|
-
},
|
|
368
379
|
"title": {
|
|
369
380
|
"type": "string",
|
|
370
381
|
"description": "Short task title (required)."
|
|
@@ -563,16 +574,34 @@
|
|
|
563
574
|
"description": "The pushed worktree branch the reviewer inspected."
|
|
564
575
|
},
|
|
565
576
|
"reviewed_sha": {
|
|
566
|
-
"type":
|
|
567
|
-
|
|
577
|
+
"type": [
|
|
578
|
+
"string",
|
|
579
|
+
"array"
|
|
580
|
+
],
|
|
581
|
+
"items": {
|
|
582
|
+
"type": "string"
|
|
583
|
+
},
|
|
584
|
+
"description": "Every requested PR head this independent reviewer inspected (full SHAs)."
|
|
568
585
|
},
|
|
569
586
|
"pr": {
|
|
570
|
-
"type":
|
|
571
|
-
|
|
587
|
+
"type": [
|
|
588
|
+
"string",
|
|
589
|
+
"array"
|
|
590
|
+
],
|
|
591
|
+
"items": {
|
|
592
|
+
"type": "string"
|
|
593
|
+
},
|
|
594
|
+
"description": "PR URLs, optional when using the recorded review candidate."
|
|
572
595
|
},
|
|
573
596
|
"pr_head": {
|
|
574
|
-
"type":
|
|
575
|
-
|
|
597
|
+
"type": [
|
|
598
|
+
"string",
|
|
599
|
+
"array"
|
|
600
|
+
],
|
|
601
|
+
"items": {
|
|
602
|
+
"type": "string"
|
|
603
|
+
},
|
|
604
|
+
"description": "Full PR head SHAs; reviewers can use reviewed_sha instead."
|
|
576
605
|
}
|
|
577
606
|
},
|
|
578
607
|
"required": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.150",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.41",
|
|
28
|
-
"@volter/ztrack": "^2.4.
|
|
28
|
+
"@volter/ztrack": "^2.4.21",
|
|
29
29
|
"yaml": "^2.9.0",
|
|
30
30
|
"@volter/supercode-teams": "^0.2.145"
|
|
31
31
|
},
|
package/board/rows.mjs
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
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','workspace_path']);
|
|
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.sequences={}; 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)');
|
|
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,columns});
|
|
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==='@sequence') { if(value)this.sequences[row.key]=value;else delete this.sequences[row.key]; }
|
|
44
|
-
else if(row.name==='@task_cache') { if(value)this.cachedTasks[row.key]=value;else delete this.cachedTasks[row.key]; }
|
|
45
|
-
else if(row.name==='@card_cache') { if(value)this.cachedCards[row.key]=value;else delete this.cachedCards[row.key]; }
|
|
46
|
-
else this.insert(mem,row.name,value,row.key);
|
|
47
|
-
}
|
|
48
|
-
if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
|
|
49
|
-
for (const [name, value] of Object.entries(this.sequences)) {
|
|
50
|
-
const sequence = mem.prepare('SELECT seq FROM sqlite_sequence WHERE name=?').get(name);
|
|
51
|
-
if (sequence) mem.prepare('UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name=?').run(value.seq,name);
|
|
52
|
-
else mem.prepare('INSERT INTO sqlite_sequence(name,seq) VALUES(?,?)').run(name,value.seq);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
this.revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0);
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
changes(mem,{all=false}={}) {
|
|
59
|
-
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 DISTINCT name,key FROM _runtime_dirty').all();
|
|
60
|
-
const changes = dirty.map(({name,key})=>{
|
|
61
|
-
const table=this.tables.get(name);
|
|
62
|
-
const row=mem.prepare(`SELECT * FROM ${quote(name)} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).get(...JSON.parse(key))??null;
|
|
63
|
-
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}};
|
|
64
|
-
if(name==='supercode_cards')return {name:'@clock',key:JSON.parse(key)[0],value:row&&{workspace_root:row.workspace_root,since:row.since,after_fired:row.after_fired,next_run_at:row.next_run_at},source:{name,key,row}};
|
|
65
|
-
return {name,key,value:row,source:{name,key,row}};
|
|
66
|
-
}).filter(change=>{
|
|
67
|
-
if(all)return true;
|
|
68
|
-
const {name,key,value,source}=change;
|
|
69
|
-
if(name==='task_links') {
|
|
70
|
-
const [parent,child]=JSON.parse(key);
|
|
71
|
-
const before=json(this.cachedCards[child]?.blocked_by,[]).some(b=>b.kind==='card'&&b.id===parent);
|
|
72
|
-
return before!==Boolean(source.row);
|
|
73
|
-
}
|
|
74
|
-
const before=this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(name,key)?.value??null;
|
|
75
|
-
if(before!==(value===null?null:JSON.stringify(value)))return true;
|
|
76
|
-
if(name==='@runtime') {
|
|
77
|
-
const fields=row=>row&&Object.fromEntries(Object.entries(row).filter(([k])=>k!=='status'));
|
|
78
|
-
return JSON.stringify(fields(source.row))!==JSON.stringify(fields(this.cachedTasks[key]??null));
|
|
79
|
-
}
|
|
80
|
-
if(name==='@clock')return JSON.stringify(source.row)!==JSON.stringify(this.cachedCards[key]??null);
|
|
81
|
-
return false;
|
|
82
|
-
});
|
|
83
|
-
if (mem.prepare("SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'").get()) {
|
|
84
|
-
for (const row of mem.prepare('SELECT name,seq FROM sqlite_sequence').all()) {
|
|
85
|
-
if (Number(this.sequences[row.name]?.seq) !== Number(row.seq)) changes.push({name:'@sequence',key:row.name,value:row,source:{name:'sqlite_sequence',key:JSON.stringify([row.name]),row}});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return changes;
|
|
89
|
-
}
|
|
90
|
-
mirror(changes, revision) {
|
|
91
|
-
const previous=Number(this.db.prepare("SELECT value FROM state WHERE key='read_revision'").get()?.value??-1);
|
|
92
|
-
for(const name of ['tasks','supercode_cards']) {
|
|
93
|
-
const table=this.tables.get(name); if(!table)return;
|
|
94
|
-
this.db.exec(table.sql.replace(/^CREATE TABLE (?:IF NOT EXISTS )?(?:"[^"]+"|\w+)/,`CREATE TABLE IF NOT EXISTS ${quote('read_'+name)}`));
|
|
95
|
-
const columns=this.db.prepare(`PRAGMA table_info(${quote('read_'+name)})`).all();
|
|
96
|
-
for(const c of table.columns) if(!columns.some(old=>old.name===c.name))this.db.exec(`ALTER TABLE ${quote('read_'+name)} ADD COLUMN ${quote(c.name)} ${c.type}${c.dflt_value==null?'':` DEFAULT ${c.dflt_value}`}`);
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
const rows=previous===revision-1?changes.filter(c=>['tasks','supercode_cards'].includes(c.source.name)).map(c=>c.source):this.db.prepare("SELECT name,key,value FROM runtime_rows WHERE name IN ('@task_cache','@card_cache')").all().map(r=>({name:r.name==='@task_cache'?'tasks':'supercode_cards',key:JSON.stringify([r.key]),row:json(r.value,null)}));
|
|
100
|
-
for(const {name,key,row} of rows) {
|
|
101
|
-
const table=this.tables.get(name), target=quote('read_'+name);
|
|
102
|
-
if(!row) {this.db.prepare(`DELETE FROM ${target} WHERE ${table.keys.map(k=>`${quote(k)} IS ?`).join(' AND ')}`).run(...JSON.parse(key));continue;}
|
|
103
|
-
const keys=Object.keys(row);
|
|
104
|
-
this.db.prepare(`INSERT OR REPLACE INTO ${target} (${keys.map(quote)}) VALUES (${keys.map(()=>'?')})`).run(...keys.map(k=>row[k]??null));
|
|
105
|
-
}
|
|
106
|
-
this.db.prepare("INSERT INTO state VALUES('read_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
|
|
107
|
-
}
|
|
108
|
-
write(changes) {
|
|
109
|
-
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); CREATE INDEX IF NOT EXISTS runtime_rows_task ON runtime_rows(name,json_extract(value,'$.task_id'),json_extract(value,'$.id')); CREATE INDEX IF NOT EXISTS runtime_rows_id ON runtime_rows(name,json_extract(value,'$.id'))`);
|
|
110
|
-
const revision=Number(this.db.prepare("SELECT value FROM state WHERE key='rows_revision'").get()?.value??0)+1;
|
|
111
|
-
const statement=this.db.prepare('INSERT INTO runtime_rows VALUES(?,?,?,?) ON CONFLICT(name,key) DO UPDATE SET value=excluded.value,revision=excluded.revision');
|
|
112
|
-
for(const {name,key,value,source} of changes) {
|
|
113
|
-
if (OMIT.has(name)) continue;
|
|
114
|
-
const encoded=value===null?null:JSON.stringify(value);
|
|
115
|
-
if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(name,key)?.value !== encoded) statement.run(name,key,encoded,revision);
|
|
116
|
-
if(name==='@runtime' || name==='@clock') {
|
|
117
|
-
const cacheName=name==='@runtime'?'@task_cache':'@card_cache';
|
|
118
|
-
const cache=name==='@runtime'?this.cachedTasks:this.cachedCards;
|
|
119
|
-
const encodedCache=source?.row?JSON.stringify(source.row):null;
|
|
120
|
-
if (this.db.prepare('SELECT value FROM runtime_rows WHERE name=? AND key=?').get(cacheName,key)?.value !== encodedCache) statement.run(cacheName,key,encodedCache,revision);
|
|
121
|
-
if(source?.row)cache[key]=source.row;else delete cache[key];
|
|
122
|
-
}
|
|
123
|
-
if(name==='@runtime') { if(value)this.runtime[key]=value;else delete this.runtime[key]; }
|
|
124
|
-
if(name==='@clock') { if(value)this.clocks[key]=value;else delete this.clocks[key]; }
|
|
125
|
-
if(name==='@sequence') { if(value)this.sequences[key]=value;else delete this.sequences[key]; }
|
|
126
|
-
}
|
|
127
|
-
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);
|
|
128
|
-
this.db.prepare("INSERT INTO state VALUES('rows_revision',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(String(revision));
|
|
129
|
-
this.mirror(changes,revision);
|
|
130
|
-
this.revision=revision;
|
|
131
|
-
}
|
|
132
|
-
}
|