@volter/supercode-orchestrator 0.5.228 → 0.5.230

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.
Files changed (80) hide show
  1. package/README.md +18 -2
  2. package/activation.mjs +1 -1
  3. package/adapters/agent-mailbox.mjs +10 -0
  4. package/adapters/api-server.mjs +6 -0
  5. package/agents.mjs +149 -40
  6. package/apply/doors.mjs +3 -1
  7. package/bin/agent-channel.mjs +7 -2
  8. package/bin/connection-runner.mjs +60 -19
  9. package/bin/hermes-worker.mjs +2 -1
  10. package/bin/orchestrator.mjs +3 -3
  11. package/bin/runtime.mjs +3 -2
  12. package/board/agent-current.mjs +82 -0
  13. package/board/agent-declarations.mjs +10 -3
  14. package/board/args.mjs +137 -6
  15. package/board/call.mjs +16 -1
  16. package/board/caller-session.mjs +3 -1
  17. package/board/capacity.mjs +59 -45
  18. package/board/card-state.mjs +14 -5
  19. package/board/ci.mjs +521 -178
  20. package/board/cli.mjs +614 -209
  21. package/board/client.mjs +64 -21
  22. package/board/close-current.mjs +36 -0
  23. package/board/current-records.mjs +44 -0
  24. package/board/custody.mjs +2 -1
  25. package/board/dispatch.mjs +1973 -846
  26. package/board/doors.mjs +2 -2
  27. package/board/engine.mjs +623 -85
  28. package/board/event-stream.mjs +10 -5
  29. package/board/fence.mjs +2 -0
  30. package/board/files.mjs +93 -62
  31. package/board/generation-owner.mjs +188 -53
  32. package/board/git-document.mjs +3 -1
  33. package/board/github-rest.mjs +29 -7
  34. package/board/incidents.mjs +5 -17
  35. package/board/listing.mjs +14 -8
  36. package/board/local-admission.mjs +59 -0
  37. package/board/lock-holder.mjs +18 -6
  38. package/board/machine-fleet.mjs +66 -13
  39. package/board/owner-door-worker.mjs +3 -3
  40. package/board/owner-door.mjs +17 -7
  41. package/board/owner-health.mjs +12 -15
  42. package/board/owner-interface.mjs +6 -0
  43. package/board/publication-reader.mjs +50 -0
  44. package/board/published.mjs +40 -12
  45. package/board/queries.mjs +24 -4
  46. package/board/record-view.mjs +30 -3
  47. package/board/request-mail.mjs +37 -6
  48. package/board/resting-admission.mjs +318 -0
  49. package/board/resting-codec-qualifications.json +33 -0
  50. package/board/resting-protocol.mjs +71 -0
  51. package/board/resting-state-protocol.json +120 -0
  52. package/board/resting-state.mjs +113 -0
  53. package/board/runtime-door.mjs +2 -2
  54. package/board/session-continuity.mjs +127 -0
  55. package/board/session-work.mjs +25 -13
  56. package/board/standalone-declaration.mjs +24 -0
  57. package/board/storage-contract.mjs +129 -7
  58. package/board/store.mjs +73 -11
  59. package/board/tools.mjs +56 -13
  60. package/board/wake.mjs +18 -5
  61. package/board/workflow.mjs +284 -82
  62. package/board/workspace.mjs +86 -9
  63. package/board/ztrack.mjs +344 -91
  64. package/connection.mjs +72 -17
  65. package/gates.mjs +2 -1
  66. package/hand-off.mjs +4 -3
  67. package/hermes-cli.mjs +5 -3
  68. package/hermes-locks.mjs +5 -2
  69. package/hosted.mjs +2 -1
  70. package/index.d.ts +2 -0
  71. package/index.mjs +1 -1
  72. package/kanban-worker.mjs +63 -13
  73. package/loop.mjs +33 -0
  74. package/mcp/hermes-kanban-tools.json +109 -50
  75. package/mcp/server.mjs +7 -1
  76. package/ontology.d.ts +26 -0
  77. package/orchestration.mjs +15 -4
  78. package/package.json +19 -12
  79. package/session-recovery.mjs +2 -1
  80. package/worker-route.mjs +37 -19
@@ -4,7 +4,8 @@ import { watch, existsSync, readFileSync, realpathSync, statSync } from 'node:fs
4
4
  import { dirname, basename, join, resolve } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { once } from 'node:events';
7
- import { listBoards, openBoard, backingPaths } from './store.mjs';
7
+ import { listBoards, backingPaths } from './store.mjs';
8
+ import { openPublicationReader } from './publication-reader.mjs';
8
9
 
9
10
  const identity = root => createHash('sha256').update(realpathSync(root)).digest('hex');
10
11
  const cursorOf = (home, boards) => Buffer.from(JSON.stringify({version:1,home,boards})).toString('base64url');
@@ -24,7 +25,11 @@ export async function* followBoardPublications({root,after,signal}) {
24
25
  const settled=new Map();
25
26
  const statOf=path=>{try{const s=statSync(path);return `${s.size}:${s.mtimeMs}`;}catch{return '-';}};
26
27
  const notify=(name=null)=>{
27
- const named=name===null?[]:stores.filter(path=>basename(path)===name);
28
+ // A store in rollback-journal mode is written in place, and on macOS FSEvents names no change to the store file
29
+ // itself, only its journal coming and going (a board's sqlite store, kept in `delete` journal mode; VO-3761, where
30
+ // every start waited out RESETTLE_MS): a journal's notice is its store's, read against the store's own stamp.
31
+ const store=name!==null&&name.endsWith('-journal')?name.slice(0,-'-journal'.length):name;
32
+ const named=store===null?[]:stores.filter(path=>basename(path)===store);
28
33
  // A store file no pass stamps (the root's kanban.db where only named boards exist) reads '-' until one does.
29
34
  if(named.length&&named.every(path=>statOf(path)===(settled.get(path)??'-')))return;
30
35
  dirty=true;wake?.();
@@ -40,12 +45,12 @@ export async function* followBoardPublications({root,after,signal}) {
40
45
  const register=slugs=>{
41
46
  for(const entry of watchers.values())entry.accept=[];
42
47
  stores=[join(root,'kanban.db'),join(root,'kanban.db-wal')];
43
- directory(root,name=>['kanban.db','kanban.db-wal','kanban'].includes(name));
48
+ directory(root,name=>['kanban.db','kanban.db-wal','kanban.db-journal','kanban'].includes(name));
44
49
  directory(join(root,'kanban'),name=>['boards','ztrack.json','board'].includes(name));
45
50
  directory(join(root,'kanban','boards'),()=>true);
46
51
  for(const slug of slugs){
47
52
  const paths=backingPaths(root,slug);
48
- file(paths.sqlite);file(`${paths.sqlite}-wal`);file(paths.ztrack);stores.push(paths.sqlite,`${paths.sqlite}-wal`);
53
+ file(paths.sqlite);file(`${paths.sqlite}-wal`);file(`${paths.sqlite}-journal`);file(paths.ztrack);stores.push(paths.sqlite,`${paths.sqlite}-wal`);
49
54
  directory(paths.files,name=>name.endsWith('.json'));
50
55
  try {const cfg=JSON.parse(readFileSync(paths.ztrack,'utf8'));const document=resolve(dirname(paths.ztrack),cfg.document);file(document);const own=basename(document);directory(dirname(document),name=>name===`${own}.archive.md`||(name.startsWith(`${own}.archive-`)&&name.endsWith('.md')));}catch{/* other backing */}
51
56
  }
@@ -65,7 +70,7 @@ export async function* followBoardPublications({root,after,signal}) {
65
70
  if(inventory!==null&&current!==inventory)throw resync('Workflow board inventory changed');
66
71
  inventory=current;
67
72
  for(const slug of slugs){
68
- const board=openBoard(root,slug);
73
+ const board=openPublicationReader(root,slug);
69
74
  try {
70
75
  // The follower only reads the board's retained log: what a writer changed, it published in its own transaction,
71
76
  // and what a native writer marked, the board's owner publishes on its wake (published.mjs). Read in one view,
package/board/fence.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  import { renameSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { boardHome } from '@volter/teams/board-owner';
12
+ import { assertRestingRows } from './resting-state.mjs';
12
13
  import { assertGenerationWriter } from './generation.mjs';
13
14
 
14
15
  let mine = null;
@@ -34,6 +35,7 @@ export function takeEpoch(root, boards) {
34
35
  /** At a write transaction's start: in the owner, store its epoch unless a newer one is stored, and exit when it is. */
35
36
  export function fenced(db) {
36
37
  assertGenerationWriter();
38
+ assertRestingRows(db);
37
39
  if (mine === null) return;
38
40
  db.exec(STATE);
39
41
  const r = db.prepare(`INSERT INTO supercode_board_state (key, value) VALUES ('owner_epoch', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value
package/board/files.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { fillReadableIds } from './readable-id.mjs';
2
+ import { assertBacklogQualified, assertPauseRecord, assertRestingRows } from './resting-state.mjs';
2
3
  // The `files` backing of a board (docs/architecture/orchestrator.md §2.9, "Backings"): the same tables as a
3
4
  // Hermes `kanban.db`, kept as a directory of JSON files instead of one SQLite file. `<id>.json` holds everything of
4
5
  // one card (its `tasks` row, its `supercode_cards` row, the links to its parents, its comments, events, runs,
@@ -16,7 +17,7 @@ import { CARD_NUMBERS, unnumberTasks } from './store.mjs';
16
17
  import { createHash } from 'node:crypto';
17
18
  import { join } from 'node:path';
18
19
  import { DatabaseSync } from 'node:sqlite';
19
- import { LIST_REVISION, listRevision } from './listing.mjs';
20
+ import { LIST_CONNECTION, LIST_REVISION, READ_BOUNDARY, listRevision } from './listing.mjs';
20
21
 
21
22
  const STATE = '_board.json';
22
23
 
@@ -61,6 +62,8 @@ class FilesBoard {
61
62
  const self = this;
62
63
  // the connection the engine holds: SQLite's statement API over whatever snapshot is current
63
64
  this.db = {
65
+ get [LIST_CONNECTION]() { return self.mem; },
66
+ [READ_BOUNDARY]: fn => self.read(fn),
64
67
  [LIST_REVISION]: () => self.read(() => listRevision(self.mem)),
65
68
  prepare(sql) {
66
69
  return {
@@ -93,66 +96,90 @@ class FilesBoard {
93
96
  if (this.mem && stamp === this.stamp) return;
94
97
  const marked = this.mem && this.mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_dirty'").get()
95
98
  ? this.mem.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map((r) => r.task_id) : [];
96
- this.mem?.close();
97
99
  this.statements.clear();
98
100
  const mem = new DatabaseSync(':memory:');
99
- this.stateText = existsSync(join(this.dir, STATE)) ? readFileSync(join(this.dir, STATE), 'utf8') : null;
100
- const state = this.stateText === null ? null : JSON.parse(this.stateText);
101
- for (const sql of state?.schema ?? []) mem.exec(sql);
102
- this.init(mem, { fresh: !state?.schema?.length });
103
- // the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
104
- // every card's file by the next write (persist)
105
- this.keptSchema = state?.schema ?? [];
106
- const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
107
- const insert = (table, rows) => {
108
- const have = columns(table);
109
- for (const row of rows) {
110
- const keys = Object.keys(row).filter((k) => have.has(k));
111
- mem.prepare(`INSERT INTO ${quote(table)} (${keys.map(quote).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k]));
101
+ const previousMem = this.mem, previousKeys = this.keys;
102
+ // Parse and qualify a replacement before retiring the committed projection.
103
+ // A malformed or partially written file must not leave closed prepared readers.
104
+ try {
105
+ const stateText = existsSync(join(this.dir, STATE)) ? readFileSync(join(this.dir, STATE), 'utf8') : null;
106
+ const state = stateText === null ? null : JSON.parse(stateText);
107
+ for (const sql of state?.schema ?? []) mem.exec(sql);
108
+ this.init(mem, { fresh: !state?.schema?.length });
109
+ // the schema as the directory keeps it: a layout this load added (a table, a column, the card numbers) is written to
110
+ // every card's file by the next write (persist)
111
+ const keptSchema = state?.schema ?? [];
112
+ const columns = (table) => new Set(mem.prepare(`PRAGMA table_info(${quote(table)})`).all().map((c) => c.name));
113
+ const insert = (table, rows) => {
114
+ const have = columns(table);
115
+ for (const row of rows) {
116
+ const keys = Object.keys(row).filter((k) => have.has(k));
117
+ mem.prepare(`INSERT INTO ${quote(table)} (${keys.map(quote).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k]));
118
+ }
119
+ };
120
+ const previous = this.texts;
121
+ const texts = new Map();
122
+ const cards = [];
123
+ for (const name of readdirSync(this.dir)) {
124
+ if (!name.endsWith('.json') || name.startsWith('_')) continue;
125
+ const text = readFileSync(join(this.dir, name), 'utf8');
126
+ const card = JSON.parse(text);
127
+ texts.set(card.id, text);
128
+ cards.push(card);
112
129
  }
113
- };
114
- const previous = this.texts;
115
- this.texts = new Map();
116
- const cards = [];
117
- for (const name of readdirSync(this.dir)) {
118
- if (!name.endsWith('.json') || name.startsWith('_')) continue;
119
- const text = readFileSync(join(this.dir, name), 'utf8');
120
- const card = JSON.parse(text);
121
- this.texts.set(card.id, text);
122
- cards.push(card);
123
- }
124
- // The id counters first: a number handed out before stays handed out.
125
- for (const { name, seq } of state?.sequence ?? []) {
126
- 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);
127
- }
128
- // Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
129
- // holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
130
- // order they were made.
131
- for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
132
- const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
133
- cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
134
- for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
135
- for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
136
- // A task whose file names no number keeps none: only cards are numbered.
137
- unnumberTasks(mem, new Set(cards.filter((card) => card.tables?.[CARD_NUMBERS]?.length).map((card) => card.id)),
138
- (state?.sequence ?? []).find((row) => row.name === CARD_NUMBERS)?.seq ?? 0);
139
- // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
140
- // those marks: its next write compares each card with its published head once (a named limit: each projection opened
141
- // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
142
- // published and adds only the cards whose files changed since (a hand edit, a card another writer added or removed);
143
- // this process's own writes are already the texts it knows (persist), so they are not marked again.
144
- if (previous && mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_dirty'").get()) {
145
- mem.exec('DELETE FROM supercode_publication_dirty');
146
- const mark = mem.prepare('INSERT INTO supercode_publication_dirty (task_id) VALUES (?)');
147
- const changed = [...new Set([...previous.keys(), ...this.texts.keys()])].filter((id) => previous.get(id) !== this.texts.get(id));
148
- for (const id of new Set([...marked, ...changed])) mark.run(id);
130
+ // The id counters first: a number handed out before stays handed out.
131
+ for (const { name, seq } of state?.sequence ?? []) {
132
+ 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);
133
+ }
134
+ // Inserting a card's row numbers it when it has no number yet (store.mjs layOutNumbers): every number a card file
135
+ // holds loads first, so each card keeps its own and a new one is past them all, and then the cards load in the
136
+ // order they were made.
137
+ for (const card of cards) if (card.tables?.[CARD_NUMBERS]) insert(CARD_NUMBERS, card.tables[CARD_NUMBERS]);
138
+ const madeAt = (card) => Number(card.tables?.tasks?.[0]?.created_at ?? 0);
139
+ cards.sort((a, b) => madeAt(a) - madeAt(b) || (String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0));
140
+ for (const card of cards) for (const [table, rows] of Object.entries(card.tables ?? {})) if (table !== CARD_NUMBERS) insert(table, rows);
141
+ for (const [table, rows] of Object.entries(state?.tables ?? {})) insert(table, rows);
142
+ // A files import can carry execution history as well as card flags. Qualify each
143
+ // backlog against that history before publishing the imported connection to readers.
144
+ assertRestingRows(mem, this.dir);
145
+ for (const row of mem.prepare("SELECT task_id,paused FROM supercode_cards WHERE paused IS NOT NULL AND paused != 'null'").all()) {
146
+ assertPauseRecord(typeof row.paused === 'string' ? JSON.parse(row.paused) : row.paused, { id: row.task_id, source: 'files' });
147
+ }
148
+ for (const row of mem.prepare("SELECT task_id,backlog FROM supercode_cards WHERE backlog IS NOT NULL AND backlog != 'null'").all()) {
149
+ const backlog = typeof row.backlog === 'string' ? JSON.parse(row.backlog) : row.backlog;
150
+ assertBacklogQualified(mem, row.task_id, { backlog, source: 'files' });
151
+ }
152
+ // A task whose file names no number keeps none: only cards are numbered.
153
+ unnumberTasks(mem, new Set(cards.filter((card) => card.tables?.[CARD_NUMBERS]?.length).map((card) => card.id)),
154
+ (state?.sequence ?? []).find((row) => row.name === CARD_NUMBERS)?.seq ?? 0);
155
+ // Loading inserts every card, which marks every card changed (published.mjs). The first load of this projection keeps
156
+ // those marks: its next write compares each card with its published head once (a named limit: each projection opened
157
+ // compares every card once, as each load already reads every card's file). A reload keeps the marks not yet
158
+ // published and adds only the cards whose files changed since (a hand edit, a card another writer added or removed);
159
+ // this process's own writes are already the texts it knows (persist), so they are not marked again.
160
+ if (previous && mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_dirty'").get()) {
161
+ mem.exec('DELETE FROM supercode_publication_dirty');
162
+ const mark = mem.prepare('INSERT INTO supercode_publication_dirty (task_id) VALUES (?)');
163
+ const changed = [...new Set([...previous.keys(), ...texts.keys()])].filter((id) => previous.get(id) !== texts.get(id));
164
+ for (const id of new Set([...marked, ...changed])) mark.run(id);
165
+ }
166
+ // a card whose file is gone has no row to fire its delete trigger: its live head is marked, so it is published removed
167
+ if (mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_heads'").get()) markRemovedHeads(mem);
168
+ this.mem = mem;
169
+ this.watch();
170
+ fillReadableIds(mem);
171
+ this.stateText = stateText;
172
+ this.keptSchema = keptSchema;
173
+ this.texts = texts;
174
+ this.stamp = stamp;
175
+ previousMem?.close();
176
+ } catch (error) {
177
+ this.mem = previousMem;
178
+ this.keys = previousKeys;
179
+ mem.close();
180
+ this.statements.clear();
181
+ throw error;
149
182
  }
150
- // a card whose file is gone has no row to fire its delete trigger: its live head is marked, so it is published removed
151
- if (mem.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'supercode_publication_heads'").get()) markRemovedHeads(mem);
152
- this.mem = mem;
153
- this.watch();
154
- fillReadableIds(mem);
155
- this.stamp = stamp;
156
183
  }
157
184
 
158
185
  /** Each table that belongs to a card, with the column naming the card. */
@@ -242,11 +269,15 @@ class FilesBoard {
242
269
  }
243
270
 
244
271
  read(fn) {
245
- // a held board knows its files until an event says otherwise; one opened per use reads them on each query
246
- const known = this.held && !this.changed && !this.unwatched && this.mem && this.stamp !== null;
247
- // the mark clears only once the files were read: a read that fails leaves it, and the next read tries again
248
- if (!this.inTx && !known) { if (this.stampOf() !== this.stamp) this.load(); if (this.held) this.changed = false; }
249
- return fn();
272
+ if (this.readDepth) return fn();
273
+ this.readDepth = 1;
274
+ try {
275
+ // a held board knows its files until an event says otherwise; one opened per use reads them on each query
276
+ const known = this.held && !this.changed && !this.unwatched && this.mem && this.stamp !== null;
277
+ // the mark clears only once the files were read: a read that fails leaves it, and the next read tries again
278
+ if (!this.inTx && !known) { if (!this.mem || this.stampOf() !== this.stamp) this.load(); if (this.held) this.changed = false; }
279
+ return fn();
280
+ } finally { this.readDepth = 0; }
250
281
  }
251
282
 
252
283
  /** A write outside a transaction commits on its own, as SQLite's autocommit does; VACUUM and PRAGMA only read here. */