@volter/supercode-orchestrator 0.4.131 → 0.4.133
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 +42 -0
- package/board/cli.mjs +3 -1
- package/board/dispatch.mjs +7 -1
- package/board/workflow.mjs +1 -1
- package/package.json +1 -1
package/board/ci.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Deployment evidence is observed before a board mutation. No network or Git
|
|
2
2
|
// process runs inside the optimistic transaction that decides completion.
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, relative } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
4
7
|
|
|
5
8
|
const observations = new Map();
|
|
6
9
|
const key = (board, id) => `${board.root}\0${board.slug}\0${id}`;
|
|
@@ -40,6 +43,16 @@ export function observeCompletionEvidence(board, id, entry, payload = {}) {
|
|
|
40
43
|
const repo = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
|
|
41
44
|
if (!repo) throw new Error('Deployment evidence requires this workspace\'s GitHub origin.');
|
|
42
45
|
Object.assign(state, { commit, repository: repo });
|
|
46
|
+
if (entry.params.completion_ci.keeper_repositories?.includes(repo)) {
|
|
47
|
+
const receiptPath = join(board.root, 'runtime-deployment.json');
|
|
48
|
+
const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
|
|
49
|
+
if (receipt.repository !== repo || receipt.completed_at < Number(run('git', ['show', '-s', '--format=%ct', commit], path)) * 1000) throw new Error('No keeper tick after this runtime commit.');
|
|
50
|
+
run('git', ['merge-base', '--is-ancestor', commit, receipt.head], receipt.checkout);
|
|
51
|
+
const current = workflowRevision(board.root);
|
|
52
|
+
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.');
|
|
53
|
+
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 });
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
43
56
|
const configured = entry.params.completion_ci.workflows?.[repo] ?? entry.params.completion_ci.workflows?.['*'];
|
|
44
57
|
if (!Array.isArray(configured) || !configured.length) throw new Error(`No deployment workflow is declared for ${repo} in completion_ci.workflows.`);
|
|
45
58
|
const matchedRules = (entry.params.completion_ci.path_workflows?.[repo] ?? []).filter(rule =>
|
|
@@ -58,8 +71,37 @@ export function observeCompletionEvidence(board, id, entry, payload = {}) {
|
|
|
58
71
|
if (receipt.headSha !== commit) throw new Error(`CI run ${receipt.databaseId} is for ${receipt.headSha}, not ${commit}.`);
|
|
59
72
|
if (!deploymentWorkflows.includes(receipt.workflowName)) throw new Error(`${receipt.workflowName} is not a declared deployment workflow for ${repo}.`);
|
|
60
73
|
if (receipt.status !== 'completed' || receipt.conclusion !== 'success') throw new Error(`Deployment CI ${receipt.databaseId} is ${receipt.status}/${receipt.conclusion || 'pending'}.`);
|
|
74
|
+
state.deployed_sha = commit;
|
|
61
75
|
state.ready = true;
|
|
62
76
|
state.reason = null;
|
|
63
77
|
} catch (error) { state.reason = error.message; }
|
|
64
78
|
return state;
|
|
65
79
|
}
|
|
80
|
+
|
|
81
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
82
|
+
/** Read the tracked workflow revision the next serve tick will load. */
|
|
83
|
+
export function workflowRevision(root) {
|
|
84
|
+
try {
|
|
85
|
+
const workflow = realpathSync(join(root, 'workflow.yaml'));
|
|
86
|
+
const checkout = run('git', ['rev-parse', '--show-toplevel'], dirname(workflow));
|
|
87
|
+
const head = run('git', ['rev-parse', 'HEAD'], checkout);
|
|
88
|
+
const repository = /(?:github\.com[:/])([^/]+\/[^/]+?)(?:\.git)?$/.exec(run('git', ['remote', 'get-url', 'origin'], checkout))?.[1];
|
|
89
|
+
const name = relative(checkout, workflow).replaceAll('\\', '/');
|
|
90
|
+
const source = readFileSync(workflow, 'utf8').trimEnd();
|
|
91
|
+
if (run('git', ['show', `${head}:${name}`], checkout) !== source) return null;
|
|
92
|
+
// Prompts are loaded with the workflow and must belong to the same revision.
|
|
93
|
+
run('git', ['diff', '--quiet', 'HEAD', '--', name, join(dirname(name), 'prompts')], checkout);
|
|
94
|
+
return { checkout, repository, head, workflow_hash: digest(source), started_at: Date.now(), pid: process.pid };
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Only the retained serve loop calls this after a successful tick. */
|
|
99
|
+
export function retainWorkflowTick(root, before) {
|
|
100
|
+
if (!before) return null;
|
|
101
|
+
const after = workflowRevision(root);
|
|
102
|
+
if (!after || after.head !== before.head || after.workflow_hash !== before.workflow_hash) return null;
|
|
103
|
+
const receipt = { ...before, completed_at: Date.now() };
|
|
104
|
+
const path = join(root, 'runtime-deployment.json'), temporary = `${path}.${process.pid}.tmp`;
|
|
105
|
+
writeFileSync(temporary, JSON.stringify(receipt) + '\n', { mode: 0o600 }); renameSync(temporary, path);
|
|
106
|
+
return receipt;
|
|
107
|
+
}
|
package/board/cli.mjs
CHANGED
|
@@ -1199,14 +1199,16 @@ async function serve(root, args = {}) {
|
|
|
1199
1199
|
const state = await laneState(root);
|
|
1200
1200
|
const settings = { ...state.settings, ...override };
|
|
1201
1201
|
interval = settings.interval;
|
|
1202
|
-
for (const r of await tick({ root, lanes: state.lanes, settings, params })) {
|
|
1202
|
+
for (const r of await tick({ root, lanes: state.lanes, settings, params, deploymentReceipt: true })) {
|
|
1203
1203
|
const said = summary(r);
|
|
1204
|
+
if (r.deployment) log(`runtime deployed ${r.deployment.repository}@${r.deployment.head}; workflow ${r.deployment.workflow_hash}; tick ${r.deployment.completed_at}`);
|
|
1204
1205
|
if (!said.endsWith('nothing to do') || args.verbose) log(said);
|
|
1205
1206
|
if (args.verbose && r.ms) log(` took ${Object.entries(r.ms).map(([k, v]) => `${k} ${(v / 1000).toFixed(1)}s`).join(', ')}`);
|
|
1206
1207
|
if (r.paused && !pausedSaid) { log(`new starts paused by ${r.paused} (\`supercode workflow resume\` lifts it)`); pausedSaid = true; }
|
|
1207
1208
|
// Event bursts do not shorten the six-sweep stuck warning.
|
|
1208
1209
|
stuck[r.board] = r.ready_waiting && !r.started.length && !r.paused ? (stuck[r.board] || Date.now()) : 0;
|
|
1209
1210
|
if (stuck[r.board] && Date.now() - stuck[r.board] >= interval * 6000 && Date.now() - (warned[r.board] ?? 0) >= 300_000) { log(`board ${r.board} stuck: ${r.ready_waiting} ready card(s) and nothing started for ${Math.floor((Date.now() - stuck[r.board]) / 1000)}s; \`supercode workflow diagnostics\``); warned[r.board] = Date.now(); }
|
|
1211
|
+
for (const [t, why] of r.queued) log(` ${t} waits: ${why}`);
|
|
1210
1212
|
for (const [t, why] of r.failed) log(` ${t} did not start: ${why}`);
|
|
1211
1213
|
for (const [t, why] of r.unsettled ?? []) log(` ${t} could not be read: ${why}`);
|
|
1212
1214
|
for (const [t, s, m] of r.started) log(` ${t} started ${s} (${m})`);
|
package/board/dispatch.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Every side effect follows a committed transition: a card is claimed before its session is launched, and a
|
|
16
16
|
// subscriber's cursor moves only after its notice was delivered, so two dispatchers over one board never both
|
|
17
17
|
// launch and a crash between the two steps is answered by the next tick.
|
|
18
|
+
import { workflowRevision, retainWorkflowTick } from './ci.mjs';
|
|
18
19
|
import { createRequire } from 'node:module';
|
|
19
20
|
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
20
21
|
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -1683,8 +1684,9 @@ export async function plan({ root, lanes, settings, slugs = null, params = {} })
|
|
|
1683
1684
|
* `only` limits starting to one card (a create that answers whether its card started). Each board is ticked under its
|
|
1684
1685
|
* version-checked card claims; no dispatcher file lock. Answers one report per board.
|
|
1685
1686
|
*/
|
|
1686
|
-
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {} }) {
|
|
1687
|
+
export async function tick({ root, lanes, settings, only = null, slugs = null, dispatch = true, params = {}, deploymentReceipt = false }) {
|
|
1687
1688
|
// a verb's flags (dispatch --max, --failure-limit) are params of this tick, read by the workflow as its own
|
|
1689
|
+
const deployment = deploymentReceipt ? workflowRevision(root) : null;
|
|
1688
1690
|
const loaded = await loadWorkflow(root, { fresh: true });
|
|
1689
1691
|
const entry = { ...loaded, params: { ...loaded.params, ...params } };
|
|
1690
1692
|
const needFleet = Object.values(lanes).some((l) => l.surface === 'pane');
|
|
@@ -1749,5 +1751,9 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
1749
1751
|
}
|
|
1750
1752
|
out.push(report);
|
|
1751
1753
|
}
|
|
1754
|
+
if (deployment && out.every(report => !report.error)) {
|
|
1755
|
+
const receipt = retainWorkflowTick(root, deployment);
|
|
1756
|
+
if (receipt && out[0]) out[0].deployment = receipt;
|
|
1757
|
+
}
|
|
1752
1758
|
return out;
|
|
1753
1759
|
}
|
package/board/workflow.mjs
CHANGED
|
@@ -399,7 +399,7 @@ export function eventTo(db, board, entry, id, status, { roles = [], payload = {}
|
|
|
399
399
|
if (!taken?.target || String(templateValue(taken.target, scope) ?? '') !== status) continue;
|
|
400
400
|
const failed = (taken.validate ?? []).find((v) => !holds(v.expr, scope));
|
|
401
401
|
if (!failed) return { name };
|
|
402
|
-
refusal ??= failed.message;
|
|
402
|
+
refusal ??= render(failed.message, scope);
|
|
403
403
|
}
|
|
404
404
|
return { name: null, refusal };
|
|
405
405
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.133",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|