@volter/supercode-orchestrator 0.4.146 → 0.4.148
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/board/ci.mjs +104 -108
- package/board/cli.mjs +42 -17
- package/board/dispatch.mjs +141 -30
- package/board/engine.mjs +84 -16
- package/board/notices.mjs +5 -0
- package/board/request-mail.mjs +59 -0
- package/board/tools.mjs +13 -7
- package/board/workflow.mjs +20 -5
- package/board/ztrack.mjs +30 -4
- package/mcp/hermes-kanban-tools.json +50 -10
- package/package.json +2 -2
package/board/ci.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
// process runs inside the optimistic transaction
|
|
1
|
+
// Review candidates are observed before a board mutation. No network or Git
|
|
2
|
+
// process runs inside the optimistic transaction. Deployment is not a completion gate.
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync, realpathSync, writeFileSync, renameSync
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
|
|
5
5
|
import { dirname, join, relative } from 'node:path';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { pathToFileURL } from 'node:url';
|
|
8
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { resolveWorkspace } from './workspace.mjs';
|
|
9
8
|
|
|
10
9
|
const observations = new Map();
|
|
11
10
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
@@ -14,14 +13,19 @@ const run = (program, args, cwd) => {
|
|
|
14
13
|
if (result.status !== 0) throw new Error(`${program}: ${(result.stderr || result.error?.message || 'command failed').trim().slice(-500)}`);
|
|
15
14
|
return result.stdout.trim();
|
|
16
15
|
};
|
|
17
|
-
const codePath = path => path && !/^(?:docs\/|\.volter\/tracker\/)/.test(path) && (!/\.(?:md|txt|png|jpg|svg)$/i.test(path) || /(?:^|\/)prompts\//.test(path));
|
|
18
16
|
|
|
19
17
|
export function completionEvidence(board, id) {
|
|
20
|
-
return observations.get(key(board, id)) ?? { required:
|
|
18
|
+
return observations.get(key(board, id)) ?? { required: false, ready: true, phase: 'source', deployed_sha: null };
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
export function
|
|
24
|
-
|
|
21
|
+
export function completionPhase(board, id, entry, verb, payload, roles = []) {
|
|
22
|
+
if (verb === 'request_review') return 'request_review';
|
|
23
|
+
if ((verb === 'request_state' && payload.state === 'done' || verb === 'complete') && roles.includes('reviewer')) return 'review_verdict';
|
|
24
|
+
return verb === 'complete' ? 'acceptance' : 'completion';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function observeCompletionEvidence(board, id, entry, payload = {}, phase = 'completion') {
|
|
28
|
+
const state = inspectCompletionEvidence(board, id, entry, payload, phase);
|
|
25
29
|
observations.set(key(board, id), state);
|
|
26
30
|
return state;
|
|
27
31
|
}
|
|
@@ -37,111 +41,103 @@ export function withCompletionEvidence(board, id, state, fn) {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
/** The latest explicitly requested PR is the reviewer's candidate, never the deployment tip. */
|
|
45
|
+
export function reviewCandidate(board, id) {
|
|
46
|
+
const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_requested' ORDER BY id DESC LIMIT 1").get(id);
|
|
47
|
+
const payload = row && JSON.parse(row.payload);
|
|
48
|
+
return payload?.ci?.pr ? payload.ci : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function repositoryWorkspace(board, id) {
|
|
52
|
+
const task = board.db.prepare('SELECT t.*,c.subtask_of,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
53
|
+
const parent = task?.subtask_of && board.db.prepare('SELECT t.*,c.workspace_anchor,c.machine FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(task.subtask_of);
|
|
54
|
+
const workspace = task?.workspace_kind === 'scratch' ? parent : task;
|
|
55
|
+
return workspace?.workspace_kind !== 'scratch' && workspace?.workspace_anchor ? resolveWorkspace(workspace, false).path : workspace?.workspace_kind !== 'scratch' ? workspace?.workspace_path : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pullRequestLists = new Map();
|
|
59
|
+
|
|
60
|
+
/** Find an opened PR for this card's declared worktree branch; caller decides the transition. */
|
|
61
|
+
export function openReviewPullRequest(board, id) {
|
|
62
|
+
const path = repositoryWorkspace(board, id);
|
|
63
|
+
if (!path) return null;
|
|
64
|
+
const task = board.db.prepare('SELECT branch_name FROM tasks WHERE id=?').get(id);
|
|
65
|
+
const branch = task?.branch_name ?? run('git', ['branch', '--show-current'], path);
|
|
66
|
+
if (!branch) return null;
|
|
67
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
|
|
68
|
+
if (!repository) return null;
|
|
69
|
+
// Discovery is shared per repository for one minute; the selected PR/head is
|
|
70
|
+
// always re-read by inspectCompletionEvidence before it authorizes a move.
|
|
71
|
+
let list = pullRequestLists.get(repository);
|
|
72
|
+
if (!list || Date.now() - list.at >= 60_000) {
|
|
73
|
+
list = { at: Date.now(), items: JSON.parse(run('gh', ['pr', 'list', '--repo', repository, '--state', 'open', '--limit', '1000', '--json', 'url,headRefOid,headRefName,baseRefName,isCrossRepository'], path)) };
|
|
74
|
+
if (pullRequestLists.size >= 32) pullRequestLists.delete(pullRequestLists.keys().next().value);
|
|
75
|
+
pullRequestLists.set(repository, list);
|
|
76
|
+
}
|
|
77
|
+
const prs = list.items.filter(pr => pr.headRefName === branch && !pr.isCrossRepository);
|
|
78
|
+
if (prs.length !== 1) return null;
|
|
79
|
+
return { pr: prs[0].url, pr_head: prs[0].headRefOid, commit: prs[0].headRefOid, branch };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function inspectCompletionEvidence(board, id, entry, payload = {}, stage = 'completion') {
|
|
83
|
+
const state = { required: false, ready: true, reason: null, phase: 'source', deployed_sha: null };
|
|
43
84
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
|
|
62
|
-
if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
|
|
63
|
-
Object.assign(state, { commit, repository: repo });
|
|
64
|
-
const controller = entry.params.completion_ci.controller_deployments?.[repo];
|
|
65
|
-
if (controller) {
|
|
66
|
-
const expand = value => value.startsWith('~/') ? join(homedir(), value.slice(2)) : value;
|
|
67
|
-
const installed = expand(controller.installed_path);
|
|
68
|
-
if (!lstatSync(installed).isSymbolicLink()) throw new Error('The controller installation must be the declared checkout symlink.');
|
|
69
|
-
const skill = realpathSync(installed), checkout = run('git', ['rev-parse', '--show-toplevel'], skill);
|
|
70
|
-
const origin = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
|
|
71
|
-
if (origin !== repo) throw new Error('The controller installation points to another repository.');
|
|
72
|
-
const head = run('git', ['rev-parse', 'HEAD'], checkout);
|
|
73
|
-
run('git', ['merge-base', '--is-ancestor', commit, head], checkout);
|
|
74
|
-
run('git', ['diff', '--quiet', 'HEAD', '--', relative(checkout, join(skill, 'scripts'))], checkout);
|
|
75
|
-
const sourceHash = run(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e',
|
|
76
|
-
'const m=await import(process.argv[1]); process.stdout.write(m.installedSourceHash())', pathToFileURL(join(skill, 'scripts/controller-diagnostics.ts')).href], checkout);
|
|
77
|
-
const port = Number(readFileSync(expand(controller.port_file), 'utf8').trim());
|
|
78
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('The controller has no valid retained port.');
|
|
79
|
-
const url = `http://127.0.0.1:${port}/health`;
|
|
80
|
-
const health = JSON.parse(run('curl', ['--fail', '--silent', '--show-error', '--max-time', '2', url], checkout));
|
|
81
|
-
if (!health.alive || !health.cdp_connected || health.restart_required !== false || health.controller?.source_hash !== sourceHash || health.installed_source_hash !== sourceHash) throw new Error('The running browser controller has not loaded the committed installed code.');
|
|
82
|
-
Object.assign(state, { ready: true, workflow: 'installed browser controller', run_id: `${health.controller.pid}@${health.controller.started_at}`, status: 'completed', conclusion: 'success', deployed_sha: head, url, source_hash: sourceHash });
|
|
85
|
+
const path = repositoryWorkspace(board, id);
|
|
86
|
+
if (stage === 'completion' || stage === 'acceptance') {
|
|
87
|
+
const supplied = payload.commit ?? payload.metadata?.commit;
|
|
88
|
+
if (supplied) {
|
|
89
|
+
if (!path) throw new Error('The named commit needs a resolved repository workspace.');
|
|
90
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
|
|
91
|
+
Object.assign(state, { repository, commit: run('git', ['rev-parse', '--verify', `${supplied}^{commit}`], path) });
|
|
92
|
+
}
|
|
93
|
+
const candidate = stage === 'acceptance' ? reviewCandidate(board, id) : null;
|
|
94
|
+
if (candidate) {
|
|
95
|
+
const row = board.db.prepare("SELECT payload FROM task_events WHERE task_id=? AND kind='review_pass' ORDER BY id DESC LIMIT 1").get(id);
|
|
96
|
+
const pass = row && JSON.parse(row.payload)?.ci;
|
|
97
|
+
if (pass?.commit !== candidate.commit || pass?.pr?.url !== candidate.pr.url) throw new Error('The current PR head needs its independent verdict before acceptance.');
|
|
98
|
+
const pr = JSON.parse(run('gh', ['pr', 'view', candidate.pr.url, '--repo', candidate.repository, '--json', 'state,headRefOid'], path));
|
|
99
|
+
if (!['OPEN', 'MERGED'].includes(pr.state) || pr.headRefOid !== candidate.commit) throw new Error('The PR head changed after its verdict; request a new review.');
|
|
100
|
+
Object.assign(state, { ...candidate, ready: true, reason: null });
|
|
101
|
+
}
|
|
83
102
|
return state;
|
|
84
103
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
run('git', ['merge-base', '--is-ancestor', commit, receipt.head], receipt.checkout);
|
|
90
|
-
const current = workflowRevision(board.root);
|
|
91
|
-
if (!current || current.head !== receipt.head || current.workflow_hash !== receipt.workflow_hash) throw new Error('The retained keeper tick does not describe the live checkout and loaded workflow revision.');
|
|
92
|
-
Object.assign(state, { ready: true, workflow: 'launchd keeper', run_id: `${receipt.pid}@${receipt.completed_at}`, status: 'completed', conclusion: 'success', deployed_sha: receipt.head, url: receiptPath });
|
|
104
|
+
const asked = stage === 'review_verdict' ? reviewCandidate(board, id) : null;
|
|
105
|
+
const pr = payload.pr ?? payload.metadata?.pr ?? asked?.pr?.url;
|
|
106
|
+
if (!path) {
|
|
107
|
+
if (pr) throw new Error('A PR candidate needs a resolved repository workspace.');
|
|
93
108
|
return state;
|
|
94
109
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
const successful = rule.workflows.flatMap(workflow => deployments.get(workflow)).filter(row => row.status === 'completed' && row.conclusion === 'success');
|
|
124
|
-
const covering = successful.find(row => {
|
|
125
|
-
if (!rule.workflows.includes(row.workflowName) || !configured.includes(row.workflowName)) return false;
|
|
126
|
-
try {
|
|
127
|
-
run('git', ['merge-base', '--is-ancestor', rule.changed_at, row.headSha], path);
|
|
128
|
-
try {
|
|
129
|
-
run('git', ['merge-base', '--is-ancestor', commit, row.headSha], path);
|
|
130
|
-
return true;
|
|
131
|
-
} catch {}
|
|
132
|
-
run('git', ['merge-base', '--is-ancestor', row.headSha, commit], path);
|
|
133
|
-
// Any later change in these paths requires another native deployment,
|
|
134
|
-
// even when a different task authored that change.
|
|
135
|
-
return run('git', ['diff', '--name-only', row.headSha, commit], path).split('\n').every(name => !rule.paths.some(prefix => name.startsWith(prefix)));
|
|
136
|
-
} catch { return false; }
|
|
137
|
-
});
|
|
138
|
-
if (!covering) throw new Error(`No successful ${rule.workflows.join('/')} deployment covers ${rule.paths.join(', ')} through ${commit}.`);
|
|
139
|
-
state.coverage.push({ paths: rule.paths, commit: covering.headSha, workflow: covering.workflowName, run_id: covering.databaseId, conclusion: covering.conclusion, url: covering.url });
|
|
110
|
+
// Repository-free review-only jobs keep their own verdict contract. Ordinary
|
|
111
|
+
// implementation reviews always name an opened PR, independent of completion_ci.
|
|
112
|
+
if (stage === 'review_verdict' && !asked && !pr) return state;
|
|
113
|
+
Object.assign(state, { required: true, ready: false });
|
|
114
|
+
if (typeof pr !== 'string' || !/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/[1-9][0-9]*\/?$/.test(pr)) throw new Error('Name the opened PR URL with --pr.');
|
|
115
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], path))?.[1];
|
|
116
|
+
if (!repository || new URL(pr).pathname.split('/').slice(1, 3).join('/') !== repository) throw new Error('The PR must belong to this workspace repository.');
|
|
117
|
+
const observed = JSON.parse(run('gh', ['pr', 'view', pr, '--repo', repository, '--json', 'url,number,state,headRefOid,headRefName,baseRefName,isCrossRepository'], path));
|
|
118
|
+
if (observed.state !== 'OPEN' || observed.isCrossRepository) throw new Error('Source review needs an open PR from this repository.');
|
|
119
|
+
const supplied = payload.pr_head ?? payload.reviewed_sha ?? payload.commit ?? payload.metadata?.pr_head ?? payload.metadata?.commit;
|
|
120
|
+
if (!supplied || !/^[a-f0-9]{40}$/.test(supplied)) throw new Error('Name the full PR head commit with --pr-head (reviewer: --reviewed-sha).');
|
|
121
|
+
if (observed.headRefOid !== supplied) throw new Error(`PR head changed to ${observed.headRefOid}; request review of that head before recording a verdict.`);
|
|
122
|
+
const branch = payload.branch ?? payload.metadata?.branch ?? observed.headRefName;
|
|
123
|
+
const mainBranch = entry.params.completion_ci?.main_branches?.[repository] ?? 'main';
|
|
124
|
+
if (branch !== observed.headRefName || branch === mainBranch || observed.baseRefName !== mainBranch) throw new Error('The PR must propose its named worktree branch to the repository main branch.');
|
|
125
|
+
if (stage === 'request_review') {
|
|
126
|
+
run('git', ['check-ref-format', '--branch', branch], path);
|
|
127
|
+
if (run('git', ['rev-parse', 'HEAD'], path) !== supplied) throw new Error('The named PR head must be this worktree head.');
|
|
128
|
+
run('git', ['diff', '--quiet', 'HEAD'], path);
|
|
129
|
+
if (run('git', ['ls-files', '--others', '--exclude-standard'], path)) throw new Error('Commit the untracked work before requesting review.');
|
|
130
|
+
const pushed = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], path).split(/\s+/)[0];
|
|
131
|
+
if (pushed !== supplied) throw new Error('Push the exact PR head before requesting review.');
|
|
132
|
+
} else if (asked) {
|
|
133
|
+
if (asked.commit !== supplied || asked.pr.url !== observed.url) throw new Error('The verdict must name the requested PR and head.');
|
|
134
|
+
const latest = board.db.prepare('SELECT metadata FROM task_runs WHERE task_id=? ORDER BY id DESC LIMIT 1').get(id);
|
|
135
|
+
const bound = latest && JSON.parse(latest.metadata ?? '{}')?.supercode?.review_candidate;
|
|
136
|
+
if (bound && (bound.commit !== supplied || bound.pr?.url !== observed.url)) throw new Error('This reviewer run was launched for a different PR head.');
|
|
140
137
|
}
|
|
141
|
-
state
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
} catch (error) { state.reason = error.message; }
|
|
138
|
+
Object.assign(state, { ready: true, repository, branch, commit: supplied, source_sha: supplied,
|
|
139
|
+
pr: { url: observed.url, number: observed.number, head: supplied, branch, base: observed.baseRefName }, reason: null });
|
|
140
|
+
} catch (error) { Object.assign(state, { required: true, ready: false, reason: error.message }); }
|
|
145
141
|
return state;
|
|
146
142
|
}
|
|
147
143
|
|
package/board/cli.mjs
CHANGED
|
@@ -36,9 +36,10 @@ Hermes Kanban's verbs, with their flags:
|
|
|
36
36
|
block <id> [reason] (--on <card> | --waiting-on <message id>) [--ids ...] schedule <id> [reason] [--ids ...]
|
|
37
37
|
pause <id> --reason <why> resume <id> cancel <id> set-every <id> <interval|none>
|
|
38
38
|
request-state <id> --state block|pause|done|cancelled --reason <why> [--on <card>|--waiting-on <message>] [--evidence <pointers>]
|
|
39
|
-
unblock <id>... [--reason] request-review <id> [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
|
|
39
|
+
unblock <id>... [--reason] request-review <id> --pr URL --pr-head SHA [--summary] [--reviewer] [--force] request-changes <id> <reason> reopen-review <id>... reopen <id>... [--reason]
|
|
40
40
|
promote <id> [reason] [--ids ...] [--force] [--dry-run] archive <id>... [--rm <id>...]
|
|
41
|
-
specify <id> [--title --body] [--
|
|
41
|
+
specify <id> [--title --body] [--workspace scratch|worktree:<repository>|dir:<repository>] [--branch NAME] [--all]
|
|
42
|
+
decompose <id> [--child "<title>[@<profile>]"]... [--all]
|
|
42
43
|
diagnostics [--task] [--severity] stats assignees log <id> [--tail N] tail <id> watch [--assignee] [--kinds]
|
|
43
44
|
notify-subscribe <id> --platform P --chat-id C [--thread-id] notify-list [<id>] notify-unsubscribe <id> --platform P --chat-id C
|
|
44
45
|
dispatch [--dry-run] [--max N] [--failure-limit N] daemon [--interval S] [--max N] [--failure-limit N] [--pidfile F] [--verbose] gc [--event-retention-days] [--log-retention-days] repair
|
|
@@ -52,6 +53,8 @@ supercode's own:
|
|
|
52
53
|
waits, and its creator is told when it starts. A subtask is worked by its arc's session and never starts alone.
|
|
53
54
|
move <id> <machine|none> adopt <id> <session> [--assignee P] [--role ROLE] close <id> serve (= daemon) list --machine M
|
|
54
55
|
notify-subscribe <id> --address sc:<machine>:<harness>:<id> [--role creator|worker|dependency]
|
|
56
|
+
notify-subscribe <dependency> --for-card <your-arc>: subscribe only your arc's session to dependency notices.
|
|
57
|
+
notify-unsubscribe <dependency> --for-card <your-arc>: remove that arc's subscription only.
|
|
55
58
|
Any verb … --at sc:<machine>:board:<home>: sent as mail to a board kept on another machine.
|
|
56
59
|
<event> <id>... [reason] [--<field> value]: any other event the board's workflow declares, its flags the event's fields.
|
|
57
60
|
start <id> release a --no-start hold and start through the declared workflow
|
|
@@ -59,6 +62,11 @@ supercode's own:
|
|
|
59
62
|
goto <id> <status> [reason] [--<field> value]: the event the workflow says takes the card to that status, sent.
|
|
60
63
|
|
|
61
64
|
specify <id> --review-only|--no-review-only: a manager declares a standalone independent review job.
|
|
65
|
+
specify <id> --workspace dir:<repository> --branch NAME: the manager retargets a stopped card without replacing its history.
|
|
66
|
+
request-review <id> --pr URL --pr-head SHA [--branch NAME]: hand over an open PR at its pushed head.
|
|
67
|
+
request-state <id> --state done --reviewed-sha SHA: record the verdict for the requested PR head.
|
|
68
|
+
request-state <id>... --state STATE --reason REASON: request several decisions in one retained manager mail.
|
|
69
|
+
complete <id>: creator/manager accepts the independently reviewed arc; deployment CI is not a board gate.
|
|
62
70
|
specify/decompose with no spec start a session on the triage card to write it. A card's own session may comment on,
|
|
63
71
|
heartbeat and request review of its card, specify or decompose it in triage, and work its subtasks. The home's
|
|
64
72
|
workflow declares who may block, pause or finish a card; request-state mails its manager without changing state.
|
|
@@ -544,7 +552,7 @@ export async function runWorkflow(argv) {
|
|
|
544
552
|
case 'complete': {
|
|
545
553
|
need(1, 'a card id');
|
|
546
554
|
const metadata = metadataOf(args.metadata);
|
|
547
|
-
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null }));
|
|
555
|
+
for (const t of rest) await withBoardAsync((b) => engine.complete(b, t, { summary: args.summary ?? null, result: args.result ?? null, metadata: metadata ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? null, reviewed_sha: args['reviewed-sha'] ?? null }));
|
|
548
556
|
say(args, rest.map((t) => `Completed ${t}`).join('\n'), { ok: true, completed: rest }); return 0;
|
|
549
557
|
}
|
|
550
558
|
case 'edit': {
|
|
@@ -574,7 +582,7 @@ export async function runWorkflow(argv) {
|
|
|
574
582
|
if (args.reason) for (const [t] of results) await withBoardAsync((b) => engine.comment(b, t, callerName(), `UNBLOCK: ${args.reason}`));
|
|
575
583
|
say(args, results.map(([t, s]) => `Unblocked ${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
576
584
|
}
|
|
577
|
-
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
585
|
+
case 'request-review': need(1, 'a card id'); await withBoardAsync((b) => engine.requestReview(b, id, { summary: args.summary ?? null, reviewer: args.reviewer ?? null, metadata: metadataOf(args.metadata) ?? null, commit: args.commit ?? null, ci_run: args['ci-run'] ?? null, branch: args.branch ?? null, pr: args.pr ?? null, pr_head: args['pr-head'] ?? args.head ?? args.commit ?? null, force: Boolean(args.force), expectedRunId: workerRun(b.db, id, sid) })); say(args, `${id} → review`, { ok: true }); return 0;
|
|
578
586
|
case 'request-changes': need(2, 'a card id and the reason'); await withBoardAsync((b) => engine.requestChanges(b, id, { reason: rest.slice(1).join(' '), reviewer: args.reviewer ?? null })); say(args, `${id} sent back for changes`, { ok: true }); return 0;
|
|
579
587
|
case 'reopen': {
|
|
580
588
|
// a done card back to work; the cards that built on it are invalidated with it (Hermes's reopen)
|
|
@@ -629,7 +637,7 @@ export async function runWorkflow(argv) {
|
|
|
629
637
|
if (!targets[0]) throw new Error(`${verb} needs a card id, or --all`);
|
|
630
638
|
const results = [];
|
|
631
639
|
for (const t of targets) {
|
|
632
|
-
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args['review-only'] || args['no-review-only'])) {
|
|
640
|
+
if (verb === 'specify' && (args.title !== undefined || args.body !== undefined || args.workspace !== undefined || args.branch !== undefined || args['review-only'] || args['no-review-only'])) {
|
|
633
641
|
const moved = await withBoardAsync((b) => engine.specify(b, t, { title: args.title ?? null, body: args.body ?? null, workspace: args.workspace ?? null, branch: args.branch ?? null, reviewOnly: args['review-only'] ? true : args['no-review-only'] ? false : null, author: args.author ?? callerAddress() ?? callerName() }));
|
|
634
642
|
results.push({ id: t, specified: true, status: moved.to });
|
|
635
643
|
} else if (verb === 'decompose' && args.child?.length) {
|
|
@@ -711,6 +719,12 @@ export async function runWorkflow(argv) {
|
|
|
711
719
|
|
|
712
720
|
case 'notify-subscribe': case 'notify-unsubscribe': {
|
|
713
721
|
need(1, 'a card id');
|
|
722
|
+
if (args['for-card']) {
|
|
723
|
+
if (args.address || args['chat-id'] || args['thread-id'] || (args.platform && args.platform !== 'supercode') || (args.role && args.role !== 'dependency')) throw new Error('--for-card targets that arc\'s session; do not supply another recipient or audience.');
|
|
724
|
+
const subscription = withBoard(board => engine.subscribeDependency(board, args['for-card'], id, { remove: verb === 'notify-unsubscribe' }));
|
|
725
|
+
say(args, verb === 'notify-unsubscribe' ? `Removed ${args['for-card']}'s dependency subscription to ${id}` : `${args['for-card']} hears ${id} through ${subscription.address}`, { ok: true, ...subscription });
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
714
728
|
const platform = args.platform ?? 'supercode';
|
|
715
729
|
const chat = args['chat-id'] ?? args.address ?? (platform === 'supercode' ? callerAddress() : null);
|
|
716
730
|
if (!chat) throw new Error(`${verb} names the subscriber: --platform P --chat-id C (a supercode session's address with --platform supercode)`);
|
|
@@ -863,9 +877,10 @@ export async function runWorkflow(argv) {
|
|
|
863
877
|
if (!declared) throw new Error(`no workflow verb ${verb}; \`supercode workflow help\` lists them`);
|
|
864
878
|
need(1, 'a card id');
|
|
865
879
|
const { _: _words, root: _r, board: _b, json: _j, at: _a, ids: _i, ...fields } = args;
|
|
866
|
-
const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (rest.slice(1).join(' ') || null) };
|
|
880
|
+
const payload = { ...fields, ci_run: args['ci-run'] ?? fields.ci_run, deployed_sha: args['deployed-sha'] ?? fields.deployed_sha, reviewed_sha: args['reviewed-sha'] ?? fields.reviewed_sha, pr_head: args['pr-head'] ?? fields.pr_head, waiting_on: args['waiting-on'] ?? fields.waiting_on, reason: args.reason ?? (name === 'request_state' ? null : rest.slice(1).join(' ') || null) };
|
|
867
881
|
const results = [];
|
|
868
|
-
|
|
882
|
+
if (name === 'request_state') results.push(...await withBoardAsync(board => engine.requestStateMany(board, [...rest, ...(args.ids ?? [])], payload)));
|
|
883
|
+
else for (const t of [id, ...(args.ids ?? [])]) results.push([t, (await withBoardAsync((b) => engine.act(b, t, name, { payload }))).to]);
|
|
869
884
|
say(args, results.map(([t, s]) => name === 'request_state' ? `${t}: ${payload.state} requested; state remains ${s}. Mail delivery is recorded on the card.` : `${t} → ${s}`).join('\n'), { ok: true, results }); return 0;
|
|
870
885
|
}
|
|
871
886
|
}
|
|
@@ -1101,7 +1116,11 @@ function rolesFor(db, cardId, sessionId, params = {}) {
|
|
|
1101
1116
|
const current = task.current_run_id ? json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(task.current_run_id)?.metadata, {})?.supercode : null;
|
|
1102
1117
|
if (current?.session_id === sessionId) roles.push(current.role ?? 'implementer');
|
|
1103
1118
|
else if (engine.lastSession(db, cardId)?.session_id === sessionId) roles.push('implementer');
|
|
1104
|
-
if (task.subtask_of
|
|
1119
|
+
if (task.subtask_of) {
|
|
1120
|
+
const parent = engine.getTask(db, task.subtask_of);
|
|
1121
|
+
const worker = parent?.current_run_id ? engine.currentRun(db, parent.id)?.metadata?.supercode : engine.lastSession(db, task.subtask_of);
|
|
1122
|
+
if (worker?.session_id === sessionId) roles.push('arc');
|
|
1123
|
+
}
|
|
1105
1124
|
return roles;
|
|
1106
1125
|
}
|
|
1107
1126
|
|
|
@@ -1116,14 +1135,18 @@ function authorize(db, verb, rest, args, own) {
|
|
|
1116
1135
|
// (what a session may do to a card is its workflow's: each transition's `by`)
|
|
1117
1136
|
if ((['pause', 'resume'].includes(verb) && !rest[0]) || ['swarm', 'dispatch', 'daemon', 'serve', 'gc', 'repair', 'adopt', 'close', 'move', 'claim', 'attach-rm', 'migrate'].includes(verb)) refuse(`does not ${verb}; the fleet's manager does`);
|
|
1118
1137
|
if (args.all) refuse(`does not ${verb} every card; only its own`);
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1138
|
+
if (['notify-subscribe', 'notify-unsubscribe'].includes(verb) && args['for-card']) {
|
|
1139
|
+
if (args['for-card'] !== own.id) refuse(`subscribes only its own arc (--for-card ${own.id})`);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const targets = verb === 'request-state' ? [...rest, ...(args.ids ?? [])] : [rest[0], ...(args.ids ?? [])];
|
|
1143
|
+
for (const target of targets) {
|
|
1144
|
+
const card = engine.getTask(db, target);
|
|
1145
|
+
if (!card) continue;
|
|
1146
|
+
if (completionOwnerOf(db, card.id) === actorOf()) continue;
|
|
1147
|
+
if (verb === 'comment' && isLatestReviewer(db, card.id, actorOf())) continue;
|
|
1148
|
+
if (card.id !== own.id && card.subtask_of !== own.id) refuse(`writes only its own card and its subtasks, not ${card.id}`);
|
|
1149
|
+
}
|
|
1127
1150
|
}
|
|
1128
1151
|
|
|
1129
1152
|
/** Close the live sessions of a card that is not running (its worker's and its reviewers'), freeing its machine's place. */
|
|
@@ -1202,6 +1225,7 @@ async function serve(root, args = {}) {
|
|
|
1202
1225
|
}
|
|
1203
1226
|
pausedSaid = Boolean(paused(root)) && pausedSaid;
|
|
1204
1227
|
let interval = 60;
|
|
1228
|
+
let nextMailAt = Infinity;
|
|
1205
1229
|
const began = Date.now();
|
|
1206
1230
|
if (wakeReason) log(`dispatch wake ${wakeReason.sources.join(', ')}; event age ${began - wakeReason.at}ms`);
|
|
1207
1231
|
wakeReason = null;
|
|
@@ -1210,6 +1234,7 @@ async function serve(root, args = {}) {
|
|
|
1210
1234
|
const settings = { ...state.settings, ...override };
|
|
1211
1235
|
interval = settings.interval;
|
|
1212
1236
|
for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
|
|
1237
|
+
nextMailAt = Math.min(nextMailAt, r.next_mail_at ?? Infinity);
|
|
1213
1238
|
const said = summary(r);
|
|
1214
1239
|
if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
|
|
1215
1240
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
@@ -1233,7 +1258,7 @@ async function serve(root, args = {}) {
|
|
|
1233
1258
|
// the next tick is due an interval after this one began (a long tick is not followed by a full interval's wait)
|
|
1234
1259
|
events.refresh();
|
|
1235
1260
|
if (!stopped && !moved) {
|
|
1236
|
-
wakeReason = await events.wait(Math.max(0, interval * 1000 - (Date.now() - began)));
|
|
1261
|
+
wakeReason = await events.wait(Math.max(0, Math.min(interval * 1000 - (Date.now() - began), nextMailAt - Date.now())));
|
|
1237
1262
|
// A busy event stream is coalesced to at most one tick per second.
|
|
1238
1263
|
const remaining = 1000 - (Date.now() - began);
|
|
1239
1264
|
if (remaining > 0 && !stopped) await new Promise(done => setTimeout(done, remaining));
|