@volter/supercode-orchestrator 0.5.210 → 0.5.212
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 +15 -1
- package/agents.mjs +11 -2
- package/bin/orchestrator.mjs +5 -0
- package/board/agent-declarations.mjs +18 -1
- package/board/attachment-write.mjs +68 -0
- package/board/call.mjs +2 -1
- package/board/capacity.mjs +54 -41
- package/board/ci.mjs +58 -29
- package/board/cli.mjs +51 -6
- package/board/credential-guard.mjs +15 -2
- package/board/dispatch.mjs +122 -40
- package/board/engine.mjs +24 -13
- package/board/fleet-listeners.mjs +16 -5
- package/board/published.mjs +18 -17
- package/board/queries.mjs +30 -0
- package/board/store.mjs +18 -2
- package/board/workflow.mjs +42 -16
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/hermes-cli.mjs +7 -19
- package/index.d.ts +1 -1
- package/loop.mjs +11 -1
- package/mcp/mount.mjs +22 -14
- package/orchestration.mjs +29 -2
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -19,6 +19,16 @@ stage's own transition, which ends the run in the default workflow. See the
|
|
|
19
19
|
[private manual reading](../../docs/evidence/board-manual-claim-2026-10-09.md)
|
|
20
20
|
for scope and limits; existing inconsistent claims are not repaired automatically.
|
|
21
21
|
|
|
22
|
+
Completing a task with `--commit` inherits its arc's workspace when the task is
|
|
23
|
+
scratch. When that workspace belongs to another machine, the board verifies the
|
|
24
|
+
commit against the declared repository's pushed branch head through GitHub,
|
|
25
|
+
without requiring the checkout on the board host. A legacy directory path is
|
|
26
|
+
resolved through its own machine's workspace door to obtain that identity.
|
|
27
|
+
Remote commits take a full SHA or at least seven hexadecimal characters; a
|
|
28
|
+
different head or an unread remote refuses completion. Local checkouts keep
|
|
29
|
+
their existing Git commit resolution. This source check does not replace the
|
|
30
|
+
arc's independent review.
|
|
31
|
+
|
|
22
32
|
The orchestrator's runtime over the ontology: one typed operational model
|
|
23
33
|
(`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
|
|
24
34
|
`docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
|
|
@@ -125,7 +135,11 @@ redacted refusal even when a failed call is fast. See
|
|
|
125
135
|
Board discovery caches advisory reads while waiting. Review requests, verdicts
|
|
126
136
|
and acceptance refresh the complete source set, including previously draft or
|
|
127
137
|
closed declared sources, and validate its current heads. An unread set defers
|
|
128
|
-
the action.
|
|
138
|
+
the action. Before the dispatcher spends those validation reads, it checks the
|
|
139
|
+
advisory candidate against its local worktrees. A mismatched HEAD or uncommitted
|
|
140
|
+
work refuses that attempt locally. Each later round rechecks the checkout, so
|
|
141
|
+
a repair wakes it; a successful preflight still needs the fresh complete-source,
|
|
142
|
+
local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
|
|
129
143
|
old work. Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
130
144
|
reserves its own attempt. All processes and credentials sharing this supercode
|
|
131
145
|
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
package/agents.mjs
CHANGED
|
@@ -232,8 +232,17 @@ export function localAgentRecord(root,org,id){
|
|
|
232
232
|
/** Rename only identities this card role declared; a reassigned standing agent keeps its own name. */
|
|
233
233
|
export function renameCardAgent(root, assignment, readableId) {
|
|
234
234
|
const intent=agentIntent(root,assignment.org,assignment.agent_id);
|
|
235
|
-
if(!intent
|
|
236
|
-
|
|
235
|
+
if(!intent||assignment.state!=='active')return false;
|
|
236
|
+
let declaration=intent.declaration_json?JSON.parse(intent.declaration_json):null;
|
|
237
|
+
if(!declaration){
|
|
238
|
+
// Older declared intents kept only their stable key. Recover this role's own current identity, never an agent
|
|
239
|
+
// reassigned from another card or a standing profile. The existing Teams identity keeps its lineage and sessions.
|
|
240
|
+
const suffix=`/card/${assignment.target}/${assignment.role}`,base=intent.key.replace(/\/generation\/\d+$/,'');
|
|
241
|
+
if(!base.endsWith(suffix)||declarationHead(root,assignment.org,base)!==assignment.agent_id)return false;
|
|
242
|
+
const parent=declarationHead(root,assignment.org,`${base.slice(0,-suffix.length)}/root`);
|
|
243
|
+
if(!parent)return false;
|
|
244
|
+
declaration={kind:'card',card:assignment.target,role:assignment.role,profile:intent.profile_ref,parent};
|
|
245
|
+
}
|
|
237
246
|
if(declaration.kind!=='card'||declaration.card!==assignment.target||declaration.role!==assignment.role)return false;
|
|
238
247
|
const name=`${assignment.role}-${readableId}`;
|
|
239
248
|
if(declaration.name===name&&declaration.readableId===readableId)return false;
|
package/bin/orchestrator.mjs
CHANGED
|
@@ -53,6 +53,11 @@ if (process.argv[2] === 'workflow') {
|
|
|
53
53
|
const {disconnectWorkplace}=await import('../connection.mjs');
|
|
54
54
|
try{const {values}=parseArgs({args:process.argv.slice(3),options:{root:{type:'string'},connection:{type:'string'}}});process.stdout.write(JSON.stringify(await disconnectWorkplace(values),null,2)+'\n');}
|
|
55
55
|
catch(error){process.stderr.write(`disconnect: ${error.message}\n`);process.exitCode=1;}
|
|
56
|
+
} else if (process.argv[2] === 'mcp-server') {
|
|
57
|
+
// a session's orchestrator tools on stdio, bound by its environment (mcp/mount.mjs mounts this through the stable
|
|
58
|
+
// supercode)
|
|
59
|
+
const {serveStdio,bindingFromEnv}=await import('../mcp/server.mjs');
|
|
60
|
+
serveStdio(bindingFromEnv());
|
|
56
61
|
} else if (process.argv[2] === 'agent-channel') {
|
|
57
62
|
await import('./agent-channel.mjs');
|
|
58
63
|
} else {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Consume the card-creation intent after the board transaction; retries reuse the daemon-minted id. The card's agent is
|
|
2
2
|
// minted and recorded here, and its declaration handed to this machine's daemon, which declares it in Teams: whoever ran
|
|
3
3
|
// the effect (the creating command, or a delivery pass retrying it) makes no Teams call (D137).
|
|
4
|
-
import { agentScope, localCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
|
|
4
|
+
import { agentScope, localCardAgent, renameCardAgent, finishAgentAssignment, cardAgentAssignments, cardAgentScopeTransition, pendingCardAgentChanges, acknowledgeCardAgentChanges, handOffAgents } from '../agents.mjs';
|
|
5
5
|
import { cardHandleOf, readableIdOf, event, json, tx, view } from './store.mjs';
|
|
6
6
|
import { workflowFor } from './workflow.mjs';
|
|
7
7
|
import { cardAgentFacts, cardAgentReceiptRecorded, syncCardAgentScope } from './published.mjs';
|
|
@@ -47,6 +47,23 @@ export async function declareCardAgentEffect(board,effect) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** A readable-id edit carries every live role's retained declaration through the existing machine identity door.
|
|
51
|
+
* No assignment is created or revived here; an ended or reassigned agent keeps its own identity and history. */
|
|
52
|
+
export async function renameCardAgentsEffect(board,effect) {
|
|
53
|
+
const task=view(board,db=>db.prepare('SELECT t.readable_id,t.status,c.subtask_of,c.archived FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(effect.id));
|
|
54
|
+
if(!task?.readable_id||task.subtask_of||task.archived||['done','cancelled','archived'].includes(task.status))return true;
|
|
55
|
+
const agents=new Set();
|
|
56
|
+
for(const assignment of cardAgentAssignments(board.root,effect.id)) {
|
|
57
|
+
if(assignment.state!=='active'||!admitsScope(board,effect.id,assignment.org))continue;
|
|
58
|
+
renameCardAgent(board.root,assignment,task.readable_id);
|
|
59
|
+
agents.add(assignment.agent_id);
|
|
60
|
+
}
|
|
61
|
+
if(!agents.size)return true;
|
|
62
|
+
const pending=(await handOffAgents(board.root)).filter(item=>item.agent==='*'||agents.has(item.agent));
|
|
63
|
+
if(pending.length)throw new Error(pending.map(item=>item.error).join('; '));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/** A failed board write leaves the owner's receipt pending. A failed acknowledgement repeats no board event. */
|
|
51
68
|
export function recordCardAgentChanges(board,target){
|
|
52
69
|
const changes=pendingCardAgentChanges(board.root,target);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Upload staging belongs to the board, beneath the authorized card, and to one caller.
|
|
2
|
+
// No attachment record exists until all bytes and their digest have been verified.
|
|
3
|
+
import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, rmSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CHUNK = 1024 * 1024;
|
|
8
|
+
const safeName = name => basename(String(name)).replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^\.+$/, '') || 'attachment';
|
|
9
|
+
|
|
10
|
+
export function receiveAttachment({ directory, upload, caller, input, add }) {
|
|
11
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(upload) || !caller)
|
|
12
|
+
throw new Error('attachment upload needs its identifier and an admitted caller');
|
|
13
|
+
let body;
|
|
14
|
+
try { body = JSON.parse(input); } catch { throw new Error('attachment upload needs JSON input'); }
|
|
15
|
+
const staging = join(directory, '.uploads', upload), metadata = join(staging, 'upload.json'), path = join(staging, 'bytes');
|
|
16
|
+
if (body?.action === 'begin') {
|
|
17
|
+
if (!Number.isSafeInteger(body.size) || body.size < 0 || typeof body.name !== 'string') throw new Error('invalid attachment extent or name');
|
|
18
|
+
mkdirSync(join(directory, '.uploads'), { recursive: true });
|
|
19
|
+
mkdirSync(staging, { mode: 0o700 });
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(path, '', { flag: 'wx', mode: 0o600 });
|
|
22
|
+
writeFileSync(metadata, JSON.stringify({ caller, size: body.size, name: safeName(body.name) }), { flag: 'wx', mode: 0o600 });
|
|
23
|
+
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
24
|
+
return { ok: true, upload, received: 0 };
|
|
25
|
+
}
|
|
26
|
+
const held = JSON.parse(readFileSync(metadata, 'utf8'));
|
|
27
|
+
if (held.caller !== caller) throw new Error('attachment upload belongs to another caller');
|
|
28
|
+
if (body?.action === 'abort') { rmSync(staging, { recursive: true }); return { ok: true, upload, aborted: true }; }
|
|
29
|
+
if (body?.action === 'append') {
|
|
30
|
+
const bytes = typeof body.bytes === 'string' && body.bytes.length <= Math.ceil(CHUNK / 3) * 4 ? Buffer.from(body.bytes, 'base64') : null;
|
|
31
|
+
if (!bytes?.length || bytes.length > CHUNK || bytes.toString('base64') !== body.bytes
|
|
32
|
+
|| !Number.isSafeInteger(body.offset) || body.offset < 0 || body.offset + bytes.length > held.size)
|
|
33
|
+
throw new Error('invalid attachment chunk');
|
|
34
|
+
const file = openSync(path, 'r+');
|
|
35
|
+
try {
|
|
36
|
+
if (fstatSync(file).size !== body.offset) throw new Error('attachment chunk is not at its next offset');
|
|
37
|
+
for (let written = 0; written < bytes.length;) {
|
|
38
|
+
const count = writeSync(file, bytes, written, bytes.length - written, body.offset + written);
|
|
39
|
+
if (count <= 0) throw new Error('attachment chunk write stopped');
|
|
40
|
+
written += count;
|
|
41
|
+
}
|
|
42
|
+
} finally { closeSync(file); }
|
|
43
|
+
return { ok: true, upload, received: body.offset + bytes.length };
|
|
44
|
+
}
|
|
45
|
+
if (body?.action !== 'commit' || !/^[a-f0-9]{64}$/.test(body.sha256)) throw new Error('invalid attachment upload action or digest');
|
|
46
|
+
const file = openSync(path, 'r'), hash = createHash('sha256'), buffer = Buffer.alloc(CHUNK);
|
|
47
|
+
try {
|
|
48
|
+
if (fstatSync(file).size !== held.size) throw new Error('attachment upload is incomplete');
|
|
49
|
+
for (let offset = 0; offset < held.size;) {
|
|
50
|
+
const count = readSync(file, buffer, 0, Math.min(CHUNK, held.size - offset), offset);
|
|
51
|
+
if (count <= 0) throw new Error('attachment upload ended before its extent');
|
|
52
|
+
hash.update(buffer.subarray(0, count)); offset += count;
|
|
53
|
+
}
|
|
54
|
+
if (hash.digest('hex') !== body.sha256) throw new Error('attachment upload digest does not match the source');
|
|
55
|
+
} finally { closeSync(file); }
|
|
56
|
+
let target;
|
|
57
|
+
for (let n = 0;; n++) {
|
|
58
|
+
target = join(directory, n ? `${n}-${held.name}` : held.name);
|
|
59
|
+
try { linkSync(path, target); break; } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
60
|
+
}
|
|
61
|
+
let id;
|
|
62
|
+
try { id = add({ path: target, size: held.size }, held.name); }
|
|
63
|
+
catch (error) { unlinkSync(target); throw error; }
|
|
64
|
+
// The committed record is authoritative; failed staging cleanup must not turn success into an ambiguous retry.
|
|
65
|
+
let cleanup = null;
|
|
66
|
+
try { rmSync(staging, { recursive: true }); } catch { cleanup = staging; }
|
|
67
|
+
return { ok: true, id, path: target, size: held.size, sha256: body.sha256, name: held.name, ...(cleanup ? { cleanup } : {}) };
|
|
68
|
+
}
|
package/board/call.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// a connection of its own (store.mjs openBoard), and nothing is written
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
|
+
import { withCredentialGuard } from './credential-guard.mjs';
|
|
18
19
|
|
|
19
20
|
const calls = new AsyncLocalStorage();
|
|
20
21
|
// what a call learns as it runs, and the workflow it read (pinned: a reload while it runs does not change its basis)
|
|
@@ -34,7 +35,7 @@ export function within({ caller = null, facts = {}, cwd, stdin = null, out, err,
|
|
|
34
35
|
/** Run `fn` as a unit of the process's own work (a round, a delivery pass): the process's identity and output, with
|
|
35
36
|
* what it learns and the workflow it read kept to itself. */
|
|
36
37
|
export function ownCall(fn) {
|
|
37
|
-
return calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn);
|
|
38
|
+
return withCredentialGuard(() => calls.run({ kind: 'own', caller: null, facts: process.env, ...fresh(), get cwd() { return process.cwd(); }, stdin: null, out: own.out, err: own.err }, fn));
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** The caller as its address names it, or null. */
|
package/board/capacity.mjs
CHANGED
|
@@ -89,50 +89,63 @@ export async function holdings(board, options) {
|
|
|
89
89
|
*/
|
|
90
90
|
async function readHoldings(board, { lanes, fleetOk, alive, liveRecord, modelOf = () => null, storedOf = null, machineOf, home = null, runLaneOf = null }, unchanged) {
|
|
91
91
|
const out = [];
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
let cursor = '';
|
|
93
|
+
const high = board.db.prepare('SELECT MAX(id) AS id FROM tasks').get()?.id ?? '';
|
|
94
|
+
for (;;) {
|
|
94
95
|
await yieldToOwner();
|
|
95
96
|
if (!unchanged()) return null;
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
97
|
+
// Limit candidates before filtering or joining: even a page containing only archived/detached cards is bounded.
|
|
98
|
+
const candidates = board.db.prepare('SELECT id FROM tasks WHERE id>? AND id<=? ORDER BY id LIMIT 32').all(cursor, high);
|
|
99
|
+
if (!candidates.length) break;
|
|
100
|
+
cursor = candidates.at(-1).id;
|
|
101
|
+
const rows = board.db.prepare(`SELECT t.id,t.status,t.assignee,t.current_run_id,t.model_override,c.machine,c.subtask_of
|
|
102
|
+
FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id
|
|
103
|
+
WHERE t.id IN (SELECT value FROM json_each(?)) AND t.status!='archived' AND c.detached IS NULL`)
|
|
104
|
+
.all(JSON.stringify(candidates.map(row => row.id)));
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
await yieldToOwner();
|
|
107
|
+
if (!unchanged()) return null;
|
|
108
|
+
const lane = lanes[row.assignee] ?? {};
|
|
109
|
+
const card = {
|
|
110
|
+
card: row.id, board: board.slug, assignee: row.assignee ?? null, status: statusOf(board.db, row),
|
|
111
|
+
// what the board stores for an arc is read through its workflow's stored_as rules now, never the column a write has
|
|
112
|
+
// not yet brought up to date (a paused card counted as running until its row caught up)
|
|
113
|
+
stored: !row.subtask_of && storedOf ? storedOf(row.id) : row.status,
|
|
114
|
+
// a card that names no machine runs on this home's: a cap on this machine counts it
|
|
115
|
+
machine: row.machine ? machineOf(row.machine) : home,
|
|
116
|
+
model: row.model_override ?? lane.model ?? null, harness: lane.harness ?? null,
|
|
117
|
+
};
|
|
118
|
+
// a session's model: what its transcript reports, else what its launch resolved, else the card's
|
|
119
|
+
const sessionOf = (s, slot) => {
|
|
120
|
+
const configured = s.model ?? card.model;
|
|
121
|
+
return { ...card, counts: 'sessions', slot, session: s.session_id ?? null, unconfigured: !configured,
|
|
122
|
+
dispatch: dispatched(s), run: s.run ?? null,
|
|
123
|
+
machine: s.surface === 'headless' ? home : s.machine || s.surface ? machineOf(s.machine ?? row.machine ?? home)
|
|
124
|
+
: executionHost(runLaneOf ? runLaneOf(row) : lane, row.machine, home, machineOf), harness: s.harness ?? card.harness ?? 'claude-code',
|
|
125
|
+
model: (s.session_id ? modelOf(s) : null) ?? configured };
|
|
126
|
+
};
|
|
127
|
+
const sessions = fleetOk ? await sessionsOf(board.db, row.id, unchanged) : new Map();
|
|
128
|
+
if (sessions === null) return null;
|
|
129
|
+
const held = [];
|
|
130
|
+
let current = null;
|
|
131
|
+
if (row.current_run_id) {
|
|
132
|
+
current = { ...json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.current_run_id)?.metadata, {})?.supercode, run: row.current_run_id };
|
|
133
|
+
out.push({ ...sessionOf(current, current.slot ?? 'worker'), session: undefined, slot: undefined, counts: 'runs' });
|
|
134
|
+
}
|
|
135
|
+
for (const [slot, s] of sessions) {
|
|
136
|
+
if (!alive(s)) continue;
|
|
137
|
+
const session = sessionOf(s, slot);
|
|
138
|
+
held.push(session);
|
|
139
|
+
if (IN_TURN.has(liveRecord(s)?.status)) out.push({ ...session, counts: 'turns' });
|
|
140
|
+
}
|
|
141
|
+
// a live run whose session the fleet does not list yet (launching) holds a session's place already
|
|
142
|
+
if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
|
|
143
|
+
out.push(...held);
|
|
144
|
+
// the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
|
|
145
|
+
// until it is done); its model and harness are its run's session's, else its newest live session's, else its own
|
|
146
|
+
// a subtask is a task of its arc: it never runs or holds a slot of its own
|
|
147
|
+
if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
|
|
128
148
|
}
|
|
129
|
-
// a live run whose session the fleet does not list yet (launching) holds a session's place already
|
|
130
|
-
if (current && !held.some((x) => x.slot === (current.slot ?? 'worker'))) held.unshift(sessionOf(current, current.slot ?? 'worker'));
|
|
131
|
-
out.push(...held);
|
|
132
|
-
// the card itself, whatever its runs and sessions: a cap says which by its status (a started card holds its slot
|
|
133
|
-
// until it is done); its model and harness are its run's session's, else its newest live session's, else its own
|
|
134
|
-
// a subtask is a task of its arc: it never runs or holds a slot of its own
|
|
135
|
-
if (!row.subtask_of) out.push({ ...(held[0] ?? { ...card, unconfigured: !card.model }), session: undefined, slot: undefined, counts: 'cards' });
|
|
136
149
|
}
|
|
137
150
|
return out;
|
|
138
151
|
}
|
package/board/ci.mjs
CHANGED
|
@@ -291,13 +291,15 @@ async function repositoryWorkspace(board, id) {
|
|
|
291
291
|
// the worktree's folder, when it is still to be made, is named for its card (workspace.mjs worktreeFolder)
|
|
292
292
|
const workspace = { ...owner, handle: cardHandleOf(board, owner.id) };
|
|
293
293
|
const here = isLocalWorkspace(workspace);
|
|
294
|
+
if (!here) {
|
|
295
|
+
const identity = workspace.workspace_anchor ?? workspace.workspace_path;
|
|
296
|
+
if (!identity) return null;
|
|
297
|
+
// A portable remote declaration supplies the source identity. Legacy paths
|
|
298
|
+
// are resolved by their own machine, never read as paths on the board host.
|
|
299
|
+
const resolved = plainWorkspace(workspace.workspace_kind, identity, workspace.branch_name) ?? await resolveWorkspace(workspace, false);
|
|
300
|
+
return { ...resolved, branch: workspace.branch_name ?? resolved.branch, here: false };
|
|
301
|
+
}
|
|
294
302
|
if (workspace.workspace_anchor) {
|
|
295
|
-
// A portable remote declaration supplies the source identity. Reviewing
|
|
296
|
-
// its pushed head does not require that machine to keep the old checkout.
|
|
297
|
-
if (!here) {
|
|
298
|
-
const declared = plainWorkspace(workspace.workspace_kind, workspace.workspace_anchor, workspace.branch_name);
|
|
299
|
-
if (declared) return { ...declared, here: false };
|
|
300
|
-
}
|
|
301
303
|
const resolved = await resolveWorkspace(workspace, false);
|
|
302
304
|
// Resolution without create names a solo worktree even before it exists on
|
|
303
305
|
// this machine. Its portable repository and branch still identify the
|
|
@@ -305,7 +307,24 @@ async function repositoryWorkspace(board, id) {
|
|
|
305
307
|
if (here && !checkoutPresent(resolved.path)) return { ...resolved, here: await recreatedWorktree(workspace, resolved) };
|
|
306
308
|
return { ...resolved, here };
|
|
307
309
|
}
|
|
308
|
-
return workspace.workspace_path ? { path: workspace.workspace_path, here:
|
|
310
|
+
return workspace.workspace_path ? { path: workspace.workspace_path, here: checkoutPresent(workspace.workspace_path) } : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** A supplied completion commit belongs to the workspace's machine. Local Git
|
|
314
|
+
* keeps its existing semantics; a remote declaration is read at its pushed head. */
|
|
315
|
+
async function completionSource(workspace, supplied) {
|
|
316
|
+
if (workspace?.here && workspace.path) {
|
|
317
|
+
return { repository: await originOf(workspace.path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], workspace.path) };
|
|
318
|
+
}
|
|
319
|
+
if (!workspace?.repository || !workspace.branch) throw new Error('The named commit needs a declared repository and branch, resolved through the workspace machine when only a path is recorded.');
|
|
320
|
+
if (typeof supplied !== 'string' || !/^[a-f0-9]{7,40}$/i.test(supplied)) throw new Error('Name the remote completion commit with its full SHA or an abbreviation of at least seven hexadecimal characters.');
|
|
321
|
+
// Read the declared branch, not an arbitrary commit that merely exists in
|
|
322
|
+
// the repository. No checkout or daemon upgrade on the board host is needed.
|
|
323
|
+
const pushed = await github(`/repos/${workspace.repository}/git/ref/heads/${encodeURIComponent(workspace.branch)}`);
|
|
324
|
+
const head = pushed?.object?.type === 'commit' ? pushed.object.sha : null;
|
|
325
|
+
if (typeof head !== 'string' || !/^[a-f0-9]{40}$/.test(head)) throw new Error(`The declared remote ${workspace.repository}:${workspace.branch} answered no commit head.`);
|
|
326
|
+
if (!head.startsWith(supplied.toLowerCase())) throw new Error(`The named commit ${supplied} must be the pushed head of ${workspace.repository}:${workspace.branch}, currently ${head}.`);
|
|
327
|
+
return { repository: workspace.repository, branch: workspace.branch, commit: head, source_sha: head };
|
|
309
328
|
}
|
|
310
329
|
|
|
311
330
|
/** A card's declared solo worktree that is missing here (deleted, or never made on this machine) while its branch is on
|
|
@@ -383,7 +402,7 @@ function registeredBranches(board, id) {
|
|
|
383
402
|
* declaration (engine.comment), the arc's complete set. A multi-repository arc carries them on the arc card, and its
|
|
384
403
|
* review discovers and accepts PRs from them with no subtask registered in that repository (VO-2858, VO-2860). */
|
|
385
404
|
export function arcSources(db, id) {
|
|
386
|
-
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
405
|
+
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_valid(payload) AND json_type(payload,'$.branches')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
387
406
|
let declared = [];
|
|
388
407
|
try { declared = row ? JSON.parse(row.payload).branches : []; } catch { /* an unreadable declaration names none */ }
|
|
389
408
|
return declared.filter((b) => typeof b?.repository === 'string' && typeof b?.branch === 'string').map((b) => ({ repository: b.repository, branch: b.branch }));
|
|
@@ -396,8 +415,9 @@ export const namedWorkBranch = (id, head) => [`wt/${id}`, `wt/${id.replace(/^t_/
|
|
|
396
415
|
/** The branch names an arc declares as its work: its own and its subtasks' `--branch`, the branches it declares as its
|
|
397
416
|
* sources (arcSources), and each branch its latest review request covered. With `namedWorkBranch`, these are the branches its PRs are opened from. */
|
|
398
417
|
export function declaredWorkBranches(db, id) {
|
|
399
|
-
const declared = db.prepare(`SELECT
|
|
400
|
-
|
|
418
|
+
const declared = db.prepare(`SELECT branch_name FROM tasks WHERE id=? AND status!='cancelled' AND branch_name IS NOT NULL
|
|
419
|
+
UNION SELECT t.branch_name FROM supercode_cards c CROSS JOIN tasks t ON t.id=c.task_id
|
|
420
|
+
WHERE c.subtask_of=? AND t.status!='cancelled' AND t.branch_name IS NOT NULL`).all(id, id).map((r) => r.branch_name);
|
|
401
421
|
declared.push(...arcSources(db, id).map((b) => b.branch));
|
|
402
422
|
const row = db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
403
423
|
let reviewed = [];
|
|
@@ -410,7 +430,7 @@ export function declaredWorkBranches(db, id) {
|
|
|
410
430
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
411
431
|
* checked out (or no branch), is not a work branch: such a card's PRs are only its `wt/<card>` and registered ones, and with none it
|
|
412
432
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
413
|
-
export async function openReviewPullRequests(board, id, { fresh = false } = {}) {
|
|
433
|
+
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
|
|
414
434
|
const own = await repositoryWorkspace(board, id);
|
|
415
435
|
const registered = registeredBranches(board, id);
|
|
416
436
|
if (!own && !registered.length) return null;
|
|
@@ -450,7 +470,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
450
470
|
if (branch === list.defaultBranch) branch = null;
|
|
451
471
|
}
|
|
452
472
|
// a draft is not handed over yet: its review starts when it is marked ready; a PR to another base (main→prod) is a release, not the arc's work
|
|
453
|
-
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
473
|
+
for (const pr of list.items) if (!pr.isCrossRepository && !pr.isDraft && pr.baseRefName === pr.defaultBranch && mine(repository, pr.headRefName)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
454
474
|
// A manual review and merge during a dispatcher outage may predate any
|
|
455
475
|
// board request. The source-review door already accepts merged heads; make
|
|
456
476
|
// them discoverable so the ordinary independent verdict can be retained.
|
|
@@ -467,7 +487,7 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
467
487
|
&& pr.baseRefName === pr.defaultBranch && (answered || !judgedHead(board, id, pr.headRefOid)));
|
|
468
488
|
});
|
|
469
489
|
for (const history of histories) for (const pr of history) {
|
|
470
|
-
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
490
|
+
if (!prs.some(known => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
471
491
|
}
|
|
472
492
|
return prs;
|
|
473
493
|
});
|
|
@@ -481,9 +501,13 @@ export async function openReviewPullRequests(board, id, { fresh = false } = {})
|
|
|
481
501
|
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => repositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
482
502
|
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
483
503
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
484
|
-
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid });
|
|
504
|
+
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
485
505
|
}
|
|
486
506
|
if (!prs.length) return branch || !ownRepository ? null : { waits: `the card has no work branch (${task?.branch_name ? `it declares ${task.branch_name}, its repository's default` : current ? `its checkout ${own.path} is on ${current}, its repository's default` : `its checkout ${own.path} is on no branch`}): give it one with --branch, or open its PR from a wt/${id} branch` };
|
|
507
|
+
// Advisory facts can refuse an attempt before its authoritative REST scan. Recheck the local checkout each
|
|
508
|
+
// round, so a repaired HEAD or clean worktree wakes it without caching the refusal. This is never evidence
|
|
509
|
+
// for a transition: verifyPullRequest repeats the checks against fresh metadata and the pushed head below.
|
|
510
|
+
if (checkWorkspaces) await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
487
511
|
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch };
|
|
488
512
|
}
|
|
489
513
|
|
|
@@ -636,9 +660,7 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
636
660
|
if (stage === 'completion' || stage === 'acceptance') {
|
|
637
661
|
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
638
662
|
if (supplied) {
|
|
639
|
-
|
|
640
|
-
if (!path) throw new Error('The named commit needs a resolved repository workspace.');
|
|
641
|
-
Object.assign(state, { repository: await originOf(path), commit: await run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
663
|
+
Object.assign(state, await completionSource(await repositoryWorkspace(board, id), supplied));
|
|
642
664
|
}
|
|
643
665
|
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
644
666
|
if (candidate) {
|
|
@@ -712,14 +734,28 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
712
734
|
return state;
|
|
713
735
|
}
|
|
714
736
|
|
|
715
|
-
/**
|
|
716
|
-
async function
|
|
717
|
-
|
|
718
|
-
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
737
|
+
/** Local review readiness, also usable with advisory metadata to refuse before spending REST admission. */
|
|
738
|
+
async function requireReviewWorkspaceHead(board, id, own, observed, head, { pushed = false } = {}) {
|
|
739
|
+
const url = observed.url, repository = repositoryOf(url), branch = observed.headRefName;
|
|
719
740
|
// An arc may change several repositories. A PR is read from GitHub; its pushed head is the reviewed one. The
|
|
720
741
|
// card's own checkout, and this machine's checkout of the PR's branch when it keeps one, must also hold that head
|
|
721
742
|
// with nothing uncommitted. A card on another machine is reviewed at its pushed head: its checkout is not read from here.
|
|
722
743
|
const path = observed.state === 'MERGED' || (own && !own.here) ? null : !own ? await branchCheckout(board, id, repository, observed.headRefName) : await originOf(own.path) === repository && await run('git', ['branch', '--show-current'], own.path) === observed.headRefName ? own.path : await branchCheckout(board, id, repository, observed.headRefName);
|
|
744
|
+
if (!path) return;
|
|
745
|
+
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
746
|
+
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
747
|
+
const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
|
|
748
|
+
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
749
|
+
if (pushed) {
|
|
750
|
+
const remote = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
751
|
+
if (remote !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
|
|
756
|
+
async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
|
|
757
|
+
if (typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url)) throw new Error('Name the opened PR URL with --pr.');
|
|
758
|
+
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
723
759
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
724
760
|
// verdict is recorded on that head, and nothing about a worktree or its base can move under it any more.
|
|
725
761
|
const merged = observed.state === 'MERGED';
|
|
@@ -732,14 +768,7 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
|
|
|
732
768
|
const mainBranch = observed.defaultBranch ?? 'main';
|
|
733
769
|
// A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
|
|
734
770
|
if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
|
|
735
|
-
if (stage === 'request_review'
|
|
736
|
-
await run('git', ['check-ref-format', '--branch', branch], path);
|
|
737
|
-
if (await run('git', ['rev-parse', 'HEAD'], path) !== head) throw new Error(`The head named for ${url} must be its worktree head.`);
|
|
738
|
-
const uncommitted = (await run('git', ['status', '--porcelain', '--untracked-files=normal'], path)).split('\n').filter(Boolean);
|
|
739
|
-
if (uncommitted.length) throw new Error(`Commit the work in ${path} before requesting review: ${uncommitted.length} uncommitted path(s): ${uncommitted.slice(0, 5).map((l) => l.replace(/^\s*\S+\s+/, '')).join(', ')}${uncommitted.length > 5 ? ', …' : ''}`);
|
|
740
|
-
const pushed = (await run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path)).split(/\s+/)[0];
|
|
741
|
-
if (pushed !== head) throw new Error(`Push the exact head of ${url} before requesting review.`);
|
|
742
|
-
}
|
|
771
|
+
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
743
772
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
744
773
|
}
|
|
745
774
|
|
package/board/cli.mjs
CHANGED
|
@@ -15,8 +15,9 @@ import { detachSession } from './custody.mjs';
|
|
|
15
15
|
import { taskStatusOf } from './published.mjs';
|
|
16
16
|
import { executionOf } from './session-work.mjs';
|
|
17
17
|
import { attachmentRecord, readAttachment } from './attachment-read.mjs';
|
|
18
|
+
import { receiveAttachment } from './attachment-write.mjs';
|
|
18
19
|
import { listCardsInTurns, READ_BY_OWNER, writeCardList } from './listing.mjs';
|
|
19
|
-
import { redactText } from '
|
|
20
|
+
import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
|
|
20
21
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
21
22
|
import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, copyFileSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs';
|
|
22
23
|
import { notRuntime, ownHome } from './owner-files.mjs';
|
|
@@ -112,13 +113,14 @@ The board's verbs, with their flags:
|
|
|
112
113
|
comment <id> (<text> | --file PATH|-) [--author] [--max-len] [(--pr URL --pr-head SHA)...] [--source owner/repo:branch]... attach <id> <path> [--name] [--content-type] attachments <id> attach-rm <n>
|
|
113
114
|
attachment-read <id> <attachment-id> [--offset N --length N --version V] bounded base64/metadata JSON, including through --at
|
|
114
115
|
complete <id>... [--result] [--summary] [--metadata JSON] [--commit SHA] edit <id> --result [--summary] [--metadata]
|
|
116
|
+
finish <id> --result TEXT (when declared: end your completed attempt; the board selects review)
|
|
115
117
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
116
118
|
pause <id> --reason <why> resume <id> cancel <id> --reason <why>
|
|
117
119
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
118
120
|
unblock <id>... --on <blocker> --reason <what arrived> request-review <id> (--pr URL --pr-head SHA)... [--summary] [--reviewer] [--force] [--commit SHA] request-changes <id> <reason> [--finding <finding>]... reopen-review <id>... reopen <id>... [--reason]
|
|
119
121
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... --reason <why> [--rm <id>...]
|
|
120
122
|
specify <id> [--readable-id ID] [--title --body | --body-file PATH|-] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--machine M|none] [--all]
|
|
121
|
-
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
123
|
+
decompose <id> [--child "<title>[@<profile>]"]... [--all] file the card's subtasks atomically, if its workflow allows
|
|
122
124
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
123
125
|
github-usage [--since 1h] local REST attempts by caller/endpoint; no GitHub read
|
|
124
126
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
@@ -833,6 +835,15 @@ export async function runVerb(argv) {
|
|
|
833
835
|
}
|
|
834
836
|
case 'attach': {
|
|
835
837
|
need(2, 'a card id and a file');
|
|
838
|
+
if (args.upload !== undefined) {
|
|
839
|
+
if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
|
|
840
|
+
withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
|
|
841
|
+
const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
842
|
+
const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
|
|
843
|
+
add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
|
|
844
|
+
say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
836
847
|
const source = callerPath(rest[1]);
|
|
837
848
|
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
838
849
|
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
@@ -1749,8 +1760,11 @@ async function answerVerbs(root, log) {
|
|
|
1749
1760
|
* absence from a slow-only log cannot prove those calls met the time bound. */
|
|
1750
1761
|
export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
1751
1762
|
const scoped = typeof slowLog.withStepScope === 'function';
|
|
1763
|
+
// The owner's receipt includes asynchronous custody preparation too: it is part of the caller's wait.
|
|
1764
|
+
const began = performance.now(), before = slowLog.processCounters?.() ?? null;
|
|
1765
|
+
let prepared = false;
|
|
1752
1766
|
const run = async () => {
|
|
1753
|
-
|
|
1767
|
+
prepared = true;
|
|
1754
1768
|
let named = null, by = null, answer = null, outcome = 'error';
|
|
1755
1769
|
try {
|
|
1756
1770
|
answer = await answerRequest(root, line, (verb, caller) => { named = verb; by = caller; }, { readOnly });
|
|
@@ -1769,7 +1783,23 @@ export async function answerOne(root, line, { readOnly = false } = {}) {
|
|
|
1769
1783
|
}
|
|
1770
1784
|
}
|
|
1771
1785
|
};
|
|
1772
|
-
|
|
1786
|
+
const guarded = async () => {
|
|
1787
|
+
try { return await withCredentialGuard(run); }
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
if (!prepared) {
|
|
1790
|
+
const ended = performance.now(), ms = ended - began;
|
|
1791
|
+
const code = error?.code === 'custody_unreadable' ? 'custody_unreadable' : 'custody_preparation_failed';
|
|
1792
|
+
// No request, caller payload or error text is inspected before custody is available. Fixed metadata needs
|
|
1793
|
+
// no redaction read and still records the failed preparation in this request's own step scope.
|
|
1794
|
+
recordSlow({ kind: 'call', name: 'owner verb custody preparation', args: [], ms, outcome: code,
|
|
1795
|
+
slowest: null, force: true, includeFast: true,
|
|
1796
|
+
extra: { failure: { code, phase: 'custody preparation' }, step_scope: scoped ? 'request' : 'process_interval',
|
|
1797
|
+
steps: scoped ? takeSteps() : slowLog.stepsWithin?.(began, ended) ?? null } });
|
|
1798
|
+
}
|
|
1799
|
+
throw error;
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
return scoped ? slowLog.withStepScope(guarded) : guarded();
|
|
1773
1803
|
}
|
|
1774
1804
|
|
|
1775
1805
|
/** One request answered in a call of its own; on the read door (`readOnly`) a listing only, and `{ byOwner: true }` for
|
|
@@ -1949,7 +1979,20 @@ async function serve(root, args = {}) {
|
|
|
1949
1979
|
let listenerRecords = {}, listenerReloading = null, listenerReloadDue = false, listenerReading = null;
|
|
1950
1980
|
const listenerErrors = new Set();
|
|
1951
1981
|
const listenerLog = text => { if (!listenerErrors.has(text)) { if (listenerErrors.size >= 128) listenerErrors.clear(); listenerErrors.add(text); log(text); } };
|
|
1952
|
-
const scanListeners = ({ waiting = true } = {}) =>
|
|
1982
|
+
const scanListeners = async ({ waiting = true } = {}) => {
|
|
1983
|
+
if (stopped || !fleetListeners.enabled || !listenerBootstrapped || !fleetRecordsCurrent()) return;
|
|
1984
|
+
const revision = listenerRevision;
|
|
1985
|
+
try {
|
|
1986
|
+
await ownCall(() => {
|
|
1987
|
+
if (stopped || revision !== listenerRevision || !fleetListeners.enabled || !fleetRecordsCurrent()) return;
|
|
1988
|
+
observeRoleFleet(root, fleetListeners, listenerRecords, listenerLog, { waiting });
|
|
1989
|
+
});
|
|
1990
|
+
} catch {
|
|
1991
|
+
// Every caller may schedule this without awaiting it. Preparation refusal leaves source cursors untouched;
|
|
1992
|
+
// the next admitted scan retries the retained observations, while the owner's public door stays available.
|
|
1993
|
+
listenerLog('fleet listener scan refused; retained observations wait for a prepared owner call');
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1953
1996
|
const readListenerRecords = () => {
|
|
1954
1997
|
if (!fleetListeners.enabled || stopped || listenerReading) return listenerReading;
|
|
1955
1998
|
const revision = listenerRevision;
|
|
@@ -1964,7 +2007,9 @@ async function serve(root, args = {}) {
|
|
|
1964
2007
|
};
|
|
1965
2008
|
fleetListeners = new FleetListenerRouter({ root, log: listenerLog,
|
|
1966
2009
|
file: (to, body, key) => ownCall(() => fileFleetNotice(root, to, body, key)),
|
|
1967
|
-
|
|
2010
|
+
// The scan/flush prepares custody once; enqueue needs the event synchronously before committing its cursor.
|
|
2011
|
+
enrich: event => enrichFleetEvent(root, event),
|
|
2012
|
+
work: ownCall,
|
|
1968
2013
|
retry: () => scanListeners({ waiting: false }),
|
|
1969
2014
|
});
|
|
1970
2015
|
const unfollowRecords = onRecordsChanged(records => { listenerRecords = records; scanListeners(); });
|
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
// gitleaks' rule set and this machine's custody values), plus the shape of an environment listing: several NAME=value
|
|
4
4
|
// lines in a row. A shell that expanded `$(env)` or a pasted .env into a comment once put live keys on a card, where
|
|
5
5
|
// every reader, every launch prompt and the board's own remote received them.
|
|
6
|
-
import { redactText } from '@volter/teams/redact';
|
|
6
|
+
import { redactText, redactionSnapshotAsync, withRedactionSnapshot } from '@volter/teams/redact';
|
|
7
|
+
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
8
|
+
|
|
9
|
+
/** One fresh, fail-closed custody observation per call/round, read before its synchronous guards. No role decision
|
|
10
|
+
* is kept here, and a concurrent caller cannot replace this call's redaction basis. */
|
|
11
|
+
export async function withCredentialGuard(fn) {
|
|
12
|
+
const began = performance.now();
|
|
13
|
+
let snapshot;
|
|
14
|
+
try { snapshot = await redactionSnapshotAsync(process.env, { fresh: true }); }
|
|
15
|
+
finally { noteStep('custody observation', performance.now() - began); }
|
|
16
|
+
return withRedactionSnapshot(process.env, snapshot, fn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const redactGuardText = text => redactText(text);
|
|
7
20
|
|
|
8
21
|
const ENV_LINE = /^\s*(?:export\s+|declare\s+-x\s+)?[A-Z][A-Z0-9_]{2,}=\S/;
|
|
9
22
|
const ENV_LINES = 5;
|
|
@@ -13,7 +26,7 @@ export function credentialDumpIn(text) {
|
|
|
13
26
|
if (typeof text !== 'string' || text.length < 8) return null;
|
|
14
27
|
const lines = text.split('\n').filter((line) => ENV_LINE.test(line)).length;
|
|
15
28
|
if (lines >= ENV_LINES) return `${lines} NAME=value lines, the shape of an environment listing`;
|
|
16
|
-
const redacted =
|
|
29
|
+
const redacted = redactGuardText(text);
|
|
17
30
|
if (redacted === text) return null;
|
|
18
31
|
const before = new Set(text.match(/\[redacted:[^\]]+\]/g) ?? []);
|
|
19
32
|
const found = [...new Set((redacted.match(/\[redacted:[^\]]+\]/g) ?? []).filter((marker) => !before.has(marker)))]
|