@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 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})`);
@@ -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
  }
@@ -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.131",
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": {