@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.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/published.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
|
|
|
6
6
|
// supercode_publication_dirty, whoever writes (this process, Hermes's native writer, a document import). A transaction
|
|
7
7
|
// publishes the marked cards and clears their marks; one that marked nothing reads one row and publishes nothing.
|
|
8
8
|
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
-
import { executionOf, readingContext } from './session-work.mjs';
|
|
9
|
+
import { executionOf, presenceFresh, readingContext } from './session-work.mjs';
|
|
10
10
|
import { cardState } from './card-state.mjs';
|
|
11
11
|
import { workflowFor } from './workflow.mjs';
|
|
12
12
|
import { cardAgentScopeTransition } from '../agents.mjs';
|
|
@@ -40,8 +40,8 @@ CREATE INDEX IF NOT EXISTS supercode_publication_heads_sequence ON supercode_pub
|
|
|
40
40
|
// is where a prune under way resumes, null when none is (prunePublications).
|
|
41
41
|
export const PUBLICATION_COLUMNS = { supercode_publications: { snapshot_bytes: 'INTEGER' }, supercode_publication_heads: { fingerprint: 'TEXT' }, supercode_publication_log: { prune_from: 'INTEGER' } };
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
const SNAPSHOT_SHAPE =
|
|
43
|
+
// Snapshot shape: 1 names the card as a person does; 5 adds attachments; 6 adds attributed backlog and bounded pauses.
|
|
44
|
+
const SNAPSHOT_SHAPE = 6;
|
|
45
45
|
const SNAPSHOT_RELEASE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
46
46
|
const REVIEW_KIND_LIST = ['review_requested', 'approved', 'review_approved', 'changes_requested', 'escalated'];
|
|
47
47
|
export const AGENT_KIND_LIST = ['agent_declared', 'agent_assignment_ended', 'agent_assignment_reassigned', 'agent_assignment_adopted'];
|
|
@@ -115,7 +115,7 @@ const RECLAIM_BYTES = 256 * 1024 * 1024;
|
|
|
115
115
|
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
116
116
|
const parse = (value,fallback=null) => {try{return JSON.parse(value)??fallback;}catch{return fallback;}};
|
|
117
117
|
const at = value => value == null ? null : new Date(Number(value)*1000).toISOString();
|
|
118
|
-
const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',resumed:'resumed',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
|
|
118
|
+
const kinds = {started:'started',created:'created',completed:'completed',cancelled:'cancelled',reopened:'reopened',blocked:'blocked',unblocked:'unblocked',paused:'paused',pause_extended:'pause_extended',resumed:'resumed',backlogged:'backlogged',planned:'planned',assigned:'assigned',linked:'linked',unlinked:'unlinked',commented:'commented',specified:'specified',review_requested:'review_requested',changes_requested:'changes_requested'};
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* The ontology's Task status (contracts/ontology.md: todo, running, review, done, cancelled) of a card in a board lane.
|
|
@@ -229,16 +229,41 @@ const fingerprintReads=db=>({
|
|
|
229
229
|
// a run's metadata changes in place (its session's observations), so every run's snapshot columns are read whole
|
|
230
230
|
runs:db.prepare('SELECT id,profile,step_key,status,started_at,ended_at,outcome,summary,metadata,error FROM task_runs WHERE task_id=? ORDER BY id'),
|
|
231
231
|
});
|
|
232
|
+
/**
|
|
233
|
+
* A run as its card's snapshot reads it: its session's presence reading with whether it is current in place of when it
|
|
234
|
+
* was observed. The dispatcher renews an unchanged reading every minute (dispatch.mjs supervise); the snapshot carries
|
|
235
|
+
* what the reading says and whether it is current (publishedExecution), never the time it was renewed, so a renewal that
|
|
236
|
+
* changes neither is no change of the card. On the MacBook (2026-10-30) the renewal alone republished each running card
|
|
237
|
+
* whole every minute (1.95 MB for t_2a2ef475).
|
|
238
|
+
*/
|
|
239
|
+
function presenceRead(run,now){
|
|
240
|
+
if(typeof run.metadata!=='string'||!run.metadata.includes('"presence_observation"'))return run;
|
|
241
|
+
const metadata=parse(run.metadata,null),reading=metadata?.supercode?.presence_observation;
|
|
242
|
+
if(!reading||typeof reading!=='object')return run;
|
|
243
|
+
const {observed_at,...said}=reading;
|
|
244
|
+
return {...run,metadata:JSON.stringify({...metadata,supercode:{...metadata.supercode,presence_observation:{...said,current:presenceFresh(reading,now)}}})};
|
|
245
|
+
}
|
|
232
246
|
/**
|
|
233
247
|
* A card's fingerprint: what its snapshot is built from, read cheaply (no comment text, review or agent event payloads,
|
|
234
248
|
* and no snapshot built), with the snapshot's shape, its board's key and the options it is built with. Equal
|
|
235
|
-
* fingerprints build equal snapshots, but for a
|
|
236
|
-
*
|
|
249
|
+
* fingerprints build equal snapshots, but for a bounded wait (a heartbeat's `until`) passing, which a snapshot's readers
|
|
250
|
+
* apply themselves.
|
|
237
251
|
*/
|
|
238
252
|
function fingerprintOf(reads,rows,row,key,options){
|
|
239
253
|
const task={};for(const [column,value] of Object.entries(row))if(!UNREAD_TASK_COLUMNS.has(column))task[column]=value;
|
|
240
254
|
return hash(JSON.stringify([SNAPSHOT_SHAPE,SNAPSHOT_RELEASE,key,Boolean(options.nativeHolds),task,rows.own(row.id)??null,rows.number(row.id),rows.parents(row.id),rows.children(row.id),
|
|
241
|
-
reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id)]));
|
|
255
|
+
reads.facts.get(row.id)??null,reads.scope.get(row.id)??null,reads.runs.all(row.id).map(run=>presenceRead(run,options.now))]));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A card's session reading as its snapshot publishes it: what the board last read of the session (its state, turn,
|
|
259
|
+
* machine and reason), without the time of that reading or when it stops being current. Those move with every renewal;
|
|
260
|
+
* the board's own doors (`workflow list`, `workflow show`: listing.mjs) read them live, and no reader of a published
|
|
261
|
+
* card reads them (the connector's publishableCard, the Workplace's cards, `workflow events`).
|
|
262
|
+
*/
|
|
263
|
+
export function publishedExecution(execution){
|
|
264
|
+
if(!execution||typeof execution!=='object')return execution;
|
|
265
|
+
const {observed_at,fresh_until,...said}=execution;
|
|
266
|
+
return said;
|
|
242
267
|
}
|
|
243
268
|
/** A card's snapshot; `key` is its board's key (store.mjs boardKey), with which `ref` names the card as a person does
|
|
244
269
|
* (`VO-856`, store.mjs cardRef), null for a card with no number. */
|
|
@@ -269,6 +294,7 @@ function supersede(db,sequence){
|
|
|
269
294
|
}
|
|
270
295
|
|
|
271
296
|
export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
|
|
297
|
+
const now=options.now??Date.now()/1000;
|
|
272
298
|
const own=rows.own(row.id)??{},number=rows.number(row.id);
|
|
273
299
|
const comments=rows.comments(row.id).map(comment=>({...comment,created_at:at(comment.created_at)}));
|
|
274
300
|
const verdicts={review_requested:'requested',approved:'approved',review_approved:'approved',changes_requested:'changes_requested',escalated:'escalated'};
|
|
@@ -278,16 +304,16 @@ export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
|
|
|
278
304
|
const native=parse(run.metadata,{})?.supercode??null;
|
|
279
305
|
const session=native?{machine:native.machine,harness:native.harness,session_id:native.session_id,address:native.address,pane:native.pane,surface:native.surface,state:native.state,closed_at:native.closed_at,detached_at:native.detached_at??null,detached_to:native.detached_to??null,agent:native.agent_id??null,counted:native.counted??null,profile_differences:native.agent_profile_mismatch?.differences??null}:null;
|
|
280
306
|
const metadata=parse(run.metadata,{});delete metadata.supercode;
|
|
281
|
-
return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:executionOf(run),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
|
|
307
|
+
return {id:String(run.id),profile:run.profile,step:run.step_key,status:run.status,started_at:at(run.started_at),ended_at:at(run.ended_at),outcome:run.outcome,error:run.error,execution:publishedExecution(executionOf(run,{now})),session,handoff:run.summary||Object.keys(metadata).length?{summary:run.summary,metadata}:null};
|
|
282
308
|
});
|
|
283
309
|
const lane=own.stage??row.status;
|
|
284
310
|
const archived=Boolean(own.archived || lane==='archived');
|
|
285
311
|
// a subtask, a row under its card that no person names, is published with no readable id, as the board shows it
|
|
286
|
-
return {id:row.id,readable_id:own.subtask_of?null:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,blocked_by:own.blocked_by},options).status,lane,
|
|
287
|
-
execution:executionOf(runs.find(run=>run.id===row.current_run_id),readingContext(db,row.id,row.current_run_id)),
|
|
312
|
+
return {id:row.id,readable_id:own.subtask_of?null:row.readable_id??null,number,ref:key&&number?`${key}-${number}`:null,title:row.title,body:row.body??'',assignee:row.assignee,status:taskStatusOf(lane,row.completed_at),board_status:cardState({...row,stage:lane,paused:own.paused,backlog:own.backlog,blocked_by:own.blocked_by},options).status,lane,
|
|
313
|
+
execution:publishedExecution(executionOf(runs.find(run=>run.id===row.current_run_id),{...readingContext(db,row.id,row.current_run_id),now})),
|
|
288
314
|
priority:row.priority,created_by:row.created_by,created_at:at(row.created_at),started_at:at(row.started_at),completed_at:at(row.completed_at),result:row.result,skills:parse(row.skills,[]),model:row.model_override,provider:row.provider_override,
|
|
289
315
|
tags:parse(own.tags,null)??(row.tenant?[row.tenant]:[]),requested_by_unverified:parse(own.requested_by_unverified),machine:own.machine??null,subtask_of:own.subtask_of??null,archived,archive_reason:archived?rows.archiveReason(row.id):null,
|
|
290
|
-
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),detached:parse(own.detached),every:own.every??null,
|
|
316
|
+
blocked_by:parse(own.blocked_by,[]),paused:parse(own.paused),backlog:parse(own.backlog),detached:parse(own.detached),every:own.every??null,
|
|
291
317
|
workspace:{kind:row.workspace_kind,path:row.workspace_path,branch:row.branch_name,root:own.workspace_root,anchor:own.workspace_anchor},
|
|
292
318
|
parents:rows.parents(row.id),
|
|
293
319
|
agents:rows.agents(row.id),reviews,children:rows.children(row.id),comments,attempts,
|
|
@@ -303,7 +329,9 @@ export function cardSnapshot(db,row,rows=perCard(db),key=null,options={}) {
|
|
|
303
329
|
* compared again. The marks are cleared with it. Retention is the owner's (prunePublications), never a transaction's.
|
|
304
330
|
*/
|
|
305
331
|
export function recordCardPublications(db,actor,root,key) {
|
|
306
|
-
|
|
332
|
+
// Fingerprint and snapshot must agree at the freshness boundary: caching a current fingerprint beside an expired
|
|
333
|
+
// snapshot would also skip the next unchanged renewal, leaving the card unknown after its session was read again.
|
|
334
|
+
const options={now:Date.now()/1000,nativeHolds:Boolean(workflowFor(root,{optional:true})?.workflow.flags?.paused)};
|
|
307
335
|
const marked=db.prepare('SELECT DISTINCT task_id FROM supercode_publication_dirty').all().map(row=>row.task_id);
|
|
308
336
|
if(!marked.length)return 0;
|
|
309
337
|
db.prepare('INSERT INTO supercode_publication_log(id,pruned_through) SELECT ?,0 WHERE NOT EXISTS (SELECT 1 FROM supercode_publication_log)').run(`log_${randomUUID()}`);
|
package/board/queries.mjs
CHANGED
|
@@ -2,10 +2,30 @@
|
|
|
2
2
|
import { setImmediate as yieldToOwner } from 'node:timers/promises';
|
|
3
3
|
|
|
4
4
|
/** One reverse index seek per kind, then one payload read. ORDER BY id over an IN-kind filter can instead choose
|
|
5
|
-
* the task/id index and read unrelated event payloads to reject their kinds.
|
|
6
|
-
export function
|
|
7
|
-
const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id
|
|
8
|
-
return
|
|
5
|
+
* the task/id index and read unrelated event payloads to reject their kinds. SQL fragments are code constants. */
|
|
6
|
+
export function latestEventIdSql(task, kinds, predicate = '1') {
|
|
7
|
+
const seeks = kinds.map(kind => `SELECT (SELECT id FROM task_events WHERE task_id=${task} AND kind='${kind}' AND (${predicate}) ORDER BY id DESC LIMIT 1) AS id`);
|
|
8
|
+
return `(SELECT MAX(id) FROM (${seeks.join(' UNION ALL ')}))`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function latestEvent(db, task, kinds, columns = 'payload', predicate = '1', args = []) {
|
|
12
|
+
return db.prepare(`SELECT ${columns} FROM task_events WHERE id=${latestEventIdSql('?', kinds, predicate)}`).get(...kinds.flatMap(() => [task, ...args]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The latest dispatch wait for task `t` and card `c`, after its stage entry and latest run. Stage-entry and run event
|
|
16
|
+
* ids supply ordering when their timestamps and an older wait fall in the same second. Each history read is indexed. */
|
|
17
|
+
export const DISPATCH_WAIT_SQL = `SELECT CASE WHEN json_valid(e.payload) THEN json_object('reason',json_extract(e.payload,'$.reason'),'at',e.created_at) END
|
|
18
|
+
FROM task_events e WHERE t.current_run_id IS NULL AND COALESCE(c.stage,t.status) NOT IN ('done','archived','cancelled')
|
|
19
|
+
AND e.id=(SELECT id FROM task_events WHERE task_id=t.id AND kind='respawn_guarded' ORDER BY id DESC LIMIT 1)
|
|
20
|
+
AND e.created_at>=COALESCE(c.since,0)
|
|
21
|
+
AND e.id>COALESCE((SELECT id FROM task_events WHERE task_id=t.id AND kind='stage_entered' ORDER BY id DESC LIMIT 1),0)
|
|
22
|
+
AND e.created_at>=COALESCE((SELECT COALESCE(ended_at,started_at) FROM task_runs WHERE task_id=t.id ORDER BY id DESC LIMIT 1),0)
|
|
23
|
+
AND e.id>COALESCE((SELECT id FROM task_events WHERE run_id=(SELECT id FROM task_runs WHERE task_id=t.id ORDER BY id DESC LIMIT 1) ORDER BY id LIMIT 1),0)`;
|
|
24
|
+
|
|
25
|
+
/** The dispatcher's retained reason for a card whose next run cannot start, or null. */
|
|
26
|
+
export function dispatchWait(db, taskId) {
|
|
27
|
+
const row = db.prepare(`SELECT (${DISPATCH_WAIT_SQL}) AS wait FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?`).get(taskId);
|
|
28
|
+
return JSON.parse(row?.wait ?? 'null');
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
/** Read run history in finite, indexed pages, yielding before each page. The candidate page precedes any JSON
|
package/board/record-view.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Resident local consumer of the daemon's committed record rows. Disconnects
|
|
2
2
|
// preserve the last applied cursor, but held data is stale until caught up.
|
|
3
3
|
import { jsonObjectReader } from '@volter/teams/lines';
|
|
4
|
+
import { SECTION_PARTS_CODEC, eventSection, sectionParts, hydrateSectionParts } from '@volter/supercode-teams/record-section-parts';
|
|
4
5
|
|
|
5
6
|
export class RecordView {
|
|
6
7
|
constructor(open, { progressMs = 10_000 } = {}) {
|
|
7
8
|
this.open = open; this.records = {}; this.positions = new Map();
|
|
8
9
|
this.cursor = null; this.stream = null; this.waiters = new Set();
|
|
9
10
|
this.pending = [];
|
|
11
|
+
this.sectionRows = new Map(); this.dirtySections = new Map();
|
|
10
12
|
this.ready = false; this.heardAt = 0;
|
|
11
13
|
this.progressMs = progressMs; this.progressTimer = null;
|
|
12
14
|
}
|
|
@@ -27,7 +29,7 @@ export class RecordView {
|
|
|
27
29
|
}
|
|
28
30
|
connect() {
|
|
29
31
|
if (this.stream) return Promise.resolve();
|
|
30
|
-
return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor })).then((stream) => {
|
|
32
|
+
return this.opening ??= Promise.resolve().then(() => this.open({ after: this.cursor, record_row_codecs: [SECTION_PARTS_CODEC] })).then((stream) => {
|
|
31
33
|
this.stream = stream; this.ready = false; this.heardAt = Date.now();
|
|
32
34
|
const readObject = jsonObjectReader();
|
|
33
35
|
stream.on('data', (frame) => {
|
|
@@ -82,7 +84,12 @@ export class RecordView {
|
|
|
82
84
|
if (frame.type === 'beat') { this.heardAt = Date.now(); return; }
|
|
83
85
|
if (frame.type === 'snapshot') {
|
|
84
86
|
this.self = frame.machine; this.records = frame.records; this.positions.clear(); this.pending = [];
|
|
85
|
-
|
|
87
|
+
this.sectionRows.clear(); this.dirtySections.clear();
|
|
88
|
+
for (const [machine, held] of Object.entries(this.records)) {
|
|
89
|
+
this.positions.set(machine, new Map((held.record?.sessions ?? []).map((row, at) => [row.address, at])));
|
|
90
|
+
this.sectionRows.set(machine, new Map(['fleet', 'health_events'].filter(section => Object.hasOwn(held.record ?? {}, section))
|
|
91
|
+
.map(section => [section, sectionParts(held.record[section])])));
|
|
92
|
+
}
|
|
86
93
|
this.cursor = frame.cursor; this.ready = true;
|
|
87
94
|
} else if (frame.type === 'changes') {
|
|
88
95
|
if (!this.cursor || frame.cursor?.id !== this.cursor.id) throw new Error('record row changes name a different database; a new snapshot is required');
|
|
@@ -99,11 +106,12 @@ export class RecordView {
|
|
|
99
106
|
if (change.sequence <= this.cursor.sequence || !this.own(change.key)) continue;
|
|
100
107
|
const { machine, section, key, position } = change.key;
|
|
101
108
|
if (change.topic === 'record-head') {
|
|
102
|
-
if (change.operation === 'delete') { delete this.records[machine]; this.positions.delete(machine); continue; }
|
|
109
|
+
if (change.operation === 'delete') { delete this.records[machine]; this.positions.delete(machine); this.sectionRows.delete(machine); this.dirtySections.delete(machine); continue; }
|
|
103
110
|
const { record: summary, meta } = change.value;
|
|
104
111
|
const previous = this.records[machine]?.record;
|
|
105
112
|
const record = summary == null ? null : { sessions: [], mail: {}, panes: null, capabilities: null, unread: {}, ...previous, ...summary };
|
|
106
113
|
if (record) { if (!summary.sessions_rows) record.sessions = null; delete record.sessions_rows; }
|
|
114
|
+
else this.sectionRows.delete(machine);
|
|
107
115
|
this.records[machine] = { record, stream: machine === this.self ? 'self' : meta?.stream ?? 'closed', since: meta?.since ?? null, why: meta?.why ?? null, received_at: meta?.received_at ?? null };
|
|
108
116
|
} else if (change.topic === 'record-row') {
|
|
109
117
|
const held = this.records[machine] ??= { stream: 'closed', record: null };
|
|
@@ -118,6 +126,13 @@ export class RecordView {
|
|
|
118
126
|
record.sessions = [...rows.values()]; moved.add(machine);
|
|
119
127
|
} else if (section === 'mail') {
|
|
120
128
|
if (deleted) delete record.mail[key]; else record.mail[key] = change.value;
|
|
129
|
+
} else if (eventSection(section)) {
|
|
130
|
+
const sections = this.sectionRows.get(machine) ?? new Map(); this.sectionRows.set(machine, sections);
|
|
131
|
+
let rows = sections.get(section) ?? sectionParts(record[section]);
|
|
132
|
+
if (key === '') { rows = new Map(); if (!deleted) rows.set('', { key, position, value: change.value }); }
|
|
133
|
+
else { rows.delete(''); if (deleted) rows.delete(key); else rows.set(key, { key, position, value: change.value }); }
|
|
134
|
+
sections.set(section, rows);
|
|
135
|
+
const dirty = this.dirtySections.get(machine) ?? new Set(); this.dirtySections.set(machine, dirty); dirty.add(section);
|
|
121
136
|
} else record[section] = deleted ? null : change.value;
|
|
122
137
|
}
|
|
123
138
|
}
|
|
@@ -125,6 +140,18 @@ export class RecordView {
|
|
|
125
140
|
const positions = this.positions.get(machine);
|
|
126
141
|
this.records[machine]?.record?.sessions?.sort((a, b) => (positions.get(a.address) ?? 0) - (positions.get(b.address) ?? 0) || a.address.localeCompare(b.address));
|
|
127
142
|
}
|
|
143
|
+
// A bounded page can end inside a writer transaction. Keep its row
|
|
144
|
+
// reconstruction until caught up, when every header/event change is in.
|
|
145
|
+
if (!frame.more) {
|
|
146
|
+
for (const [machine, sections] of this.dirtySections) {
|
|
147
|
+
const record = this.records[machine]?.record;
|
|
148
|
+
if (record) for (const section of sections) {
|
|
149
|
+
const rows = this.sectionRows.get(machine)?.get(section);
|
|
150
|
+
record[section] = rows?.size ? hydrateSectionParts([...rows.values()]) : null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this.dirtySections.clear();
|
|
154
|
+
}
|
|
128
155
|
this.cursor = frame.cursor; this.ready = !frame.more;
|
|
129
156
|
} else throw new Error(`unrecognized record-view frame ${frame.type}`);
|
|
130
157
|
this.heardAt = Date.now();
|
package/board/request-mail.mjs
CHANGED
|
@@ -1,13 +1,44 @@
|
|
|
1
1
|
import { event, json, now } from './store.mjs';
|
|
2
|
+
import { currentRecordChanges, currentRecordConnection } from './current-records.mjs';
|
|
3
|
+
|
|
4
|
+
const heldMail = new WeakMap();
|
|
5
|
+
const MAIL_KINDS = "('board_message','board_message_sent','board_message_passed','board_message_batch')";
|
|
6
|
+
/** Unsettled mail and its retained envelopes; new events update this small set. */
|
|
7
|
+
export function pendingRequestRows(db) {
|
|
8
|
+
let held = heldMail.get(currentRecordConnection(db));
|
|
9
|
+
const change = currentRecordChanges(db, held?.token);
|
|
10
|
+
const high = Number(db.prepare('SELECT MAX(id) AS id FROM task_events').get()?.id ?? 0);
|
|
11
|
+
if (!held || change.eventsRewritten || high < held.cursor) {
|
|
12
|
+
const pending = db.prepare(`SELECT id,task_id,payload,created_at FROM task_events original
|
|
13
|
+
WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent
|
|
14
|
+
WHERE sent.kind IN ('board_message_sent','board_message_passed') AND sent.task_id=original.task_id
|
|
15
|
+
AND json_extract(CASE WHEN json_valid(sent.payload) THEN sent.payload END,'$.event')=original.id) ORDER BY id`).all()
|
|
16
|
+
.map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
|
|
17
|
+
const batches = pending.length ? db.prepare("SELECT id,payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id) : [];
|
|
18
|
+
held = { pending: new Map(pending.map(row => [row.id,row])), batches: new Map(batches.map(row => [row.id,json(row.payload,{})])), cursor: high };
|
|
19
|
+
heldMail.set(currentRecordConnection(db), held);
|
|
20
|
+
} else {
|
|
21
|
+
while (held.cursor < high) {
|
|
22
|
+
const rows = db.prepare(`SELECT id,task_id,kind,payload,created_at FROM task_events
|
|
23
|
+
WHERE id>? AND id<=? AND kind IN ${MAIL_KINDS} ORDER BY id LIMIT 64`).all(held.cursor,high);
|
|
24
|
+
if (!rows.length) { held.cursor = high; break; }
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const payload = json(row.payload,{}) ?? {};
|
|
27
|
+
if (row.kind === 'board_message') held.pending.set(row.id,{ ...row,mail: payload });
|
|
28
|
+
else if (row.kind === 'board_message_batch') held.batches.set(row.id,payload);
|
|
29
|
+
else if (held.pending.get(payload.event)?.task_id === row.task_id) held.pending.delete(payload.event);
|
|
30
|
+
}
|
|
31
|
+
held.cursor = rows.at(-1).id;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const [id,batch] of held.batches) if (!batch.members?.some(member => held.pending.has(member.id))) held.batches.delete(id);
|
|
35
|
+
held.token = change.token;
|
|
36
|
+
return { pending: [...held.pending.values()].sort((a,b) => a.id-b.id), batches: [...held.batches.values()] };
|
|
37
|
+
}
|
|
2
38
|
|
|
3
39
|
export function pendingRequestMail(db, taskId = null) {
|
|
4
|
-
const pending
|
|
5
|
-
WHERE kind='board_message' AND NOT EXISTS (SELECT 1 FROM task_events sent WHERE sent.kind IN ('board_message_sent', 'board_message_passed')
|
|
6
|
-
AND sent.task_id=original.task_id AND json_extract(sent.payload,'$.event')=original.id) ORDER BY id`).all()
|
|
7
|
-
.map(row => ({ ...row, mail: json(row.payload, {}) ?? {} }));
|
|
40
|
+
const { pending, batches } = pendingRequestRows(db);
|
|
8
41
|
if (!pending.length) return { ready: [], nextAt: Infinity };
|
|
9
|
-
const batches = db.prepare("SELECT payload FROM task_events WHERE kind='board_message_batch' AND id>=? ORDER BY id").all(pending[0].id)
|
|
10
|
-
.map(row => json(row.payload, {}));
|
|
11
42
|
const assigned = new Set();
|
|
12
43
|
const ready = [];
|
|
13
44
|
let nextAt = Infinity;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Reader-first native hold admission. The actual stable owner's selected source, never a caller flag, decides
|
|
2
|
+
// whether this generation may interpret fields that a legacy rollback would ignore or lose.
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { askBoardOwner } from '@volter/teams/board-owner';
|
|
9
|
+
import { nativeJsonEqual, parseNativeJson } from '@volter/supercode-interchange/native-json';
|
|
10
|
+
import { readSourceCandidate, readSourceRecord } from '@volter/supercode-teams/source-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
11
|
+
import { sourceLockPath } from '@volter/supercode-teams/source-lock'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
12
|
+
import { readRetainedRelease } from '@volter/supercode-teams/release-selection'; // layer: the board's resting-reader admission reads the host's selected source and release (its source pins); owed: the host hands them in
|
|
13
|
+
import { installJournalPath, readInstallJournal } from '@volter/supercode-teams/machine-install-journal'; // layer: the board's resting-reader admission reads the host's durable install decision; owed: the host hands it in
|
|
14
|
+
import { restingContract } from './resting-protocol.mjs';
|
|
15
|
+
import { assertGenerationWriter } from './generation.mjs';
|
|
16
|
+
import { bindRestingReader, assertRestingInput, RESTING_WRITE_MODE } from './resting-state.mjs';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const MODE = 'backlog-bounded-pause-v1';
|
|
20
|
+
let admission = null;
|
|
21
|
+
const digest = path => createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
22
|
+
const coreSame = (left, right) => ['root', 'commit', 'supercode', 'binding', 'graph'].every(key =>
|
|
23
|
+
nativeJsonEqual(left?.[key] ?? null, right?.[key] ?? null));
|
|
24
|
+
function optionalJson(path) {
|
|
25
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
26
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
27
|
+
}
|
|
28
|
+
function selectedReader(selected, env) {
|
|
29
|
+
if (!selected || typeof selected.entry !== 'string' || !isAbsolute(selected.entry))
|
|
30
|
+
throw new Error('board resting-state admission has no complete incumbent entry');
|
|
31
|
+
const entry = realpathSync(selected.entry);
|
|
32
|
+
// Unretained mutable checkout paths name no verified predecessor capability.
|
|
33
|
+
if (!selected.candidate && !selected.release) return null;
|
|
34
|
+
if (selected.candidate && selected.release) throw new Error('board incumbent names two source selections');
|
|
35
|
+
if (selected.candidate) {
|
|
36
|
+
const source = readSourceCandidate(realpathSync(selected.candidate), sourceLockPath(env));
|
|
37
|
+
if (entry !== realpathSync(join(source.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs')) ||
|
|
38
|
+
realpathSync(selected.native) !== realpathSync(source.program)) throw new Error('board incumbent source binding changed');
|
|
39
|
+
} else if (selected.release) {
|
|
40
|
+
const release = readRetainedRelease(selected.release);
|
|
41
|
+
if (entry !== realpathSync(release.board) || realpathSync(selected.native) !== realpathSync(release.program))
|
|
42
|
+
throw new Error('board incumbent release binding changed');
|
|
43
|
+
}
|
|
44
|
+
return restingContract(entry, { optional: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only the current lock-holding owner lends this record, captured before it overwrites its predecessor's record.
|
|
48
|
+
// An ef4 owner writes the older complete shape before activation and removes it on stop, so it cannot carry a stale
|
|
49
|
+
// newer-reader claim through a legacy write, even when the next source has the same entry path or commit.
|
|
50
|
+
function recordedReader(status, env) {
|
|
51
|
+
try {
|
|
52
|
+
const prior = status.restingReaderProtocol === 1 && status.lastRestingReader;
|
|
53
|
+
if (prior?.version !== 1 || !prior.selected || !prior.resting) return null;
|
|
54
|
+
const actual = selectedReader(prior.selected, env);
|
|
55
|
+
return actual && nativeJsonEqual(actual, prior.resting) ? prior.selected : null;
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recordGrantedReader(env) {
|
|
60
|
+
const request = randomUUID();
|
|
61
|
+
return new Promise((done, reject) => {
|
|
62
|
+
let timer;
|
|
63
|
+
const finish = error => {
|
|
64
|
+
clearTimeout(timer); process.off('message', receive); process.off('disconnect', disconnected);
|
|
65
|
+
error ? reject(error) : done();
|
|
66
|
+
};
|
|
67
|
+
const receive = frame => {
|
|
68
|
+
if (frame?.type !== 'resting-reader-recorded' || frame.request !== request || frame.generation !== env.SUPERCODE_BOARD_GENERATION_ID) return;
|
|
69
|
+
finish(frame.error ? new Error(`board reader selection was not recorded: ${frame.error}`) : null);
|
|
70
|
+
};
|
|
71
|
+
const disconnected = () => finish(new Error('board owner disconnected before recording its actual reader'));
|
|
72
|
+
process.on('message', receive); process.once('disconnect', disconnected);
|
|
73
|
+
timer = setTimeout(() => finish(new Error('board owner did not record its actual reader within 8 seconds')), 8000);
|
|
74
|
+
try { process.send({ type: 'resting-writer-granted', request, generation: env.SUPERCODE_BOARD_GENERATION_ID }); }
|
|
75
|
+
catch (error) { finish(error); }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fresh stable owner has no active child yet. The install's durable source decision is existing selection
|
|
80
|
+
// history, rather than an encoding-admitted boolean an old generation could leave behind on rollback. Source
|
|
81
|
+
// commit follows every component's activation; restoration names the exact predecessor that recovery must start.
|
|
82
|
+
// Cleanup can still be unfinished: it must not strand a cold owner on text the selected reader already supports.
|
|
83
|
+
// The decision, actual primary, prepared home and this invocation's complete selection must all agree. A merely
|
|
84
|
+
// prepared/uncommitted decision grants nothing, and missing/stale history falls back to the legacy collision scan.
|
|
85
|
+
function installDecisionReader(root, env) {
|
|
86
|
+
const ledger = sourceLockPath(env), home = env.SUPERCODE_TEAMS_HOME ?? join(dirname(ledger), 'teams'), service = join(home, 'service');
|
|
87
|
+
const journal = readInstallJournal(installJournalPath(home), { optional: true });
|
|
88
|
+
if (!journal) return null;
|
|
89
|
+
try {
|
|
90
|
+
if (journal.preparedInputs === undefined) return null;
|
|
91
|
+
const rows = journal.boards.filter(board => board.home === root);
|
|
92
|
+
if (rows.length !== 1) return null;
|
|
93
|
+
const board = rows[0], prepared = journal.preparedInputs;
|
|
94
|
+
const recorded = prepared.boards?.filter(item => item?.home === board.home);
|
|
95
|
+
if (recorded?.length !== 1 || !nativeJsonEqual(recorded[0].before?.selected, board.before?.selected) ||
|
|
96
|
+
!nativeJsonEqual(recorded[0].predecessorReceipts, board.predecessorReceipts) ||
|
|
97
|
+
prepared.candidate !== journal.candidate || prepared.published !== journal.published ||
|
|
98
|
+
!/^[a-f0-9]{64}$/.test(prepared.candidateSha256 ?? '') || digest(journal.candidate) !== prepared.candidateSha256) return null;
|
|
99
|
+
let selected, committed;
|
|
100
|
+
if (journal.published) {
|
|
101
|
+
const release = readRetainedRelease(journal.candidate), decision = optionalJson(join(service, 'release-handover.json'));
|
|
102
|
+
if (decision?.transaction !== journal.transaction || decision.candidate !== journal.candidate ||
|
|
103
|
+
!['committed', 'restored'].includes(decision.state)) return null;
|
|
104
|
+
committed = decision.state === 'committed';
|
|
105
|
+
selected = committed ? { entry: release.board, native: release.program, release: journal.candidate, candidate: null } : board.before.selected;
|
|
106
|
+
if (!selected?.release || selected.candidate) return null;
|
|
107
|
+
const expected = readRetainedRelease(selected.release), primary = optionalJson(join(service, 'active-release.json'));
|
|
108
|
+
const outcome = committed ? decision.next : JSON.parse(decision.previous_text);
|
|
109
|
+
if (!nativeJsonEqual(primary, outcome) ||
|
|
110
|
+
!['root', 'native', 'entry', 'release'].every(key => primary?.release?.[key] === expected[key])) return null;
|
|
111
|
+
const own = env.SUPERCODE_RELEASE_RECEIPT && readRetainedRelease(env.SUPERCODE_RELEASE_RECEIPT);
|
|
112
|
+
if (!own || env.SUPERCODE_SOURCE_CANDIDATE || own.receipt !== expected.receipt) return null;
|
|
113
|
+
} else {
|
|
114
|
+
const candidate = readSourceCandidate(journal.candidate, ledger);
|
|
115
|
+
const decision = readSourceRecord(join(dirname(candidate.path), 'activation.json'));
|
|
116
|
+
if (decision.transaction !== journal.transaction || decision.candidate !== candidate.path ||
|
|
117
|
+
!['committed', 'restored'].includes(decision.state) || !coreSame(decision.next, candidate.value.lock)) return null;
|
|
118
|
+
committed = decision.state === 'committed';
|
|
119
|
+
selected = committed ? { entry: join(candidate.value.lock.root, 'sdk/orchestrator/bin/orchestrator.mjs'),
|
|
120
|
+
native: candidate.program, candidate: candidate.path, release: null } : board.before.selected;
|
|
121
|
+
if (!selected?.candidate || selected.release) return null;
|
|
122
|
+
const expected = readSourceCandidate(selected.candidate, ledger);
|
|
123
|
+
const primary = readSourceRecord(ledger), outcome = committed ? decision.next : parseNativeJson(decision.previous_text);
|
|
124
|
+
if (!nativeJsonEqual(primary, outcome) || !coreSame(primary, expected.value.lock)) return null;
|
|
125
|
+
const own = env.SUPERCODE_SOURCE_CANDIDATE && readSourceCandidate(env.SUPERCODE_SOURCE_CANDIDATE, ledger);
|
|
126
|
+
if (!own || env.SUPERCODE_RELEASE_RECEIPT || !coreSame(own.value.lock, expected.value.lock)) return null;
|
|
127
|
+
}
|
|
128
|
+
if (!committed) for (const receipt of Object.values(board.predecessorReceipts ?? {})) {
|
|
129
|
+
if (!receipt || !/^[a-f0-9]{64}$/.test(receipt.sha256 ?? '') || digest(receipt.path) !== receipt.sha256) return null;
|
|
130
|
+
}
|
|
131
|
+
if (!env.SUPERCODE_ORCHESTRATOR_ENTRY || realpathSync(env.SUPERCODE_ORCHESTRATOR_ENTRY) !== realpathSync(selected.entry) ||
|
|
132
|
+
!env.SUPERCODE_BIN || realpathSync(env.SUPERCODE_BIN) !== realpathSync(selected.native)) return null;
|
|
133
|
+
return selected;
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Enumerate storage declarations, not dispatchable boards: an archived board and every rotated archive still
|
|
138
|
+
// belong to the home. Read only the old projection's inventory and pending bytes, never openBoard or a new parser.
|
|
139
|
+
function boardDocuments(root, inspect) {
|
|
140
|
+
const markers = [], base = join(root, 'kanban'), named = join(base, 'boards');
|
|
141
|
+
if (existsSync(join(base, 'ztrack.json'))) markers.push(join(base, 'ztrack.json'));
|
|
142
|
+
if (existsSync(named)) for (const slug of readdirSync(named)) {
|
|
143
|
+
const marker = join(named, slug, 'ztrack.json');
|
|
144
|
+
if (existsSync(marker)) markers.push(marker);
|
|
145
|
+
}
|
|
146
|
+
for (const marker of markers) {
|
|
147
|
+
const config = optionalJson(marker);
|
|
148
|
+
if (!config || typeof config.document !== 'string') throw new Error(`board resting-state admission: invalid document declaration ${marker}`);
|
|
149
|
+
const document = resolve(dirname(marker), config.document), directory = dirname(document);
|
|
150
|
+
const paths = new Set([document, `${document}.archive.md`]);
|
|
151
|
+
if (existsSync(directory)) for (const name of readdirSync(directory))
|
|
152
|
+
if (name.startsWith(`${basename(document)}.archive-`) && name.endsWith('.md')) paths.add(join(directory, name));
|
|
153
|
+
let pending = null;
|
|
154
|
+
const projection = `${marker}.board.db`;
|
|
155
|
+
if (existsSync(projection) && statSync(projection).size) {
|
|
156
|
+
const db = new DatabaseSync(projection, { readOnly: true });
|
|
157
|
+
try {
|
|
158
|
+
db.exec('BEGIN');
|
|
159
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='board_state'").get()) {
|
|
160
|
+
const state = key => {
|
|
161
|
+
const row = db.prepare('SELECT value FROM board_state WHERE key=?').get(key);
|
|
162
|
+
return row ? JSON.parse(row.value) : null;
|
|
163
|
+
};
|
|
164
|
+
const archives = state('archives') ?? [];
|
|
165
|
+
if (!Array.isArray(archives)) throw new Error(`board resting-state admission: invalid archive inventory ${projection}`);
|
|
166
|
+
for (const archive of archives) {
|
|
167
|
+
if (!archive || typeof archive.name !== 'string' || archive.name !== basename(archive.name))
|
|
168
|
+
throw new Error(`board resting-state admission: invalid archive path ${projection}`);
|
|
169
|
+
paths.add(join(directory, archive.name));
|
|
170
|
+
}
|
|
171
|
+
pending = state('pending');
|
|
172
|
+
}
|
|
173
|
+
} finally { db.close(); }
|
|
174
|
+
}
|
|
175
|
+
for (const path of paths) {
|
|
176
|
+
let text;
|
|
177
|
+
try { text = readFileSync(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
178
|
+
inspect(text, path);
|
|
179
|
+
}
|
|
180
|
+
if (pending !== null) {
|
|
181
|
+
if (!Array.isArray(pending.files)) throw new Error(`board resting-state admission: invalid pending publication ${projection}`);
|
|
182
|
+
for (const file of pending.files) {
|
|
183
|
+
if (!file || typeof file.path !== 'string' || !paths.has(resolve(file.path)) || typeof file.base !== 'string' || typeof file.text !== 'string')
|
|
184
|
+
throw new Error(`board resting-state admission: invalid pending document ${projection}`);
|
|
185
|
+
inspect(file.base, `${file.path} (pending base)`);
|
|
186
|
+
inspect(file.text, `${file.path} (pending publication)`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
export function assertBoardRestingDocument(text, path) {
|
|
194
|
+
if (!admission || admission.compatible) return;
|
|
195
|
+
// Preparation and read-only snapshots inspect the same archived documents. Reuse only a successful
|
|
196
|
+
// observation of these exact text bytes at this path; a changed document is inspected again before use.
|
|
197
|
+
if (admission.inspected.get(path) === text) return;
|
|
198
|
+
const slots = admission.inspect(text, path);
|
|
199
|
+
if (!Array.isArray(slots)) throw new Error('board codec returned no resting-state slot inventory');
|
|
200
|
+
if (slots.length) throw new Error(`board resting-state admission: new reader metadata has no compatible incumbent reader in ${path}:${slots[0].line}`);
|
|
201
|
+
admission.inspected.set(path, text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function inspectRows(db, path) {
|
|
205
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_cards'").get()) return;
|
|
206
|
+
const hasBacklog = db.prepare('PRAGMA table_info(supercode_cards)').all().some(column => column.name === 'backlog');
|
|
207
|
+
for (const row of db.prepare(`SELECT task_id,paused,${hasBacklog ? 'backlog' : 'NULL AS backlog'} FROM supercode_cards`).all())
|
|
208
|
+
assertRestingInput(row.paused, row.backlog, { source: `${path}:${row.task_id}` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Scan every backing, archived board and pending publication before granting a writer. No schema migration or
|
|
212
|
+
// modern parser runs here; the incumbent's raw declarations and rows are the inventory.
|
|
213
|
+
function inspectHome(root) {
|
|
214
|
+
if (admission?.compatible) return;
|
|
215
|
+
const base = join(root, 'kanban'), named = join(base, 'boards');
|
|
216
|
+
const places = [{ directory: base, sqlite: join(root, 'kanban.db') }];
|
|
217
|
+
if (existsSync(named)) for (const name of readdirSync(named)) {
|
|
218
|
+
const directory = join(named, name);
|
|
219
|
+
if (statSync(directory).isDirectory()) places.push({ directory, sqlite: join(directory, 'kanban.db') });
|
|
220
|
+
}
|
|
221
|
+
for (const { directory, sqlite } of places) {
|
|
222
|
+
const files = join(directory, 'board');
|
|
223
|
+
if (existsSync(join(files, '_board.json'))) {
|
|
224
|
+
for (const name of readdirSync(files)) {
|
|
225
|
+
if (!name.endsWith('.json')) continue;
|
|
226
|
+
const path = join(files, name), card = optionalJson(path);
|
|
227
|
+
for (const row of card?.tables?.supercode_cards ?? []) assertRestingInput(row.paused, row.backlog, { source: path });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const path of [sqlite, join(directory, 'ztrack.json.board.db')]) {
|
|
231
|
+
if (!existsSync(path) || !statSync(path).size) continue;
|
|
232
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
233
|
+
try { db.exec('BEGIN'); inspectRows(db, path); } finally { db.close(); }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
boardDocuments(root, assertBoardRestingDocument);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A retained final source can be its own first reader, before a board exists. An initialized or previously
|
|
240
|
+
// used home is never inferred fresh from empty task rows. A fencing counter alone carries no resting data.
|
|
241
|
+
// The only generation record allowed during activation is this
|
|
242
|
+
// owner's provisional selection for this exact child; it is not itself a durable reader acknowledgement.
|
|
243
|
+
export function pristineHome(root, { owner, generation, selected, provisional = false, epoch } = {}) {
|
|
244
|
+
if (['kanban', 'kanban.db', 'kanban.db-wal', 'kanban.db-shm'].some(name => existsSync(join(root, name)))) return false;
|
|
245
|
+
let recordedEpoch = null;
|
|
246
|
+
try {
|
|
247
|
+
const text = readFileSync(join(root, 'board.epoch'), 'utf8');
|
|
248
|
+
recordedEpoch = Number(text);
|
|
249
|
+
if (!Number.isSafeInteger(recordedEpoch) || recordedEpoch < 1 || text !== `${recordedEpoch}\n`) return false;
|
|
250
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
251
|
+
// Admission may encounter an abandoned pre-writer fence. Activation must name this invocation's new one.
|
|
252
|
+
if (epoch !== undefined && (!Number.isSafeInteger(epoch) || epoch < 1 || recordedEpoch !== epoch)) return false;
|
|
253
|
+
const recordPath = join(root, 'board-generation.json');
|
|
254
|
+
if (!existsSync(recordPath)) return true;
|
|
255
|
+
const record = optionalJson(recordPath);
|
|
256
|
+
return provisional && record?.version === 1 && record.owner === owner && record.active === process.pid && record.generation === generation &&
|
|
257
|
+
nativeJsonEqual(record.selected, selected) && record.lastRestingReader == null && record.previous == null &&
|
|
258
|
+
Array.isArray(record.unended) && record.unended.length === 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function prepareBoardRestingAdmission(root, env = process.env) {
|
|
262
|
+
if (admission) throw new Error('board resting-state admission is already captured for this generation');
|
|
263
|
+
root = realpathSync(root);
|
|
264
|
+
const answer = await askBoardOwner(root, { argv: ['handover', '--action', 'status'] }, { waitMs: 0, timeoutMs: 8000 });
|
|
265
|
+
if (answer.code !== 0 || answer.unanswered) throw new Error('board resting-state admission: incumbent owner status is unknown');
|
|
266
|
+
const status = JSON.parse(Array.isArray(answer.stdout) ? answer.stdout.join('') : answer.stdout);
|
|
267
|
+
if (status.protocol !== 1 || status.owner !== process.ppid || status.owner !== Number(env.SUPERCODE_BOARD_GENERATION_OWNER))
|
|
268
|
+
throw new Error('board resting-state admission: status does not name this generation’s own stable owner');
|
|
269
|
+
// Active selected source is authoritative. Recorded/journal history is usable only when the cold owner has no
|
|
270
|
+
// active generation, and every retained source and digest still binds to the actual selection.
|
|
271
|
+
const selected = status.selected ?? installDecisionReader(root, env) ?? recordedReader(status, env);
|
|
272
|
+
const reader = selected ? selectedReader(selected, env) : null;
|
|
273
|
+
// Earlier, unreleased v1 hold readers did not understand the richer Acceptance layout. A hold-mode claim alone
|
|
274
|
+
// cannot authorize a writer of that text; require its separately source-bound codec capability as well.
|
|
275
|
+
let compatible = reader?.protocol.readModes.includes(MODE) === true
|
|
276
|
+
&& reader.codec?.formatFeatures?.includes('acceptance-layout-v1') === true;
|
|
277
|
+
const own = restingContract();
|
|
278
|
+
let firstReader = null;
|
|
279
|
+
if (!selected && !compatible && RESTING_WRITE_MODE !== 'legacy-holds' && status.restingReaderProtocol === 1) {
|
|
280
|
+
const ownSelected = { entry: env.SUPERCODE_ORCHESTRATOR_ENTRY, native: env.SUPERCODE_BIN,
|
|
281
|
+
candidate: env.SUPERCODE_SOURCE_CANDIDATE || null, release: env.SUPERCODE_RELEASE_RECEIPT || null };
|
|
282
|
+
const first = { owner: status.owner, generation: env.SUPERCODE_BOARD_GENERATION_ID, selected: ownSelected,
|
|
283
|
+
epoch: status.firstReaderEpoch ?? null };
|
|
284
|
+
if (pristineHome(root, first)) {
|
|
285
|
+
const actual = selectedReader(ownSelected, env);
|
|
286
|
+
if (actual?.codec?.formatFeatures?.includes('acceptance-layout-v1') === true
|
|
287
|
+
&& nativeJsonEqual(actual, own)) { firstReader = first; compatible = true; }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const codec = require('@volter/ztrack/board-document');
|
|
291
|
+
if (typeof codec.inspectBoardRestingSlots !== 'function') throw new Error('board codec has no raw resting-state inspection door');
|
|
292
|
+
admission = Object.freeze({ compatible, selected, reader, inspect: codec.inspectBoardRestingSlots, inspected: new Map() });
|
|
293
|
+
bindRestingReader(compatible);
|
|
294
|
+
let activated = false;
|
|
295
|
+
const preflight = () => {
|
|
296
|
+
if (firstReader && !activated && !pristineHome(root, { ...firstReader, provisional: true }))
|
|
297
|
+
throw new Error('board first-reader admission is no longer pristine; no writer was granted');
|
|
298
|
+
inspectHome(root);
|
|
299
|
+
};
|
|
300
|
+
preflight();
|
|
301
|
+
// Field writers cannot be admitted against a legacy source, even on an empty board. A bridge only publishes the
|
|
302
|
+
// exact tested legacy-readable contract in that mode; rollback over a final reader makes no such claim.
|
|
303
|
+
if (RESTING_WRITE_MODE !== 'legacy-holds' && !compatible)
|
|
304
|
+
throw new Error('board resting-state writer requires an actually selected compatible reader bridge');
|
|
305
|
+
return {
|
|
306
|
+
compatible,
|
|
307
|
+
firstReader: Boolean(firstReader),
|
|
308
|
+
preflight,
|
|
309
|
+
async activateWriter() {
|
|
310
|
+
assertGenerationWriter();
|
|
311
|
+
if (status.restingReaderProtocol === 1) await recordGrantedReader(env);
|
|
312
|
+
else if (RESTING_WRITE_MODE !== 'legacy-holds')
|
|
313
|
+
throw new Error('board owner predates durable resting-state reader selections; restart it through its lifecycle before enabling hold writes');
|
|
314
|
+
activated = true;
|
|
315
|
+
},
|
|
316
|
+
contract: { restingReaderProtocol: 1, resting: own, restingAdmission: firstReader ? 'first-reader' : compatible ? 'compatible-reader' : 'legacy-reader' },
|
|
317
|
+
};
|
|
318
|
+
}
|