@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/agent-door.mjs +75 -0
- package/agents.mjs +42 -23
- package/bin/orchestrator.mjs +1 -2
- package/board/agent-declarations.mjs +3 -1
- package/board/args.mjs +36 -0
- package/board/call.mjs +45 -0
- package/board/caller-session.mjs +25 -0
- package/board/cli.mjs +258 -121
- package/board/client.mjs +85 -0
- package/board/dispatch.mjs +70 -76
- package/board/doors.mjs +60 -0
- package/board/engine.mjs +9 -5
- package/board/event-stream.mjs +7 -7
- package/board/fence.mjs +42 -0
- package/board/files.mjs +23 -3
- package/board/git-document.mjs +2 -1
- package/board/lock-holder.mjs +49 -0
- package/board/owner-files.mjs +19 -0
- package/board/published.mjs +106 -41
- package/board/store.mjs +38 -11
- package/board/workflow.mjs +109 -13
- package/board/ztrack.mjs +24 -14
- package/hand-off.mjs +2 -2
- package/index.d.ts +1 -1
- package/kanban-worker.mjs +11 -16
- package/loop.mjs +3 -2
- package/mcp/server.mjs +12 -9
- package/package.json +2 -2
- package/bin/agent-door.mjs +0 -53
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);
|
package/board/git-document.mjs
CHANGED
|
@@ -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)
|
|
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
|
+
}
|
package/board/published.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
// The card writer commits the
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
82
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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,
|
|
216
|
-
signalBoardWrite(board.root,
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
export
|
|
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:
|
|
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.
|
package/board/workflow.mjs
CHANGED
|
@@ -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,
|
|
10
|
-
import {
|
|
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
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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) {
|