@volter/supercode-orchestrator 0.5.211 → 0.5.213
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 +33 -7
- package/bin/orchestrator.mjs +5 -0
- package/board/attachment-write.mjs +68 -0
- package/board/ci.mjs +105 -78
- package/board/cli.mjs +38 -2
- package/board/dispatch.mjs +171 -51
- package/board/engine.mjs +20 -11
- package/board/github-rest.mjs +84 -2
- package/board/incidents.mjs +1 -0
- package/board/machine-fleet.mjs +3 -1
- package/board/session-work.mjs +7 -0
- package/board/store.mjs +2 -2
- package/board/workflow.mjs +39 -13
- package/board/workspace.mjs +21 -10
- package/board-input.d.ts +2 -0
- package/board-input.mjs +33 -1
- package/connection.mjs +5 -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 +3 -3
package/README.md
CHANGED
|
@@ -124,7 +124,10 @@ range under the same worker scope. The maximum `length` is 1,048,576 bytes.
|
|
|
124
124
|
`XDG_CONFIG_HOME`). It needs neither a running board nor a GitHub request. It
|
|
125
125
|
groups retained wire attempts by caller and endpoint, includes their purposes
|
|
126
126
|
and outcomes, and shows the latest 32 response-header receipts. Unknown transport
|
|
127
|
-
completion remains counted. Tokens and response bodies are not retained
|
|
127
|
+
completion remains counted. Tokens and raw response bodies are not retained in
|
|
128
|
+
the attempts ledger. Budget refusals have separate local receipts, exposed as
|
|
129
|
+
`refusals`; `refusals_since` and `refusals_complete` say whether collection covers
|
|
130
|
+
the requested window. An older ledger cannot prove the absence of refusals.
|
|
128
131
|
|
|
129
132
|
Board actions check their local role, guard and validator rules before GitHub.
|
|
130
133
|
Fresh source observations are reused only within that action, with at most eight
|
|
@@ -132,15 +135,38 @@ concurrent REST operations per process. Owner receipts retain the return code an
|
|
|
132
135
|
redacted refusal even when a failed call is fast. See
|
|
133
136
|
[acceptance latency readings](../../docs/evidence/board-acceptance-latency-2026-10-14.md).
|
|
134
137
|
|
|
135
|
-
Board discovery
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
Board discovery persists advisory projections in this same private SQLite file:
|
|
139
|
+
one open-PR list per API origin, credential digest and repository for five minutes,
|
|
140
|
+
independent of which card declares that repository. Default-branch metadata has
|
|
141
|
+
its own entry; acquiring it never repeats the list. Branch histories last fifteen
|
|
142
|
+
minutes, merged PR observations an hour and other PR observations a minute.
|
|
143
|
+
Failures remain failures for the entry's interval. In-process readers share a
|
|
144
|
+
pending result; another process defers on its durable claim instead of duplicating
|
|
145
|
+
the request. A claim expires after six minutes if its process disappears.
|
|
146
|
+
Successful fresh validation refreshes advisory discovery, including after restart.
|
|
147
|
+
|
|
148
|
+
Review requests, verdicts
|
|
149
|
+
and acceptance refresh the complete source set in the arc's repositories,
|
|
150
|
+
including previously draft or closed declared sources, and validate its current
|
|
151
|
+
heads. That scope includes its workspace, registered branches, declared sources,
|
|
152
|
+
previous candidate and discovered sibling work, and is retained in the source
|
|
153
|
+
receipt. Broad known-repository discovery remains advisory; unrelated checkouts
|
|
154
|
+
spend no validation calls. Every fresh source entry first acquires that advisory
|
|
155
|
+
scope, even for old receipts and sibling checkouts now on main; an unread scope
|
|
156
|
+
refuses authorization. A round with every head judged stops at advisory
|
|
157
|
+
eligibility, and the review evidence door refuses a redundant proposed head set
|
|
158
|
+
before a fresh scan. An unread set defers
|
|
138
159
|
the action. Before the dispatcher spends those validation reads, it checks the
|
|
139
|
-
advisory candidate
|
|
140
|
-
|
|
160
|
+
advisory candidate's PR properties and local worktrees. An invalid PR state,
|
|
161
|
+
draft, head or base refuses before the full validation scan, including a declared
|
|
162
|
+
PR on the wrong base. Discovery refreshes changes to those properties. A mismatched
|
|
163
|
+
HEAD or uncommitted work also refuses locally. Each later round rechecks the checkout, so
|
|
141
164
|
a repair wakes it; a successful preflight still needs the fresh complete-source,
|
|
142
165
|
local and pushed-head checks before review is recorded. Unread commit dates also defer eligibility and are never cached as
|
|
143
|
-
old work.
|
|
166
|
+
old work. An arc that already named a PR for review cannot become a no-code
|
|
167
|
+
disposition merely because it merged; it skips the repeated no-code history
|
|
168
|
+
validation. Advisory no-code and commit eligibility use discovery's allowance.
|
|
169
|
+
Merge validation still reads the current head. Every request page, retry, redirect and merge PUT
|
|
144
170
|
reserves its own attempt. All processes and credentials sharing this supercode
|
|
145
171
|
home share a rolling-hour ceiling of 1,200 attempts, including at most 600
|
|
146
172
|
discovery attempts. Endpoint exhaustion/Retry-After also defers subsequent work.
|
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 {
|
|
@@ -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/ci.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { createHash } from 'node:crypto';
|
|
|
7
7
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
8
|
import { branchCheckouts, checkoutOf, isLocalWorkspace, resolveWorkspace, plainWorkspace } from './workspace.mjs';
|
|
9
9
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
10
|
-
import { githubFetch } from './github-rest.mjs';
|
|
10
|
+
import { githubFetch, githubDiscovery } from './github-rest.mjs';
|
|
11
11
|
import { cardHandleOf } from './store.mjs';
|
|
12
12
|
|
|
13
13
|
// GitHub is read through its REST API with Node's own fetch, in this process and awaited (never a child the caller
|
|
@@ -177,22 +177,15 @@ const numberOf = (url) => Number(new URL(url).pathname.split('/')[4]);
|
|
|
177
177
|
/** A PR as the board reads it (gh's field names). */
|
|
178
178
|
const pullRequest = (repository, p) => ({ url: p.html_url, number: p.number, state: p.merged_at ? 'MERGED' : String(p.state).toUpperCase(), headRefOid: p.head.sha,
|
|
179
179
|
headRefName: p.head.ref, baseRefName: p.base.ref, defaultBranch: p.base.repo?.default_branch ?? null, isCrossRepository: p.head.repo?.full_name?.toLowerCase() !== repository.toLowerCase(), isDraft: Boolean(p.draft) });
|
|
180
|
-
const
|
|
180
|
+
const discoveryIdentity = async key => `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
181
|
+
const discoveryRead = async (key, ttl, read, options) => githubDiscovery(await discoveryIdentity(key), ttl, read, options);
|
|
182
|
+
const readPullRequest = async (url) => discoverPullRequest(url, { fresh: true });
|
|
181
183
|
// Discovery is advisory. Already-merged sources are retained for an hour, others for a minute. Every transition
|
|
182
184
|
// still uses readPullRequest/verifyPullRequest above/below, which bypass this cache and reads its current head.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (held && Date.now() < held.until) return held.pending;
|
|
188
|
-
if (discoveredPullRequests.size >= 4096 && !held) discoveredPullRequests.delete(discoveredPullRequests.keys().next().value);
|
|
189
|
-
held = { until: Date.now() + 60_000 };
|
|
190
|
-
held.pending = github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: 'discovery' })
|
|
191
|
-
.then(body => { const pr = pullRequest(repositoryOf(url), body); held.until = Date.now() + (pr.state === 'MERGED' ? 3600_000 : 60_000); return pr; });
|
|
192
|
-
// Failed reads stay failed for a minute; no cached empty source or invented successful head.
|
|
193
|
-
void held.pending.catch(() => {});
|
|
194
|
-
discoveredPullRequests.set(key, held);
|
|
195
|
-
return held.pending;
|
|
185
|
+
export async function discoverPullRequest(url, { fresh = false } = {}) {
|
|
186
|
+
return discoveryRead(`pr:${repositoryOf(url).toLowerCase()}:${numberOf(url)}`, 60_000, async () => pullRequest(repositoryOf(url),
|
|
187
|
+
await github(`/repos/${repositoryOf(url)}/pulls/${numberOf(url)}`, false, { purpose: fresh ? 'validation' : 'discovery' })),
|
|
188
|
+
{ fresh, ttlOf: pr => pr.state === 'MERGED' ? 3600_000 : 60_000 });
|
|
196
189
|
}
|
|
197
190
|
|
|
198
191
|
/**
|
|
@@ -339,31 +332,16 @@ async function recreatedWorktree(workspace, resolved) {
|
|
|
339
332
|
try { const made = await resolveWorkspace(workspace, true); return made.path === resolved.path && checkoutPresent(made.path); } catch { return false; }
|
|
340
333
|
}
|
|
341
334
|
|
|
342
|
-
const pullRequestLists = new Map();
|
|
343
|
-
const mergedPullRequestLists = new Map();
|
|
344
|
-
// Keep in-flight and failed list reads too, with room for the registered branch set. History changes rarely;
|
|
345
|
-
// ordinary open work is discovered by the repository's open-PR listing.
|
|
346
|
-
async function discoveryList(cache, key, ttl, read) {
|
|
347
|
-
key = `${githubBase()}\0${createHash('sha256').update(await githubToken() ?? '').digest('hex')}\0${key}`;
|
|
348
|
-
let held = cache.get(key);
|
|
349
|
-
if (held && Date.now() < held.until) return held.pending;
|
|
350
|
-
if (cache.size >= 4096 && !held) cache.delete(cache.keys().next().value);
|
|
351
|
-
held = { until: Date.now() + ttl };
|
|
352
|
-
held.pending = Promise.resolve().then(read).catch(error => { held.until = Date.now() + 60_000; throw error; });
|
|
353
|
-
void held.pending.catch(() => {}); cache.set(key, held);
|
|
354
|
-
return held.pending;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
335
|
/** Historical work can already be merged when an outage prevented the board's
|
|
358
336
|
* review request. Read only the arc's declared branch, never all closed PRs. */
|
|
359
337
|
async function mergedBranchPullRequests(repository, branch, { fresh = false } = {}) {
|
|
360
|
-
const key = `${repository}:${branch}`;
|
|
338
|
+
const key = `${repository.toLowerCase()}:${branch}`;
|
|
361
339
|
const read = async () => {
|
|
362
340
|
const head = encodeURIComponent(`${repository.split('/')[0]}:${branch}`);
|
|
363
341
|
return (await github(`/repos/${repository}/pulls?state=closed&head=${head}&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' }))
|
|
364
342
|
.filter(p => p.merged_at).map(p => pullRequest(repository, p));
|
|
365
343
|
};
|
|
366
|
-
return
|
|
344
|
+
return discoveryRead(`history:${key}`, 900_000, read, { fresh });
|
|
367
345
|
}
|
|
368
346
|
|
|
369
347
|
async function branchCheckout(board, id, repository, branch) {
|
|
@@ -430,18 +408,38 @@ export function declaredWorkBranches(db, id) {
|
|
|
430
408
|
* of, or a sibling checkout the arc registered (`registeredBranches`). The repository's default branch, declared or
|
|
431
409
|
* 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
|
|
432
410
|
* answers `{ waits }` saying so. One review covers them all; the caller decides the transition. */
|
|
433
|
-
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false } = {}) {
|
|
411
|
+
export async function openReviewPullRequests(board, id, { fresh = false, checkWorkspaces = false, repositories = [] } = {}) {
|
|
412
|
+
if (!fresh) return discoverReviewSources(board, id, { checkWorkspaces, repositories });
|
|
413
|
+
// Every authorization entry acquires the broad advisory scope, not only
|
|
414
|
+
// dispatch. An older receipt and a checkout now on main still have to see
|
|
415
|
+
// implicit wt/<card> PRs. Discovery failures leave that scope unknown.
|
|
416
|
+
const scope = new Set(repositories);
|
|
417
|
+
await discoverReviewSources(board, id, { repositories, strict: true }, scope);
|
|
418
|
+
return discoverReviewSources(board, id, { fresh: true, checkWorkspaces, repositories: [...scope] });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function discoverReviewSources(board, id, { fresh = false, checkWorkspaces = false, repositories = [], strict = false } = {}, scope = null) {
|
|
434
422
|
const own = await repositoryWorkspace(board, id);
|
|
435
|
-
const registered = registeredBranches(board, id);
|
|
423
|
+
const registered = registeredBranches(board, id).map(r => ({ ...r, repository: r.repository.toLowerCase() }));
|
|
436
424
|
if (!own && !registered.length) return null;
|
|
437
425
|
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
438
|
-
const ownRepository = own?.repository ?? (own?.here ? await originOf(own.path) : null);
|
|
426
|
+
const ownRepository = (own?.repository ?? (own?.here ? await originOf(own.path) : null))?.toLowerCase();
|
|
439
427
|
if (!ownRepository && !registered.length) return null;
|
|
440
428
|
const current = task?.branch_name ? null : own?.branch ?? (own?.here ? await run('git', ['branch', '--show-current'], own.path) : null);
|
|
441
429
|
let branch = task?.branch_name ?? (current || null);
|
|
442
430
|
const mine = (repository, head) => (repository === ownRepository && head === branch) || namedWorkBranch(id, head)
|
|
443
431
|
|| registered.some((r) => r.repository === repository && r.branch === head);
|
|
444
|
-
|
|
432
|
+
// Broad discovery finds wt/<card> work in sibling repositories. Authorization
|
|
433
|
+
// refreshes only this arc's repositories, including siblings already found or
|
|
434
|
+
// requested; an unrelated checkout is never a validation-purpose PR scan.
|
|
435
|
+
const previous = reviewCandidate(board, id);
|
|
436
|
+
const requested = [...candidatePullRequests(previous).map(p => p.repository ?? repositoryOf(p.pr.url)),
|
|
437
|
+
...(previous?.review_repositories ?? [])];
|
|
438
|
+
const siblings = fresh ? [] : await knownRepositories({ strict });
|
|
439
|
+
const repositoriesToRead = [...new Set([ownRepository, ...registered.map(r => r.repository), ...requested,
|
|
440
|
+
...repositories, ...siblings].filter(Boolean).map(r => r.toLowerCase()))];
|
|
441
|
+
const reviewRepositories = new Set([ownRepository, ...registered.map(r => r.repository), ...requested, ...repositories]
|
|
442
|
+
.filter(Boolean).map(r => r.toLowerCase()));
|
|
445
443
|
const answered = answeredChanges(board, id);
|
|
446
444
|
const groups = await readTogether(repositoriesToRead, async repository => {
|
|
447
445
|
const prs = [];
|
|
@@ -449,22 +447,22 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
|
|
|
449
447
|
// Advisory discovery is shared. Before a move, refresh the full set, including previously excluded PRs.
|
|
450
448
|
let list;
|
|
451
449
|
try {
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
};
|
|
461
|
-
// An empty named-only observation cannot supply branch metadata when a later card registers this repo.
|
|
462
|
-
const listKey = `${repository}:${hasWorkBranches ? 'work' : 'named'}`;
|
|
463
|
-
list = fresh ? await read() : await discoveryList(pullRequestLists, listKey, 300_000, read);
|
|
450
|
+
const items = await discoveryRead(`open:${repository.toLowerCase()}`, 300_000, async () =>
|
|
451
|
+
(await github(`/repos/${repository}/pulls?state=open&per_page=100`, true, { purpose: fresh ? 'validation' : 'discovery' })).map(p => pullRequest(repository, p)), { fresh });
|
|
452
|
+
// Empty unrelated repositories need no default metadata. Keep that lookup separate from the shared list,
|
|
453
|
+
// so a card declaring the repository can acquire it without listing the same open PRs again.
|
|
454
|
+
const needsDefault = items.length > 0 || hasWorkBranches;
|
|
455
|
+
const defaultBranch = items.find(pr => pr.defaultBranch)?.defaultBranch
|
|
456
|
+
?? (needsDefault ? await discoveryRead(`default:${repository.toLowerCase()}`, 300_000, async () =>
|
|
457
|
+
(await github(`/repos/${repository}`, false, { purpose: fresh ? 'validation' : 'discovery' })).default_branch, { fresh }) : null);
|
|
458
|
+
list = { items, defaultBranch };
|
|
464
459
|
} catch (error) {
|
|
465
|
-
if (fresh || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
460
|
+
if (fresh || strict || repository === ownRepository || registered.some(r => r.repository === repository)) throw error;
|
|
466
461
|
return []; // An unread sibling stays unread: do not cache an empty successful result.
|
|
467
462
|
}
|
|
463
|
+
// Include excluded drafts/other bases in the fresh scope: becoming ready
|
|
464
|
+
// must not let a sibling source escape the complete-set check.
|
|
465
|
+
if (list.items.some(pr => mine(repository, pr.headRefName))) reviewRepositories.add(repository);
|
|
468
466
|
// The repository's default branch is not a work branch, declared or checked out (a card working in a `dir` on main).
|
|
469
467
|
if (repository === ownRepository && branch) {
|
|
470
468
|
if (branch === list.defaultBranch) branch = null;
|
|
@@ -497,24 +495,34 @@ export async function openReviewPullRequests(board, id, { fresh = false, checkWo
|
|
|
497
495
|
// sources or its tasks' registered branches name count, and each declared PR is followed at its current head (GitHub's), never pinned to the
|
|
498
496
|
// declared one, so a push after the declaration is reviewed instead of failing every tick.
|
|
499
497
|
const declaration = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='commented' AND json_type(payload,'$.sources')='array' ORDER BY id DESC LIMIT 1").get(id);
|
|
500
|
-
const
|
|
501
|
-
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source =>
|
|
498
|
+
const declaredRepositories = new Set([ownRepository, ...registered.map((r) => r.repository)].filter(Boolean).map((r) => r.toLowerCase()));
|
|
499
|
+
const sources = (declaration ? JSON.parse(declaration.payload).sources : []).filter(source => declaredRepositories.has(repositoryOf(source.url).toLowerCase()) && !prs.some(known => known.url === source.url));
|
|
502
500
|
for (const pr of await readTogether(sources, source => fresh ? readPullRequest(source.url) : discoverPullRequest(source.url))) {
|
|
503
501
|
if (pr.isCrossRepository || !['OPEN', 'MERGED'].includes(pr.state) || (pr.state === 'OPEN' && pr.isDraft)) continue;
|
|
504
502
|
if (!prs.some((known) => known.url === pr.url)) prs.push({ url: pr.url, head: pr.headRefOid, observed: pr });
|
|
505
503
|
}
|
|
504
|
+
// Retain scope even when all matching PRs are excluded drafts/other bases,
|
|
505
|
+
// or judged merged sources, so a fresh scan cannot inherit that exclusion.
|
|
506
|
+
if (scope) for (const repository of reviewRepositories) scope.add(repository);
|
|
506
507
|
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.
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
508
|
+
// Advisory facts can refuse an attempt before its authoritative REST scan. Check every PR's properties before
|
|
509
|
+
// doing checkout work: a wrong base cannot be repaired by revalidating the whole arc every round. Discovery
|
|
510
|
+
// refreshes changed heads/bases/states; local readiness is read each round. Neither check authorizes a move:
|
|
511
|
+
// verifyPullRequest repeats both against fresh metadata and the pushed head below.
|
|
512
|
+
if (checkWorkspaces) {
|
|
513
|
+
for (const pr of prs) requireReviewPullRequestProperties(pr.url, pr.head, pr.observed);
|
|
514
|
+
await readTogether(prs, pr => requireReviewWorkspaceHead(board, id, own, pr.observed, pr.head));
|
|
515
|
+
}
|
|
516
|
+
return { pr: prs.map((p) => p.url), pr_head: prs.map((p) => p.head), branch, review_repositories: [...reviewRepositories].sort() };
|
|
512
517
|
}
|
|
513
518
|
|
|
514
519
|
/** An arc that changed no code (a disposition, such as closing a PR with its reason): its work branch holds no commit
|
|
515
520
|
* beyond its repository's default branch, locally or on origin, its checkout holds no uncommitted work, no PR was
|
|
516
521
|
* ever opened from it, and it registered no other branch. Answers what its review reads instead of a diff, or null when the arc carries (or may carry) code. */
|
|
517
|
-
export async function noCodeChange(board, id) {
|
|
522
|
+
export async function noCodeChange(board, id, { fresh = false } = {}) {
|
|
523
|
+
// Once this arc has named code for a review, an empty open set after merging is not a no-code disposition.
|
|
524
|
+
// In particular, do not revalidate the same merged branch's PR history on every advisory round.
|
|
525
|
+
if (candidatePullRequests(reviewCandidate(board, id)).length) return null;
|
|
518
526
|
const taskRow = board.db.prepare('SELECT workspace_kind FROM tasks WHERE id=?').get(id);
|
|
519
527
|
if (taskRow?.workspace_kind === 'scratch') {
|
|
520
528
|
if (board.db.prepare("SELECT 1 FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE c.subtask_of=? AND t.workspace_kind!='scratch' AND t.status!='cancelled' LIMIT 1").get(id)) return null;
|
|
@@ -542,16 +550,22 @@ export async function noCodeChange(board, id) {
|
|
|
542
550
|
const remote = (await run('git', ['ls-remote', 'origin', `refs/heads/${branch}`], own)).split(/\s+/)[0];
|
|
543
551
|
if (remote) await run('git', ['merge-base', '--is-ancestor', remote, base], own);
|
|
544
552
|
const owner = repository.split('/')[0];
|
|
545
|
-
//
|
|
546
|
-
|
|
553
|
+
// Advisory eligibility never spends validation's reserve. The committed disposition repeats this read fresh.
|
|
554
|
+
const path = `/repos/${repository}/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}&per_page=1`;
|
|
555
|
+
const history = await discoveryRead(`no-code:${repository.toLowerCase()}:${branch}`, 900_000, () =>
|
|
556
|
+
github(path, false, { purpose: fresh ? 'validation' : 'discovery' }), { fresh });
|
|
557
|
+
if (history.length) return null;
|
|
547
558
|
return { pr: [], pr_head: [], branch, no_code: { repository, branch, base, branch_head: remote || null } };
|
|
548
559
|
} catch (error) { if (error.code === 'GITHUB_BUDGET') throw error; return null; }
|
|
549
560
|
}
|
|
550
561
|
|
|
551
562
|
/** The repositories this machine keeps a checkout of. */
|
|
552
|
-
async function knownRepositories({
|
|
553
|
-
try {
|
|
554
|
-
|
|
563
|
+
async function knownRepositories({ strict = false } = {}) {
|
|
564
|
+
try {
|
|
565
|
+
const checkouts = await branchCheckouts(null, '*');
|
|
566
|
+
return [...new Set(checkouts.map(row => row.repository))];
|
|
567
|
+
}
|
|
568
|
+
catch (error) { if (strict) throw error; return []; }
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
/** Whether the arc's open PRs (`[{ url, head }]`) call for its review: its last verdict's findings are answered, or
|
|
@@ -612,28 +626,27 @@ export function answeredChanges(board, id) {
|
|
|
612
626
|
return owned.length > 0 && owned.every((t) => ['done', 'cancelled', 'archived'].includes(t.status));
|
|
613
627
|
}
|
|
614
628
|
|
|
615
|
-
const commitReads = new Map();
|
|
616
629
|
/** A commit's date and source tree, read once: both are immutable. Only a successful read is cached; an unread commit
|
|
617
630
|
* is unknown, never evidence of old work or of a changed tree. */
|
|
618
631
|
async function commitRead({ url, head }) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
commitReads.set(key, read);
|
|
626
|
-
return read;
|
|
632
|
+
return discoveryRead(`commit:${repositoryOf(url).toLowerCase()}:${head}`, 3600_000, async () => {
|
|
633
|
+
const commit = (await github(`/repos/${repositoryOf(url)}/commits/${head}`, false, { purpose: actionReads.getStore() ? 'validation' : 'discovery' })).commit;
|
|
634
|
+
const at = Math.floor(Date.parse(commit?.committer?.date) / 1000), tree = commit?.tree?.sha;
|
|
635
|
+
if (!Number.isFinite(at) || typeof tree !== 'string' || !tree) throw new Error(`GitHub commit ${head} of ${url} is unreadable; review eligibility is unknown`);
|
|
636
|
+
return { at, tree };
|
|
637
|
+
}, { cacheErrors: false });
|
|
627
638
|
}
|
|
628
639
|
const commitTime = async (p) => (await commitRead(p)).at;
|
|
629
640
|
/** Whether the PR's head carries the same source tree as `commit` of the same PR. */
|
|
630
641
|
const sameTree = async (p, commit) => (await commitRead(p)).tree === (await commitRead({ url: p.url, head: commit })).tree;
|
|
631
642
|
|
|
632
643
|
/** A cached exclusion must never hide a newly ready/reopened source from a committed review or acceptance. */
|
|
633
|
-
async function requireCurrentReviewSet(board, id, urls) {
|
|
634
|
-
const current = await openReviewPullRequests(board, id, { fresh: true
|
|
644
|
+
async function requireCurrentReviewSet(board, id, urls, repositories = []) {
|
|
645
|
+
const current = await openReviewPullRequests(board, id, { fresh: true,
|
|
646
|
+
repositories: [...repositories, ...urls.map(repositoryOf)] });
|
|
635
647
|
const omitted = (current?.pr ?? []).filter(url => !urls.includes(url));
|
|
636
648
|
if (omitted.length) throw new Error(`The current review source set also includes ${omitted.join(', ')}; refresh the complete candidate before requesting review, recording its verdict or accepting it`);
|
|
649
|
+
return current?.review_repositories ?? repositories;
|
|
637
650
|
}
|
|
638
651
|
|
|
639
652
|
/** The verdict already recorded on `head` of the card's PR: a changes-requested or pass that answered
|
|
@@ -682,9 +695,16 @@ async function inspectCurrentCompletionEvidence(board, id, entry, payload, stage
|
|
|
682
695
|
}
|
|
683
696
|
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
684
697
|
const urls = [].concat(payload.pr ?? payload.metadata?.pr ?? candidatePullRequests(asked).map((c) => c.pr.url)).filter(Boolean);
|
|
685
|
-
if (stage === 'request_review' || stage === 'review_verdict') await requireCurrentReviewSet(board, id, urls);
|
|
686
|
-
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
687
698
|
if (urls.some(url => typeof url !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(url))) throw new Error('Name each GitHub PR URL with --pr.');
|
|
699
|
+
// Advisory heads can refuse a redundant request, never authorize one. Do
|
|
700
|
+
// this before a fresh source scan, including for callers outside dispatch.
|
|
701
|
+
const proposed = [].concat(payload.pr_head ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit ?? []).filter(Boolean);
|
|
702
|
+
if (stage === 'request_review' && urls.length && proposed.length === urls.length && proposed.every(head => typeof head === 'string' && /^[a-f0-9]{40}$/.test(head))
|
|
703
|
+
&& !await reviewDue(board, id, urls.map((url, i) => ({ url, head: proposed[i] })))) {
|
|
704
|
+
throw new Error('Every proposed PR head already has a verdict or carries no new source; no review is due.');
|
|
705
|
+
}
|
|
706
|
+
if (stage === 'request_review' || stage === 'review_verdict') state.review_repositories = await requireCurrentReviewSet(board, id, urls, payload.review_repositories ?? []);
|
|
707
|
+
if (!urls.length && asked?.no_code) return { ...state, no_code: asked.no_code };
|
|
688
708
|
// Merged source is immutable and needs no surviving workspace. Resolve an
|
|
689
709
|
// unmerged candidate's checkout as before; a broken legacy path never
|
|
690
710
|
// prevents a verdict on already merged commits.
|
|
@@ -752,8 +772,8 @@ async function requireReviewWorkspaceHead(board, id, own, observed, head, { push
|
|
|
752
772
|
}
|
|
753
773
|
}
|
|
754
774
|
|
|
755
|
-
/**
|
|
756
|
-
|
|
775
|
+
/** PR-only readiness, shared by advisory refusals and fresh source validation. */
|
|
776
|
+
function requireReviewPullRequestProperties(url, supplied, observed) {
|
|
757
777
|
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
778
|
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
759
779
|
// A PR merged before its review (an outage fix landed first) is still reviewed: its head is fixed for good, so the
|
|
@@ -768,10 +788,17 @@ async function verifyPullRequest(board, id, entry, own, url, supplied, stage, ob
|
|
|
768
788
|
const mainBranch = observed.defaultBranch ?? 'main';
|
|
769
789
|
// A merged hotfix may have landed on another base (prod); a PR from the main branch itself is a release, never arc work.
|
|
770
790
|
if (branch === mainBranch || (!merged && observed.baseRefName !== mainBranch)) throw new Error(`${url} must propose its worktree branch to the repository main branch.`);
|
|
771
|
-
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
772
791
|
return { repository, branch, commit: head, source_sha: head, pr: { url: observed.url, number: observed.number, head, branch, base: observed.baseRefName } };
|
|
773
792
|
}
|
|
774
793
|
|
|
794
|
+
/** One PR of an arc review, read from GitHub and, for a review request, from the card branch's checkout. */
|
|
795
|
+
async function verifyPullRequest(board, id, entry, own, url, supplied, stage, observed) {
|
|
796
|
+
const source = requireReviewPullRequestProperties(url, supplied, observed);
|
|
797
|
+
const head = source.commit;
|
|
798
|
+
if (stage === 'request_review') await requireReviewWorkspaceHead(board, id, own, observed, head, { pushed: true });
|
|
799
|
+
return source;
|
|
800
|
+
}
|
|
801
|
+
|
|
775
802
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
776
803
|
/** Read the tracked workflow revision the next serve tick will load. */
|
|
777
804
|
export async function workflowRevision(root) {
|
package/board/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ 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
20
|
import { redactGuardText as redactText, withCredentialGuard } from './credential-guard.mjs';
|
|
20
21
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
@@ -69,7 +70,7 @@ async function boardPublishing(root) {
|
|
|
69
70
|
const board = listed?.boards?.find((b) => b.root === here);
|
|
70
71
|
return board ? { ...board, refused: listed.refused ?? null } : null;
|
|
71
72
|
}
|
|
72
|
-
import { dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
73
|
+
import { answerQuestion, dispatchingOf, knowLocalMachine, requestAdoption, alertManagers, boardAddress, boardLabel, boardSettings, boardCaller, callerSession, commandEnv, reconcileCallerLaunch, closeRequested, lanesOf, localMachine, observeFleet, paused, plan, startCard, stopDispatching, supercodeBin, tick, deliver, startDelivery } from './dispatch.mjs';
|
|
73
74
|
import { watchBoard } from './wake.mjs';
|
|
74
75
|
import { attachSideSession, releaseSideSession, sideArcs, sideSessions } from './side-sessions.mjs';
|
|
75
76
|
import { onPanesChanged, onRecordsChanged, readFleetRecords, fleetRecordsCurrent } from './machine-fleet.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
|
|
@@ -142,6 +144,8 @@ And:
|
|
|
142
144
|
With a pane lane and room, a card starts at once and answers "started" with its session; else it answers why it
|
|
143
145
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
144
146
|
adopt <id> <session> [--assignee P] [--role ROLE] adopt <arc> <session> --side · release <arc> <session> detach <id> [--to person:ID] close <id> serve (= daemon) list --machine M
|
|
147
|
+
answer <id> <option> choose an option (its number or label) of the question the card's session shows on its pane: the
|
|
148
|
+
card's manager, its creator or a signed-in person; recorded on the card as question_answered with who answered
|
|
145
149
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
146
150
|
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
147
151
|
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
@@ -662,6 +666,13 @@ export async function runVerb(argv) {
|
|
|
662
666
|
if (t.machine) out.push(` machine: ${t.machine}`);
|
|
663
667
|
if (t.reviewer) out.push(` reviewer: ${t.reviewer} (card override)`);
|
|
664
668
|
if (t.detached) out.push(` session: ${t.detached.session.address} belongs to ${t.detached.person} (detached ${ts(t.detached.at)}; dispatcher does not own it)`);
|
|
669
|
+
// the question the card's session shows on its pane, while it shows (VO-3395), with the door that answers it
|
|
670
|
+
const asked = card.runs.find((r) => r.id === t.current_run_id)?.metadata?.supercode?.question;
|
|
671
|
+
if (asked?.options?.length) {
|
|
672
|
+
out.push(` question: its session asks on pane ${asked.pane ?? '?'}${asked.machine ? ` on ${asked.machine}` : ''} (since ${ts(asked.since)}): ${asked.question || '(no words above its options)'}`);
|
|
673
|
+
for (const option of asked.options) out.push(` ${String(option.number).padStart(2)}. ${option.label}${option.detail ? ` (${option.detail})` : ''}`);
|
|
674
|
+
out.push(` answer: supercode workflow answer ${t.id} <number>`);
|
|
675
|
+
}
|
|
665
676
|
// where the card's time went: each role's runs, and the rest waiting
|
|
666
677
|
const mins = (sec) => `${Math.round(sec / 60)}m`;
|
|
667
678
|
out.push(` time: ${Object.entries(card.time).filter(([, v]) => v > 0).map(([k, v]) => `${k} ${mins(v)}`).join(', ') || '-'}`);
|
|
@@ -833,6 +844,15 @@ export async function runVerb(argv) {
|
|
|
833
844
|
}
|
|
834
845
|
case 'attach': {
|
|
835
846
|
need(2, 'a card id and a file');
|
|
847
|
+
if (args.upload !== undefined) {
|
|
848
|
+
if (rest[1] !== '-') throw new Error('attachment upload reads its carried stdin');
|
|
849
|
+
withBoard(b => { if (!engine.getTask(b.db, id)) throw new Error(`no card ${id}`); });
|
|
850
|
+
const directory = join(slug === DEFAULT_BOARD ? join(root, 'kanban', 'attachments') : join(boardDir(root, slug), 'attachments'), id);
|
|
851
|
+
const result = receiveAttachment({ directory, upload: args.upload, caller: callerAddress() ?? actorOf(), input: current().stdin,
|
|
852
|
+
add: (file, name) => withBoard(b => engine.addAttachment(b, id, { file, name, contentType: args['content-type'] ?? null, author: args.author ?? callerName() })) });
|
|
853
|
+
say(args, result.id ? `Attached ${result.name} to ${id} (#${result.id}, sha256 ${result.sha256})` : `Upload ${args.upload}: ${result.received ?? 'aborted'}`, result);
|
|
854
|
+
return 0;
|
|
855
|
+
}
|
|
836
856
|
const source = callerPath(rest[1]);
|
|
837
857
|
if (!existsSync(source) || !statSync(source).isFile()) throw new Error(`no file ${rest[1]}`);
|
|
838
858
|
const name = basename(args.name ?? source).replace(/[^A-Za-z0-9._ -]/g, '_') || 'attachment';
|
|
@@ -1252,6 +1272,22 @@ export async function runVerb(argv) {
|
|
|
1252
1272
|
say(args, `${id}: ${detached.session.address} belongs to ${detached.person}; the dispatcher no longer owns it`, { ok: true, detached });
|
|
1253
1273
|
return 0;
|
|
1254
1274
|
}
|
|
1275
|
+
case 'answer': {
|
|
1276
|
+
// the question the card's session shows on its pane, answered through its machine's panes.answer door as this
|
|
1277
|
+
// board (VO-3395); the card's manager, its creator or a signed-in person answers, and is recorded as having answered
|
|
1278
|
+
need(2, 'a card id and the option to choose (its number or its label)');
|
|
1279
|
+
withBoard(b => {
|
|
1280
|
+
const roles = rolesFor(b.db, id, sid, workflowFor(root).params);
|
|
1281
|
+
if (!roles.includes('manager') && !roles.includes('creator') && !(!sid && principalOf()?.startsWith('person:')))
|
|
1282
|
+
throw new Error(`Only ${id}'s manager, its creator or a signed-in person answers its session's question. Nothing was typed.`);
|
|
1283
|
+
});
|
|
1284
|
+
const by = sid ? callerAddress() ?? actorOf() : principalOf() ?? actorOf();
|
|
1285
|
+
const answered = await withBoardAsync((b) => answerQuestion(b, id, rest.slice(1).join(' '), { by }));
|
|
1286
|
+
const choice = answered.choice ? `${answered.choice.number}. ${answered.choice.label}` : rest.slice(1).join(' ');
|
|
1287
|
+
say(args, answered.answered ? `${id}: answered "${choice}" on pane ${answered.pane} (recorded as question_answered, by ${by})`
|
|
1288
|
+
: `${id}: chose "${choice}" on pane ${answered.pane}, but ${answered.reason ?? 'the pane did not show it taken'}`, { ok: answered.answered === true, answer: answered });
|
|
1289
|
+
return answered.answered ? 0 : 1;
|
|
1290
|
+
}
|
|
1255
1291
|
case 'adopt': {
|
|
1256
1292
|
need(2, 'a card id and a session');
|
|
1257
1293
|
withBoard(b => { if (!rolesFor(b.db, id, sid, workflowFor(root).params).includes('manager')) throw new Error('Only the manager adopts an existing session.'); });
|