@volter/supercode-orchestrator 0.5.204 → 0.5.206
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 +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/apply/doors.mjs +25 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/board/tools.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { basename, join } from 'node:path';
|
|
8
8
|
import { DEFAULT_BOARD, actorOf, event, json, openBoard, setActor, tx } from './store.mjs';
|
|
9
9
|
import * as engine from './engine.mjs';
|
|
10
|
+
import { assertSourceReceipt } from './ci.mjs';
|
|
10
11
|
import { completionOwnerOf, isLatestReviewer, loadWorkflow } from './workflow.mjs';
|
|
12
|
+
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
11
13
|
|
|
12
14
|
const ok = (fields = {}) => JSON.stringify({ ok: true, ...fields });
|
|
13
15
|
const error = (message) => JSON.stringify({ error: message });
|
|
@@ -30,6 +32,7 @@ const ORCHESTRATOR_ONLY = new Set(['kanban_list', 'kanban_unblock']);
|
|
|
30
32
|
|
|
31
33
|
/** One board tool call. `root` is the home; `env` the worker's task environment (`HERMES_KANBAN_*`). */
|
|
32
34
|
export async function boardTool(root, name, args = {}, env = {}) {
|
|
35
|
+
try { assertSourceReceipt(args); } catch (e) { return error(e.message); }
|
|
33
36
|
const own = env.HERMES_KANBAN_TASK || null;
|
|
34
37
|
const runId = env.HERMES_KANBAN_RUN_ID ? Number(env.HERMES_KANBAN_RUN_ID) : null;
|
|
35
38
|
const author = env.HERMES_PROFILE || 'worker';
|
|
@@ -112,7 +115,7 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
if (env.HERMES_SESSION_ID && own === tid) metadata = { ...(metadata ?? {}), worker_session_id: env.HERMES_SESSION_ID };
|
|
115
|
-
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null,
|
|
118
|
+
await engine.complete(board, tid, { summary, result, metadata, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
|
|
116
119
|
return ok({ task_id: tid, status: 'done' });
|
|
117
120
|
}
|
|
118
121
|
case 'kanban_block': {
|
|
@@ -122,12 +125,12 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
122
125
|
}
|
|
123
126
|
case 'kanban_request_state': {
|
|
124
127
|
const stale = ownRun(); if (stale) return error(stale);
|
|
125
|
-
await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null,
|
|
128
|
+
await engine.requestStateMany(board, targets, { state: args.state, reason: args.reason, on: args.on ?? null, waiting_on: args.waiting_on ?? null, evidence: args.evidence ?? null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? null, reviewed_sha: args.reviewed_sha ?? null });
|
|
126
129
|
return ok({ task_id: tid, task_ids: targets, requested: args.state, changed: false });
|
|
127
130
|
}
|
|
128
131
|
case 'kanban_request_review': {
|
|
129
132
|
const stale = ownRun(); if (stale) return error(stale);
|
|
130
|
-
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null,
|
|
133
|
+
await engine.requestReview(board, tid, { summary: args.summary ? redact(String(args.summary)) : null, metadata: args.metadata ? redact(args.metadata) : null, commit: args.commit ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args.pr_head ?? args.commit ?? null, reviewer: args.reviewer ?? null, expectedRunId: own === tid && runId ? runId : null });
|
|
131
134
|
return ok({ task_id: tid, status: 'review' });
|
|
132
135
|
}
|
|
133
136
|
case 'kanban_request_changes': {
|
|
@@ -172,11 +175,15 @@ export async function boardTool(root, name, args = {}, env = {}) {
|
|
|
172
175
|
return ok({ task_id: tid, attachment_id: id, path });
|
|
173
176
|
}
|
|
174
177
|
case 'kanban_attachments':
|
|
178
|
+
if (args.attachment_id !== undefined) {
|
|
179
|
+
const row = tx(board, (db) => attachmentRecord(db, tid, args.attachment_id));
|
|
180
|
+
return ok(await readAttachment(root, slug, row, { offset: args.offset, length: args.length, version: args.version }));
|
|
181
|
+
}
|
|
175
182
|
return ok({ task_id: tid, attachments: tx(board, (db) => engine.attachments(db, tid)).map((a) => ({ id: a.id, filename: a.filename, size: a.size, content_type: a.content_type, path: a.stored_path })) });
|
|
176
183
|
case 'kanban_create': {
|
|
177
184
|
const workspace = args.workspace_kind ? `${args.workspace_kind}${args.workspace_path ? `:${args.workspace_path}` : ''}` : undefined;
|
|
178
185
|
const made = engine.createCard(board, await engine.prepareCard(board, {
|
|
179
|
-
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
186
|
+
title: args.title, body: args.body ? redact(String(args.body)) : null, assignee: args.assignee ?? null, reviewer: args.reviewer ?? null, parents: [args.parents ?? []].flat(), tenant: args.tenant ?? null,
|
|
180
187
|
priority: args.priority ?? 0, workspace, project: args.project ?? null, triage: Boolean(args.triage), idempotencyKey: args.idempotency_key ?? null,
|
|
181
188
|
maxRuntime: args.max_runtime_seconds ?? null, initialStatus: args.initial_status ?? null, skills: args.skills ?? null, model: args.model ?? null, provider: args.provider ?? null,
|
|
182
189
|
goal: args.goal_mode ? { maxTurns: args.goal_max_turns ?? null } : null, createdBy: author,
|
package/board/workflow.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
7
|
import { current } from './call.mjs';
|
|
8
|
+
import { detachedCard, detachedSession } from './custody.mjs';
|
|
8
9
|
import { notRuntime } from './owner-files.mjs';
|
|
9
10
|
import { machineCall } from './doors.mjs';
|
|
10
11
|
import { execFile } from 'node:child_process';
|
|
@@ -20,6 +21,21 @@ import { cardCommitsOn, isLocalWorkspace, isWorkspacePath } from './workspace.mj
|
|
|
20
21
|
import { identifyNotice } from './notices.mjs';
|
|
21
22
|
|
|
22
23
|
const loaded = new Map();
|
|
24
|
+
// the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
|
|
25
|
+
const workflowWatchers = new Set();
|
|
26
|
+
const hold = (root, entry) => {
|
|
27
|
+
const again = loaded.get(root)?.orchestration !== entry.orchestration;
|
|
28
|
+
loaded.set(root, entry);
|
|
29
|
+
if (again) for (const watcher of workflowWatchers) watcher(root, entry);
|
|
30
|
+
};
|
|
31
|
+
/** `watcher(root, entry)` is told each workflow this process holds, now and whenever one is read again; answers its stop. */
|
|
32
|
+
export function watchWorkflows(watcher) {
|
|
33
|
+
workflowWatchers.add(watcher);
|
|
34
|
+
for (const [root, entry] of loaded) watcher(root, entry);
|
|
35
|
+
return () => workflowWatchers.delete(watcher);
|
|
36
|
+
}
|
|
37
|
+
/** Hold the workflow another thread of this process read for `root` (the owner's, on its read door). */
|
|
38
|
+
export function holdWorkflow(root, entry) { loaded.set(root, { ...entry, root }); }
|
|
23
39
|
const homes = new Map(); // root → Promise<orchestration>: the home as the IR reads it, held until it changes
|
|
24
40
|
|
|
25
41
|
/**
|
|
@@ -49,15 +65,15 @@ export async function loadWorkflow(root) {
|
|
|
49
65
|
entry = workflowEntry(orchestration);
|
|
50
66
|
entry.root = root;
|
|
51
67
|
entry.orchestration = orchestration;
|
|
52
|
-
|
|
68
|
+
hold(root, entry);
|
|
53
69
|
}
|
|
54
70
|
current().workflows.set(root, entry);
|
|
55
71
|
return entry;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
// What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
|
|
59
|
-
// layer, and which profiles there are. A session store or a run log changing is no change to it.
|
|
60
|
-
const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
|
|
75
|
+
// layer, persona, and which profiles there are. A session store or a run log changing is no change to it.
|
|
76
|
+
const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AGENTS\.md)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
|
|
61
77
|
|
|
62
78
|
/**
|
|
63
79
|
* Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
|
|
@@ -75,7 +91,7 @@ export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}
|
|
|
75
91
|
const linked = new Map(); // target folder → watcher
|
|
76
92
|
const followLinks = () => {
|
|
77
93
|
const targets = new Map();
|
|
78
|
-
const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json'];
|
|
94
|
+
const decoded = ['workflow.yaml', 'config.yaml', '.env', 'agents.json', 'AGENTS.md'];
|
|
79
95
|
const watchIn = (folder, name) => { if (!targets.has(folder)) targets.set(folder, new Set()); targets.get(folder).add(name); };
|
|
80
96
|
let realRoot = root;
|
|
81
97
|
try { realRoot = realpathSync(root); } catch { /* the home's own watch reports it */ }
|
|
@@ -143,7 +159,7 @@ export function workflowFor(root) {
|
|
|
143
159
|
/** Use `entry` for `root` (a caller that read the IR itself). */
|
|
144
160
|
export function useWorkflow(root, entry) {
|
|
145
161
|
entry.root = root;
|
|
146
|
-
|
|
162
|
+
hold(root, entry);
|
|
147
163
|
current().workflows.set(root, entry);
|
|
148
164
|
return entry;
|
|
149
165
|
}
|
|
@@ -170,6 +186,11 @@ export function completionOwnerOf(db, id) {
|
|
|
170
186
|
return creatorOf(db, id);
|
|
171
187
|
}
|
|
172
188
|
|
|
189
|
+
/** A card's explicitly named review profile, retained in its own history. */
|
|
190
|
+
export function reviewerOf(db, id) {
|
|
191
|
+
return json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND kind IN ('created','reviewer_set') ORDER BY id DESC LIMIT 1", id)?.payload, {})?.reviewer ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
173
194
|
export function latestReview(db, id) {
|
|
174
195
|
const row = one(db, "SELECT id,started_at,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='reviewer' ORDER BY id DESC LIMIT 1", id);
|
|
175
196
|
if (!row) return null;
|
|
@@ -363,7 +384,8 @@ export function runWorkingSeconds(row, at = now()) {
|
|
|
363
384
|
return Math.max(0, end - Number(row.started_at ?? end) - Number(json(row.metadata,{})?.supercode?.stopped_seconds??0));
|
|
364
385
|
}
|
|
365
386
|
|
|
366
|
-
|
|
387
|
+
/** Elapsed role totals need no review candidate or workflow guard scope. */
|
|
388
|
+
export function timeOf(db, task) {
|
|
367
389
|
const t = now();
|
|
368
390
|
const out = { coding: 0, reviewing: 0, auditing: 0, triaging: 0 };
|
|
369
391
|
const key = { implementer: 'coding', reviewer: 'reviewing', auditor: 'auditing', triager: 'triaging' };
|
|
@@ -394,6 +416,14 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
394
416
|
const lastRun = once(() => one(db, 'SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC, id DESC LIMIT 1', task.id));
|
|
395
417
|
const lastCompleted = once(() => one(db, "SELECT * FROM task_runs WHERE task_id = ? AND outcome = 'completed' ORDER BY ended_at DESC LIMIT 1", task.id));
|
|
396
418
|
const asked = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'review_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
|
|
419
|
+
const entry = current().workflows.get(board.root) ?? loaded.get(board.root);
|
|
420
|
+
// Read the coding role, never the latest run of any role: after a review or a
|
|
421
|
+
// reassignment that run may belong to a different harness than the coder's.
|
|
422
|
+
const coding = once(() => one(db, "SELECT id,profile,metadata FROM task_runs WHERE task_id=? AND json_extract(metadata,'$.supercode.role')='implementer' ORDER BY id DESC LIMIT 1", task.id));
|
|
423
|
+
const codingSession = once(() => json(coding()?.metadata, {})?.supercode ?? {});
|
|
424
|
+
const codingProfile = once(() => coding()?.profile ?? asked().implementer ?? task.assignee);
|
|
425
|
+
const coderProfile = once(() => entry?.profiles?.[codingProfile()]);
|
|
426
|
+
const configuredCoder = once(() => coderProfile()?.worker);
|
|
397
427
|
const changes = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
|
|
398
428
|
const interruption = once(() => json(one(db, `SELECT payload FROM task_events WHERE task_id = ? AND kind IN ('blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
|
|
399
429
|
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost')
|
|
@@ -415,6 +445,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
415
445
|
status,
|
|
416
446
|
blocked_by: blocks,
|
|
417
447
|
paused: json(state.paused, null),
|
|
448
|
+
detached: json(state.detached, null),
|
|
418
449
|
archived: Boolean(state.archived),
|
|
419
450
|
every: state.every ?? null,
|
|
420
451
|
next_run_at: state.next_run_at ?? null,
|
|
@@ -436,6 +467,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
436
467
|
get counters() { return { failures: Number(task.consecutive_failures || 0), block_recurrences: Number(task.block_recurrences || 0), goal_turns: Number(session().goal_turns || 0) }; },
|
|
437
468
|
goal_mode: Boolean(task.goal_mode),
|
|
438
469
|
get implementer() { return asked().implementer ?? task.assignee; },
|
|
470
|
+
get implementer_harness() { return codingSession().harness ?? configuredCoder()?.harness ?? null; },
|
|
471
|
+
get implementer_model() { return codingSession().model ?? (codingProfile() === task.assignee ? task.model_override : asked().implementer_model) ??
|
|
472
|
+
configuredCoder()?.model ?? coderProfile()?.residue?.config?.model?.default ?? null; },
|
|
473
|
+
get implementer_run() { return coding() ? { id: coding().id, profile: coding().profile, harness: codingSession().harness ?? null,
|
|
474
|
+
model: codingSession().model ?? null, machine: codingSession().machine ?? null } : null; },
|
|
475
|
+
get implementer_machine() { return Object.hasOwn(asked(), 'implementer_machine') ? asked().implementer_machine : task.machine ?? null; },
|
|
476
|
+
get implementer_model_override() { return Object.hasOwn(asked(), 'implementer_model_override') ? asked().implementer_model_override : task.model_override ?? null; },
|
|
477
|
+
get implementer_provider_override() { return Object.hasOwn(asked(), 'implementer_provider_override') ? asked().implementer_provider_override : task.provider_override ?? null; },
|
|
478
|
+
get reviewer() { return reviewerOf(db, task.id); },
|
|
439
479
|
get last_reviewer() { return changes().reviewer ?? null; },
|
|
440
480
|
get arc() { return task.subtask_of ? one(db, `SELECT t.id, ${related}, t.title, t.body, t.assignee FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id WHERE t.id = ?`, task.subtask_of) ?? null : null; },
|
|
441
481
|
// the cards filed as its subtasks (`create --subtask-of`), in the order they were filed
|
|
@@ -485,13 +525,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
485
525
|
Object.defineProperty(card, key, { get: read, enumerable: true, configurable: true });
|
|
486
526
|
if (!lazy) read();
|
|
487
527
|
}
|
|
488
|
-
const
|
|
489
|
-
for (const [key, expr] of Object.entries(derived)) {
|
|
490
|
-
// Preflight neither evaluates nor exposes a derived source value from a stale/missing observation.
|
|
491
|
-
const value = deferSources && needsSources(expr, derived) ? undefined : evaluate(expr, { card, event, params, now: now() });
|
|
492
|
-
Object.defineProperty(card, key, { value, writable: true, enumerable: true, configurable: true });
|
|
493
|
-
}
|
|
494
|
-
return {
|
|
528
|
+
const scope = {
|
|
495
529
|
card,
|
|
496
530
|
requester: actorOf(),
|
|
497
531
|
event,
|
|
@@ -499,8 +533,15 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
499
533
|
now: now(),
|
|
500
534
|
board: board.label ?? board.slug,
|
|
501
535
|
matches: (text, p) => (text === null || text === undefined ? false : pattern(p).test(String(text))),
|
|
536
|
+
lanes: entry ? lanesIn(entry) : {},
|
|
537
|
+
profile_harnesses: Object.fromEntries(Object.entries(entry?.profiles ?? {}).filter(([, profile]) => profile?.worker?.harness)
|
|
538
|
+
.map(([name, profile]) => [name, profile.worker.harness])),
|
|
502
539
|
...extra,
|
|
503
540
|
};
|
|
541
|
+
// Derived facts see the same names as transitions, including the home's
|
|
542
|
+
// lanes. The pinned entry remains the basis if the home reloads mid-call.
|
|
543
|
+
for (const [key, expr] of Object.entries(entry?.workflow?.derived ?? {})) card[key] = deferSources && needsSources(expr, entry.workflow.derived) ? undefined : evaluate(expr, scope);
|
|
544
|
+
return scope;
|
|
504
545
|
}
|
|
505
546
|
|
|
506
547
|
// ------------------------------------------------------------------ events
|
|
@@ -528,13 +569,19 @@ function noticeRun(db, task) {
|
|
|
528
569
|
: all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? ORDER BY id DESC', task.id).find((r) => json(r.metadata, {})?.supercode?.session_id) ?? null;
|
|
529
570
|
}
|
|
530
571
|
|
|
531
|
-
function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects) {
|
|
572
|
+
function queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession = null) {
|
|
532
573
|
const after = cardOf(db, task.id), to = statusOf(db, after);
|
|
574
|
+
// A verified adoption starts a new run before binding its session. Its notice
|
|
575
|
+
// belongs to that new run and verified identity, never the last ended session
|
|
576
|
+
// that noticeRun found before the transition.
|
|
577
|
+
const targetRun = noticeSession && after.current_run_id
|
|
578
|
+
? one(db, 'SELECT id, metadata FROM task_runs WHERE id = ?', after.current_run_id) : beforeRun;
|
|
533
579
|
const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
|
|
534
580
|
const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
|
|
535
581
|
const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
|
|
536
|
-
if (!
|
|
537
|
-
const session = json(
|
|
582
|
+
if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
|
|
583
|
+
const session = noticeSession ?? json(targetRun.metadata, {})?.supercode;
|
|
584
|
+
if (detachedSession(db, session)) return;
|
|
538
585
|
const address = session?.address ?? (session?.session_id && session.machine ? `sc:${session.machine}:${session.harness ?? 'claude-code'}:${session.session_id}` : null);
|
|
539
586
|
if (!address && !session?.launch_key && !session?.pane) return;
|
|
540
587
|
// The session that made this change learns it from its own command's answer; a notice about
|
|
@@ -542,16 +589,19 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
542
589
|
// confirmed is not told either: its next run resumes it with the card's state in its prompt.
|
|
543
590
|
if (address && address === actorOf()) return;
|
|
544
591
|
if (session?.close_confirmed_at) return;
|
|
545
|
-
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id:
|
|
546
|
-
const
|
|
592
|
+
const scope = scopeOf(db, board, after, { params: entry.params, event: { ...payload, verb: name, from, to }, extra: { run: { id: targetRun.id } } });
|
|
593
|
+
const defaultNotice = noticeSession
|
|
594
|
+
? '{{card.id}}: verified adoption started run {{run.id}} ({{event.from}} → {{event.to}}). Follow the board’s current state before continuing.'
|
|
595
|
+
: '{{card.id}}: {{event.verb}} changed run {{run.id}} ({{event.from}} → {{event.to}}). Stop work under the previous run; follow the board’s current state. {{event.reason ?? ""}}';
|
|
596
|
+
const body = render(entry.workflow.notify?.worker?.run_changed ?? defaultNotice, scope);
|
|
547
597
|
const source = one(db, 'SELECT MAX(id) AS id FROM task_events WHERE task_id = ?', task.id)?.id;
|
|
548
|
-
const text = identifyNotice({ id: source, kind: name, run_id:
|
|
549
|
-
const effect = { kind: 'session_notice', id: task.id, runId:
|
|
598
|
+
const text = identifyNotice({ id: source, kind: name, run_id: targetRun.id, created_at: now() }, body);
|
|
599
|
+
const effect = { kind: 'session_notice', id: task.id, runId: targetRun.id, address, text, sourceEvent: source, verb: name, messageKey: `board-run-notice-${randomBytes(16).toString('hex')}` };
|
|
550
600
|
const inserted = run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', JSON.stringify(effect));
|
|
551
601
|
effects.push({ ...effect, durableId: Number(inserted.lastInsertRowid) });
|
|
552
|
-
const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?',
|
|
602
|
+
const current = json(one(db, 'SELECT metadata FROM task_runs WHERE id = ?', targetRun.id)?.metadata, {}) ?? {};
|
|
553
603
|
current.supercode = { ...current.supercode, lifecycle_notice_pending: Number(inserted.lastInsertRowid) };
|
|
554
|
-
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current),
|
|
604
|
+
run(db, 'UPDATE task_runs SET metadata = ? WHERE id = ?', JSON.stringify(current), targetRun.id);
|
|
555
605
|
}
|
|
556
606
|
|
|
557
607
|
/**
|
|
@@ -644,7 +694,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
644
694
|
return { task, from, taken, scope, beforeRun: localOnly ? null : noticeRun(db, task) };
|
|
645
695
|
}
|
|
646
696
|
|
|
647
|
-
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
697
|
+
export function fire(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, noticeSession = null } = {}) {
|
|
648
698
|
const checked = validateEvent(db, board, entry, id, name, { roles, payload, extra });
|
|
649
699
|
if (!checked) return fireRun(db, board, entry, id, name, { roles, payload, extra });
|
|
650
700
|
const { task, from, taken, scope, beforeRun } = checked;
|
|
@@ -658,8 +708,10 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
658
708
|
const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
|
|
659
709
|
if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
|
|
660
710
|
(['resume','reclaim','replace_session','reopen','reopen_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
|
|
661
|
-
record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {})
|
|
662
|
-
|
|
711
|
+
record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
|
|
712
|
+
...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
|
|
713
|
+
? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
|
|
714
|
+
queueTransitionNotice(db, board, entry, task, from, name, payload, beforeRun, effects, noticeSession);
|
|
663
715
|
return { from, to: statusOf(db, cardOf(db, id)), moved: to !== from, effects };
|
|
664
716
|
}
|
|
665
717
|
|
|
@@ -690,7 +742,7 @@ function cardOf(db, id) {
|
|
|
690
742
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
691
743
|
if (!row) throw new Error(`no card ${id}`);
|
|
692
744
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
693
|
-
return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
745
|
+
return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
694
746
|
}
|
|
695
747
|
|
|
696
748
|
// one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
|
|
@@ -724,6 +776,7 @@ export function setStatus(db, workflow, id, status) {
|
|
|
724
776
|
/** Take every `always` transition that holds, from wherever the card is, until none does. */
|
|
725
777
|
export function settle(db, board, entry, id, effects = []) {
|
|
726
778
|
const { workflow, params } = entry;
|
|
779
|
+
if (detachedCard(db, id)) return effects;
|
|
727
780
|
settleRelations(db, board, entry, id, effects);
|
|
728
781
|
for (let hops = 0; hops < 16; hops += 1) {
|
|
729
782
|
const task = cardOf(db, id);
|
|
@@ -813,7 +866,9 @@ const FORMAT = {
|
|
|
813
866
|
blocked: ({ e, after, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, waiting_on: e.waiting_on ?? null, recurrences: Number(after.block_recurrences || 0), source_status: source }),
|
|
814
867
|
block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
|
|
815
868
|
dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
|
|
816
|
-
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
|
|
869
|
+
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
|
|
870
|
+
implementer: before.assignee, implementer_machine: before.machine ?? null, implementer_model: before.implementer_model ?? null,
|
|
871
|
+
implementer_model_override: before.model_override ?? null, implementer_provider_override: before.provider_override ?? null, reviewer: after.assignee }),
|
|
817
872
|
// the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
|
|
818
873
|
changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
|
|
819
874
|
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
@@ -922,9 +977,15 @@ const ACTIONS = {
|
|
|
922
977
|
} else record(ctx.db, on ?? ctx.id, kind, payload && Object.keys(payload).length ? payload : null, on ? null : ctx.runId);
|
|
923
978
|
},
|
|
924
979
|
assign(ctx, { field, value }) {
|
|
980
|
+
if (field === 'reviewer') {
|
|
981
|
+
if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw new Error('A card reviewer names a runnable profile in this home.');
|
|
982
|
+
record(ctx.db, ctx.id, 'reviewer_set', { reviewer: value ?? null }, ctx.runId);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
925
985
|
if (field === 'machine') {
|
|
926
986
|
const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
|
|
927
987
|
if (task.current_run_id) throw new Error('End the current run before moving its card.');
|
|
988
|
+
if ((value ?? null) === task.machine) return;
|
|
928
989
|
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw new Error('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
|
|
929
990
|
if ((value ?? null) !== task.machine) run(ctx.db, 'UPDATE tasks SET workspace_path=NULL WHERE id=?', ctx.id);
|
|
930
991
|
cardField(ctx, 'machine', value ?? null);
|
|
@@ -984,6 +1045,7 @@ const ACTIONS = {
|
|
|
984
1045
|
run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
|
|
985
1046
|
},
|
|
986
1047
|
claim(ctx, role, scope) {
|
|
1048
|
+
if (detachedCard(ctx.db, ctx.id)) throw new Error('a detached card must receive a verified adoption before starting another run');
|
|
987
1049
|
const task = cardOf(ctx.db, ctx.id);
|
|
988
1050
|
const t = now();
|
|
989
1051
|
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
@@ -1139,7 +1201,7 @@ export function retireSupersededRuns(db, id, runId, session) {
|
|
|
1139
1201
|
OR (? IS NOT NULL AND json_extract(metadata, '$.supercode.pane') = ?))`,
|
|
1140
1202
|
runId, session.machine ?? null, session.session_id ?? null, session.session_id ?? null, session.pane ?? null, session.pane ?? null)) {
|
|
1141
1203
|
const whole = json(old.metadata, {}) ?? {}, s = whole.supercode;
|
|
1142
|
-
if (!s || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1204
|
+
if (!s || detachedSession(db, s) || s.machine !== session.machine || !((session.session_id && s.session_id === session.session_id) || (session.pane && s.pane === session.pane))) continue;
|
|
1143
1205
|
if (old.task_id !== id && one(db, 'SELECT 1 FROM tasks WHERE current_run_id = ?', old.id)) throw new Error(`session is still owned by active run ${old.id} of ${old.task_id}`);
|
|
1144
1206
|
if (s.pane && s.pane === session.pane) whole.supercode = { ...s, close_transferred_to: runId, close_requested: null };
|
|
1145
1207
|
run(db, "UPDATE task_runs SET status='superseded', outcome='superseded', ended_at=?, claim_lock=NULL, claim_expires=NULL, worker_pid=NULL, metadata=? WHERE id=?", now(), JSON.stringify(whole), old.id);
|
|
@@ -1159,6 +1221,7 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
|
|
|
1159
1221
|
if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
|
|
1160
1222
|
const whole = json(row.metadata, {}) ?? {};
|
|
1161
1223
|
const session = whole.supercode;
|
|
1224
|
+
if (detachedSession(db, session)) continue;
|
|
1162
1225
|
if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
|
|
1163
1226
|
if (session.close_requested && !session.closed_at) continue;
|
|
1164
1227
|
whole.supercode = { ...session, close_requested: session.close_requested ?? now() };
|
package/board/ztrack.mjs
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// triggers name the rows it touched. Only the cards whose text changed are rendered, and each is
|
|
8
8
|
// spliced into the document at its indexed span. A document edited by hand is re-read only in the
|
|
9
9
|
// region the edit touched. So a write costs what it changes, not the size of the board.
|
|
10
|
+
//
|
|
11
|
+
// A listing answered on the owner's read door (owner-door.mjs) opens the board read-only on a connection of its own and
|
|
12
|
+
// reads it in one SQLite read transaction: WAL gives it the rows as one committed state, and no writer waits on it or
|
|
13
|
+
// it on a writer. The documents are taken with that state (readSnapshot), never locked.
|
|
10
14
|
import { current } from './call.mjs';
|
|
11
15
|
import { fenced } from './fence.mjs';
|
|
12
16
|
import { createRequire } from 'node:module';
|
|
@@ -14,7 +18,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
14
18
|
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
15
19
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
20
|
import { DatabaseSync } from 'node:sqlite';
|
|
17
|
-
import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES } from './store.mjs';
|
|
21
|
+
import { actorOf, contention, event, json, now, principalOf, storageFault, STORAGE_TRIES, unreadable } from './store.mjs';
|
|
18
22
|
import { GitDocument } from './git-document.mjs';
|
|
19
23
|
import { unimported } from './legacy-store.mjs';
|
|
20
24
|
import { noteStep, recordSlow } from '@volter/supercode-harness-sdk/slow-log';
|
|
@@ -68,11 +72,13 @@ const timedStatement = (sql, fn) => {
|
|
|
68
72
|
finally { noteStatement(sql, started, before); }
|
|
69
73
|
};
|
|
70
74
|
import { fire, workflowFor } from './workflow.mjs';
|
|
71
|
-
import { LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
75
|
+
import { byOwner, LIST_REVISION, listRevision, TASK_SUMMARY_INDEX, TASK_SUMMARY_INDEX_SQL } from './listing.mjs';
|
|
72
76
|
|
|
73
77
|
const require = createRequire(import.meta.url);
|
|
74
78
|
const hash = (text) => createHash('sha256').update(text).digest('hex');
|
|
75
79
|
const read = (path) => existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
80
|
+
// What says a document changed: a write in place moves its size or times, a replacement its inode.
|
|
81
|
+
const stampOf = (path) => { try { const s = statSync(path); return `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { return 'missing'; } };
|
|
76
82
|
const lf = (text) => text.replace(/\r\n?/g, '\n');
|
|
77
83
|
const quote = (s) => `"${String(s).replaceAll('"', '""')}"`;
|
|
78
84
|
const nativeId = (id) => /^t-[a-f0-9]+$/.test(id) ? id.replace('t-', 't_') : id;
|
|
@@ -94,6 +100,9 @@ const sqliteContention = (error) => contention(error); // resolved at call time:
|
|
|
94
100
|
const RETRY = new Error('board changed while this operation read it; retry');
|
|
95
101
|
// The current archive's size before it rotates, unless the board declares its own (`archive.max_lines`).
|
|
96
102
|
const ARCHIVE_LINES = 2000;
|
|
103
|
+
// How many read transactions a listing begins before it leaves the board to its owner, each begun again only because a
|
|
104
|
+
// document changed while it was read (readSnapshot).
|
|
105
|
+
const SNAPSHOT_TRIES = 8;
|
|
97
106
|
const isArchive = (role) => role === 'archived' || String(role ?? '').startsWith('archived:');
|
|
98
107
|
|
|
99
108
|
const BOARD_TABLES = `
|
|
@@ -109,7 +118,7 @@ DROP TABLE IF EXISTS board_export;`;
|
|
|
109
118
|
// written to the document). board_tasks: a subtask's key on its arc's Tasks list
|
|
110
119
|
// (`<arc>:c3`) and its `source:` lines, which live only in the document.
|
|
111
120
|
|
|
112
|
-
export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false }) {
|
|
121
|
+
export function openZtrackBoard(marker, { create = false, document = null, declared = null, seed = null, init, root, slug, held = false, readOnly = false }) {
|
|
113
122
|
if (!existsSync(marker)) {
|
|
114
123
|
if (!create) throw new Error(`no ztrack board ${marker}`);
|
|
115
124
|
mkdirSync(dirname(marker), { recursive: true });
|
|
@@ -123,18 +132,20 @@ export function openZtrackBoard(marker, { create = false, document = null, decla
|
|
|
123
132
|
atomic(marker, JSON.stringify({ format: 1, document: join(realpathSync(dirname(named)), basename(named)), ...said }) + '\n');
|
|
124
133
|
}
|
|
125
134
|
const config = JSON.parse(read(marker));
|
|
126
|
-
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held });
|
|
135
|
+
const backing = new ZtrackBoard({ ...config, seed, marker, init, root, slug, held, readOnly });
|
|
127
136
|
return { db: backing.db, tx: (fn) => backing.tx(fn), publishPending: () => backing.git?.publishPending(), unimported: () => backing.unimported(), close: () => backing.close(), document: backing.path,
|
|
128
137
|
documentsChanged: () => backing.documentsChanged(), documentsUnwatched: (unwatched) => { backing.unwatched = unwatched; }, saidOnOpen: () => backing.sayLate() };
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
|
|
132
141
|
class ZtrackBoard {
|
|
133
|
-
constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false }) {
|
|
142
|
+
constructor({ document, seed, marker, init, root, slug, commit = false, manager = null, archive = {}, held = false, readOnly = false }) {
|
|
134
143
|
// held by the board owner (store.mjs holdBoards): opened once, its documents' stamps read again only when the
|
|
135
144
|
// owner's watch says they changed or may have lost events (documentsChanged), and on every read while it keeps no
|
|
136
145
|
// watch on them (unwatched); git's lateness held and refreshed on its own commits
|
|
137
146
|
this.held = held; this.docsChanged = true; this.importDue = true; this.knownStamps = null;
|
|
147
|
+
// a listing's read-only view (readSnapshot): it makes, migrates, recovers, pulls and commits nothing
|
|
148
|
+
this.readOnly = readOnly;
|
|
138
149
|
this.path = resolve(dirname(marker), document);
|
|
139
150
|
this.archive = `${this.path}.archive.md`;
|
|
140
151
|
this.baseDocs = [{ role: 'open', path: this.path }, { role: 'archived', path: this.archive }];
|
|
@@ -147,7 +158,7 @@ class ZtrackBoard {
|
|
|
147
158
|
// `"manager"`: the board's manager (its principal or session address), whose writes it commits itself as part of its
|
|
148
159
|
// own work; the backing commits only the writes the manager does not make.
|
|
149
160
|
this.manager = manager;
|
|
150
|
-
this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
161
|
+
this.git = commit && !readOnly ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
151
162
|
// what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
|
|
152
163
|
if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
|
|
153
164
|
// A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
|
|
@@ -158,18 +169,25 @@ class ZtrackBoard {
|
|
|
158
169
|
else if (this.git) this.sayLate(timedStep('board open git behind', () => this.git.behind()));
|
|
159
170
|
this.seed = seed; this.root = root; this.slug = slug; this.init = init;
|
|
160
171
|
this.file = `${marker}.board.db`;
|
|
161
|
-
|
|
172
|
+
// the store is the owner's to make, import and lay out: a listing that finds none leaves the board to it
|
|
173
|
+
if (readOnly && !(existsSync(this.file) && statSync(this.file).size > 0)) throw byOwner(`${this.file} is not laid out yet`);
|
|
174
|
+
if (!readOnly) mkdirSync(dirname(this.path), { recursive: true });
|
|
162
175
|
const legacy = `${marker}.runtime.db`;
|
|
163
176
|
// A 0-byte file is no store: a read through the sqlite3 CLI creates one at a path that has none (on 2026-10-01 a
|
|
164
177
|
// `sqlite3 <store> "select …"` made the empty file the next release then opened instead of importing).
|
|
165
|
-
if (existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
178
|
+
if (!readOnly && existsSync(this.file) && statSync(this.file).size === 0) unlinkSync(this.file);
|
|
166
179
|
if (!existsSync(this.file)) this.create(legacy);
|
|
167
|
-
this.conn = timedStep('board open connection', () =>
|
|
168
|
-
|
|
180
|
+
this.conn = timedStep('board open connection', () => {
|
|
181
|
+
if (!readOnly) return new DatabaseSync(this.file);
|
|
182
|
+
try { return new DatabaseSync(this.file, { readOnly: true }); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; }
|
|
183
|
+
});
|
|
184
|
+
if (!readOnly) this.refuseUnimported(legacy);
|
|
169
185
|
this.errors = new Map(); this.statements = new Map(); this.hashes = new Map();
|
|
170
186
|
this.active = false; this.overlay = false; this.seen = null; this.refused = new Set();
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
if (!readOnly) {
|
|
188
|
+
timedStep('board open schema', () => this.prepareConnection());
|
|
189
|
+
timedStep('board open errors', () => this.loadErrors());
|
|
190
|
+
}
|
|
173
191
|
const self = this;
|
|
174
192
|
this.db = {
|
|
175
193
|
[LIST_REVISION]: () => self.read(() => listRevision(self.conn)),
|
|
@@ -185,10 +203,49 @@ class ZtrackBoard {
|
|
|
185
203
|
exec(sql) { const run = () => timedStatement(sql, () => self.conn.exec(sql)); return self.active ? self.noting(run) : self.tx(run); },
|
|
186
204
|
close() { self.close(); },
|
|
187
205
|
};
|
|
206
|
+
if (readOnly) {
|
|
207
|
+
try { timedStep('board read: snapshot', () => this.readSnapshot()); }
|
|
208
|
+
catch (error) { this.conn.close(); throw error; }
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
188
211
|
// A board's first open in this process recovers what its store accepted and derives what git owes for it (tx), so
|
|
189
212
|
// a board that is only read, or whose owner runs no round, still finishes an earlier process's commit.
|
|
190
213
|
if (seed || this.git?.underived) this.tx(() => {});
|
|
191
214
|
}
|
|
215
|
+
|
|
216
|
+
// A listing's view, taken once at its open and kept until its close: the store's rows as of one read transaction, and
|
|
217
|
+
// every document as it stood when that transaction began. A document is written only after the transaction that
|
|
218
|
+
// published it committed, so a document that stood unchanged from before the transaction began until after it was
|
|
219
|
+
// read is one the transaction already describes: its published text, the text a pending publication replaces (the
|
|
220
|
+
// rows stay authoritative), or a hand edit, which is shown through connection-local copies of the card tables as
|
|
221
|
+
// every reader shows one. A document that changed in between is read again with a new transaction. Nothing here waits:
|
|
222
|
+
// WAL serves the read transaction beside any writer, and no busy timeout is set. A store not in WAL or not laid out,
|
|
223
|
+
// one a read-only connection cannot begin reading (store.mjs unreadable) or documents that keep changing leave the
|
|
224
|
+
// listing to the owner.
|
|
225
|
+
readSnapshot() {
|
|
226
|
+
const contended = (fn) => { try { return fn(); } catch (error) { throw unreadable(error) ? byOwner(error.message) : error; } };
|
|
227
|
+
if (String(contended(() => this.conn.prepare('PRAGMA journal_mode').get())?.journal_mode).toLowerCase() !== 'wal') throw byOwner(`${this.file} is not in WAL mode`);
|
|
228
|
+
for (let attempt = 0; attempt < SNAPSHOT_TRIES; attempt++) {
|
|
229
|
+
const stamps = () => this.docs.map((doc) => `${doc.path}\0${stampOf(doc.path)}`).join('\n');
|
|
230
|
+
const before = contended(stamps);
|
|
231
|
+
this.conn.exec('BEGIN');
|
|
232
|
+
let stale;
|
|
233
|
+
try {
|
|
234
|
+
// the transaction's first read begins its snapshot
|
|
235
|
+
if (!contended(() => this.one("SELECT 1 FROM main.sqlite_master WHERE type = 'index' AND name = ?", TASK_SUMMARY_INDEX))) throw byOwner(`${this.file} is not laid out yet`);
|
|
236
|
+
this.loadErrors();
|
|
237
|
+
stale = this.staleDocuments({ pending: true });
|
|
238
|
+
if (stamps() !== before) { this.conn.exec('ROLLBACK'); this.hashes.clear(); continue; }
|
|
239
|
+
} catch (error) { try { this.conn.exec('ROLLBACK'); } catch { /* not begun */ } throw error; }
|
|
240
|
+
if (!stale.length) return;
|
|
241
|
+
this.tables = this.shapes();
|
|
242
|
+
let plan;
|
|
243
|
+
try { plan = this.observe(stale); } catch { return; } // the next write reports it
|
|
244
|
+
this.applyPlan(plan, { overlay: true });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
throw byOwner('its documents changed during every read');
|
|
248
|
+
}
|
|
192
249
|
/** The publish's lateness, said once per call (a held board's from its held reading, never by asking git). */
|
|
193
250
|
sayLate(late = this.git?.lateness ?? null) {
|
|
194
251
|
const said = current().said;
|
|
@@ -246,14 +303,9 @@ class ZtrackBoard {
|
|
|
246
303
|
// What a transaction touched, for the renderer (cards whose text may change: anything beyond run fields and scheduling clocks). Plain inserts: a trigger's
|
|
247
304
|
// own conflict clause yields to the outer statement's, so duplicates are removed when read.
|
|
248
305
|
this.conn.exec('CREATE TEMP TABLE IF NOT EXISTS board_dirty (name TEXT NOT NULL, key TEXT NOT NULL); CREATE TEMP TABLE IF NOT EXISTS board_touched (id TEXT)');
|
|
249
|
-
this.tables =
|
|
250
|
-
const names = this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all().map((row) => row.name);
|
|
306
|
+
this.tables = this.shapes();
|
|
251
307
|
const trigger = (name, when, body) => this.conn.exec(`CREATE TEMP TRIGGER IF NOT EXISTS ${quote(name)} ${when} BEGIN ${body} END`);
|
|
252
|
-
for (const name of
|
|
253
|
-
const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
|
|
254
|
-
const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
|
|
255
|
-
if (!keys.length) keys.push('rowid');
|
|
256
|
-
this.tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
|
|
308
|
+
for (const { name, keys } of this.tables.values()) {
|
|
257
309
|
const key = (row) => `json_array(${keys.map((k) => `${row}.${quote(k)}`).join(',')})`;
|
|
258
310
|
const mark = (row) => `INSERT INTO board_dirty VALUES ('${name}', ${key(row)});`;
|
|
259
311
|
trigger(`board_i_${name}`, `AFTER INSERT ON main.${quote(name)}`, mark('NEW'));
|
|
@@ -268,6 +320,18 @@ class ZtrackBoard {
|
|
|
268
320
|
}
|
|
269
321
|
}
|
|
270
322
|
|
|
323
|
+
// Each runtime table's columns and key, as the store has them.
|
|
324
|
+
shapes() {
|
|
325
|
+
const tables = new Map();
|
|
326
|
+
for (const { name } of this.conn.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'board_%'").all()) {
|
|
327
|
+
const columns = this.conn.prepare(`PRAGMA main.table_info(${quote(name)})`).all();
|
|
328
|
+
const keys = columns.filter((c) => c.pk).sort((a, b) => a.pk - b.pk).map((c) => c.name);
|
|
329
|
+
if (!keys.length) keys.push('rowid');
|
|
330
|
+
tables.set(name, { name, keys, columns: columns.map((c) => c.name) });
|
|
331
|
+
}
|
|
332
|
+
return tables;
|
|
333
|
+
}
|
|
334
|
+
|
|
271
335
|
// A store holding fewer runs than the earlier store beside it was made without the one-time import (on 2026-10-01
|
|
272
336
|
// one such store, created by an opener nobody named, sent the dispatcher on to start a second session on every
|
|
273
337
|
// running card). The board refuses it, so nothing runs on a board without its history.
|
|
@@ -388,6 +452,8 @@ class ZtrackBoard {
|
|
|
388
452
|
}
|
|
389
453
|
|
|
390
454
|
read(fn) {
|
|
455
|
+
// a listing's view is its snapshot's (readSnapshot): its statements read that, never the documents again
|
|
456
|
+
if (this.readOnly) return fn();
|
|
391
457
|
if (this.active) return this.noting(fn);
|
|
392
458
|
this.observeForRead();
|
|
393
459
|
for (let attempt = 0; ; attempt++) {
|
|
@@ -397,8 +463,7 @@ class ZtrackBoard {
|
|
|
397
463
|
}
|
|
398
464
|
|
|
399
465
|
fileHash(path) {
|
|
400
|
-
|
|
401
|
-
try { const s = statSync(path); stamp = `${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`; } catch { stamp = 'missing'; }
|
|
466
|
+
const stamp = stampOf(path);
|
|
402
467
|
const known = this.hashes.get(path);
|
|
403
468
|
if (known?.stamp === stamp) return known;
|
|
404
469
|
const raw = read(path);
|
|
@@ -462,6 +527,7 @@ class ZtrackBoard {
|
|
|
462
527
|
// ------------------------------------------------------------------ writes
|
|
463
528
|
|
|
464
529
|
tx(fn) {
|
|
530
|
+
if (this.readOnly) throw new Error('a listing reads the board and writes nothing');
|
|
465
531
|
// A write inside a write is part of it: the pull below happened once, for the outermost one.
|
|
466
532
|
if (this.active) return fn(this.db);
|
|
467
533
|
// A pull request merged on the remote (a page edited in Git Notes) is in the document before this write reads it.
|
package/board-input.d.ts
ADDED
package/board-input.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A remote card write reads its text on the caller's machine, before the board door is called.
|
|
2
|
+
// The board's own argument parser decides which file is used; its last value wins.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { parse } from './board/args.mjs';
|
|
5
|
+
|
|
6
|
+
/** Carry a comment/create/specify text input as the owner's existing `stdin` field. */
|
|
7
|
+
export function prepareBoardText(argv) {
|
|
8
|
+
const args = parse(argv);
|
|
9
|
+
const verb = args._[0];
|
|
10
|
+
const key = verb === 'comment' ? 'file' : ['create', 'specify'].includes(verb) ? 'body-file' : null;
|
|
11
|
+
const file = key === null || args.help ? undefined : args[key];
|
|
12
|
+
if (file === undefined) return { argv, stdin: null };
|
|
13
|
+
// Appending the selected flag preserves every other argument and overrides earlier occurrences. The owner
|
|
14
|
+
// reads the carried text at `-`, never an unrelated file at the caller's original path on another machine.
|
|
15
|
+
return { argv: [...argv, `--${key}`, '-'], stdin: readFileSync(file === '-' ? 0 : file, 'utf8') };
|
|
16
|
+
}
|