amsd-pipeline-shared 0.5.5 → 0.5.7
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/orchestrations/prompts/doctor-repair.md +1 -1
- package/orchestrations/scripts/lib/doctor/classify.js +24 -2
- package/orchestrations/scripts/lib/doctor/crash-record.js +1 -1
- package/orchestrations/scripts/lib/doctor/handle.js +10 -2
- package/orchestrations/scripts/lib/doctor/profile.js +40 -2
- package/orchestrations/scripts/lib/doctor/release.js +78 -0
- package/orchestrations/scripts/lib/doctor/repair.js +16 -1
- package/orchestrations/scripts/lib/doctor/state.js +1 -1
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@ AGENTIC FIRST — decide this before anything else
|
|
|
17
17
|
- The agentic layer of {{component}} is: {{agenticDescription}}. It lives in: {{agenticDirs}}.
|
|
18
18
|
- If an agent caused it, repair the AGENTIC layer: sharpen the instruction, the answer schema and its field descriptions, the hand-off contract between the agents — so the agent is asked in a way that gets the right answer. Do NOT add deterministic code that tolerates, repairs or re-parses a wrong agent answer, and do not add a guard that hides an agent's behaviour.
|
|
19
19
|
- Only a clear and outright coding bug gets a deterministic fix.
|
|
20
|
-
- This abort was classified as: {{abortKind}} ({{layerHint}}). An agentic abort MUST be repaired agentically. For a crash,
|
|
20
|
+
- This abort was classified as: {{abortKind}} ({{layerHint}}). An agentic abort MUST be repaired agentically. For a crash or a fault (the leg failed without giving a verdict: a stage with no result, a session that answered an error), the layer is yours to find — look for the agentic cause first — and say which and why.
|
|
21
21
|
- An agentic repair is proven by REPLAY: replay the real reply or hand-off that failed — copy it into a fixture — against the schema, the contract or the rendered instruction in the RED test (it fails on the base, passes after), and assert on the rendered instruction or schema text that now prevents the abort.
|
|
22
22
|
|
|
23
23
|
HOW YOU WORK — in this order
|
|
@@ -23,7 +23,7 @@ function signaturesFor(runtime, injected) {
|
|
|
23
23
|
internalFrame: rxOf(s.internalFrame), dependencyDir: s.dependencyDir };
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function classify({ profile, portRoot, res, crash, aborted, since, signatures }) {
|
|
26
|
+
function classify({ profile, portRoot, res, crash, aborted, since, signatures, evidence }) {
|
|
27
27
|
const sig = signaturesFor(profile.classify.runtime, signatures);
|
|
28
28
|
if (!res || typeof res !== 'object') return no('no-result', 'the leg gave no result');
|
|
29
29
|
if (aborted) return no('aborted', 'the operator or a watcher ended the leg');
|
|
@@ -33,7 +33,28 @@ function classify({ profile, portRoot, res, crash, aborted, since, signatures })
|
|
|
33
33
|
if (res.status === 'failed') {
|
|
34
34
|
const reason = typeof res.reason === 'string' ? res.reason : '';
|
|
35
35
|
const hit = reason && profile.agentic.abortSignatures.find((r) => r.test(reason));
|
|
36
|
-
if (!hit)
|
|
36
|
+
if (!hit) {
|
|
37
|
+
// A FAILED LEG THAT GAVE NO VERDICT (operator, 2026-10-20, after run 11 leg 3: "these are the types of bugs I need doctor to resolve during a run"): the machinery failed without throwing
|
|
38
|
+
// — a stage's result with no exit, a session that answered an error. Which lines of the leg's evidence say so is the pipeline's profile (classify.faultMarks); a lane's own verdict
|
|
39
|
+
// has a number and its reason and matches none. The marked line is still refused if it names the machine, the network, the model service or a kill.
|
|
40
|
+
const line = evidence && profile.classify.faultMarks.length ? String(evidence).split('\n').find((l) => profile.classify.faultMarks.some((r) => r.test(l))) : null;
|
|
41
|
+
if (line) {
|
|
42
|
+
if (sig.external.test(line)) return no('external', `the failing line also names the machine, the network, the model service or a kill — not the pipeline: ${line.slice(0, 160)}`);
|
|
43
|
+
return { call: true, kind: 'fault', layer: 'undecided', why: `the leg failed without a verdict: ${line.trim().slice(0, 200)}` };
|
|
44
|
+
}
|
|
45
|
+
// MOST FAILURES (profile classify.policy "all-but-refusals"): the pipeline's own refusals are its decision and what names the machine, the network, the model service or a kill is not the code —
|
|
46
|
+
// everything else, self-heal giving up included, goes to the doctor, which still needs a RED test and a GREEN fix to change anything. Judged on the reason and the END of the evidence.
|
|
47
|
+
if (profile.classify.policy === 'all-but-refusals') {
|
|
48
|
+
const lines = String(evidence || '').split('\n').filter((l) => l.trim());
|
|
49
|
+
const window = [reason, ...lines.slice(-25)].filter(Boolean).join('\n');
|
|
50
|
+
if (!window.trim()) return no('not-a-crash', 'the leg failed with no reason and no evidence — nothing to investigate');
|
|
51
|
+
if (sig.external.test(window)) return no('external', 'the end of the failure also names the machine, the network, the model service or a kill — not the pipeline');
|
|
52
|
+
if (profile.classify.refusals.some((r) => r.test(window))) return no('refusal', 'the pipeline stopped on purpose (its own refusal or pre-condition)');
|
|
53
|
+
const last = [...lines].reverse().find((l) => /fail|HALT|block|error/i.test(l)) || reason;
|
|
54
|
+
return { call: true, kind: 'failure', layer: 'undecided', why: `the leg failed (${reason || 'no reason'}): ${String(last).trim().slice(0, 200)}` };
|
|
55
|
+
}
|
|
56
|
+
return no('not-a-crash', 'the leg failed on a verdict that names no agent-caused message — reported, not repaired');
|
|
57
|
+
}
|
|
37
58
|
if (sig.external.test(reason)) return no('external', 'the agent-caused message also names the machine, the network, the model service or a kill — not the pipeline');
|
|
38
59
|
return { call: true, kind: 'agentic-abort', layer: 'agentic', why: `an agent caused the abort: ${reason.slice(0, 200)}` };
|
|
39
60
|
}
|
|
@@ -49,6 +70,7 @@ function classify({ profile, portRoot, res, crash, aborted, since, signatures })
|
|
|
49
70
|
const parts = rel.split(path.sep);
|
|
50
71
|
const inPort = !!portRoot && !rel.startsWith('..') && !path.isAbsolute(rel) && profile.scope.repairDirs.includes(parts[0]) && !parts.includes(sig.dependencyDir);
|
|
51
72
|
if (!inPort) return no('outside-port', `the throw site ${site.file}:${site.line} is not the component's own code`);
|
|
73
|
+
if (crash.fault === true) return { call: true, kind: 'fault', layer: 'undecided', why: `a fault the component raised: ${String(crash.message).slice(0, 160)} — at ${rel}:${site.line}${crash.step ? ` (step: ${crash.step})` : ''}` };
|
|
52
74
|
if (!sig.runtimeNames.has(crash.name) && !sig.runtimeMessage.test(crash.message || '')) return no('ambiguous', `an Error in ${rel}:${site.line} with no sign of a code defect — reported`);
|
|
53
75
|
return { call: true, kind: 'port-defect', layer: 'code', why: `${crash.name}: ${String(crash.message).slice(0, 160)} — thrown at ${rel}:${site.line}${crash.step ? ` (step: ${crash.step})` : ''}` };
|
|
54
76
|
}
|
|
@@ -27,7 +27,7 @@ function frames(stack) {
|
|
|
27
27
|
|
|
28
28
|
/** What was thrown, whatever it was: an Error, or anything a program may throw. */
|
|
29
29
|
function describe(e) {
|
|
30
|
-
if (e instanceof Error) return { name: e.name || 'Error', message: String(e.message), stack: String(e.stack || '') };
|
|
30
|
+
if (e instanceof Error) return { name: e.name || 'Error', message: String(e.message), stack: String(e.stack || ''), ...(e.fault === true ? { fault: true, evidence: Array.isArray(e.evidence) ? e.evidence.map(String) : [] } : {}) };
|
|
31
31
|
let text; try { text = typeof e === 'string' ? e : JSON.stringify(e); } catch { text = String(e); }
|
|
32
32
|
return { name: 'NonError', message: String(text), stack: '' };
|
|
33
33
|
}
|
|
@@ -12,7 +12,7 @@ const path = require('path');
|
|
|
12
12
|
const { spawnSync } = require('child_process');
|
|
13
13
|
const { classify } = require('./classify.js');
|
|
14
14
|
const { settings, open } = require('./state.js');
|
|
15
|
-
const { verifyRepair, reinstall, installedMismatches, checkComponent, snapshotRepos, repoChanges } = require('./repair.js');
|
|
15
|
+
const { verifyRepair, reinstall, installedMismatches, checkComponent, snapshotRepos, repoChanges, promote } = require('./repair.js');
|
|
16
16
|
const { renderPrompt } = require('./agent.js');
|
|
17
17
|
const { restart } = require('./restarter.js');
|
|
18
18
|
|
|
@@ -29,7 +29,11 @@ async function handle(a) {
|
|
|
29
29
|
const d = open(runDir, cfg, profile);
|
|
30
30
|
const portRoot = path.join(install, profile.install.dest);
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// the leg's evidence tail (the files the profile names), read BEFORE the decision: a failure that threw nothing is judged on what the run itself wrote
|
|
33
|
+
// the logs are cumulative across legs: the decision reads only what follows the last leg-start line in each file (the whole tail when the profile names none or the tail holds none)
|
|
34
|
+
const thisLeg = (text) => { if (!profile.logs.legStart) return text; const ls = text.split('\n'); let at = -1; ls.forEach((l, i) => { if (profile.logs.legStart.test(l)) at = i; }); return at < 0 ? text : ls.slice(at).join('\n'); };
|
|
35
|
+
const evidence = profile.logs.evidence.map((f) => thisLeg(tail(path.join(runDir, f), profile.logs.evidenceTailLines))).join('\n');
|
|
36
|
+
const c = classify({ profile, portRoot, res, crash, aborted, since, evidence });
|
|
33
37
|
if (!c.call) { d.event('not-called', { kind: c.kind, why: c.why }); return { handled: false, kind: c.kind, why: c.why }; }
|
|
34
38
|
d.event('trigger', { kind: c.kind, layer: c.layer, why: c.why, step: (crash && crash.step) || '' });
|
|
35
39
|
|
|
@@ -107,6 +111,10 @@ async function handle(a) {
|
|
|
107
111
|
fs.writeFileSync(path.join(A, 'reinstall.txt'), `${ri.installer || ''}\n${ri.why}\n${(ri.mismatches || []).join('\n')}\n`);
|
|
108
112
|
if (!ri.ok) return stop('reinstall-failed', ri.why);
|
|
109
113
|
d.event('reinstalled', { layer, commit: ri.commit, files: v.files }, attempt.n);
|
|
114
|
+
// PROMOTE: the main line follows the install (local, never forced) — otherwise the next repair in this run would be refused as "repo-not-installed"
|
|
115
|
+
const pr = promote({ repo, commit: ri.commit });
|
|
116
|
+
if (pr.ok) d.event('promoted', { from: pr.from, to: pr.to, branch: pr.branch }, attempt.n);
|
|
117
|
+
else { d.event('promote-skipped', { why: pr.why }, attempt.n); alert(`repair installed but not promoted: ${pr.why} — the repository's HEAD is not what is installed, so the next repair would be refused until it is`); }
|
|
110
118
|
|
|
111
119
|
fs.writeFileSync(path.join(A, 'verdict.json'), `${JSON.stringify({ status: 'repaired', layer, layerWhy: r.answer.layerWhy, summary: r.answer.summary, cause: r.answer.cause, files: v.files, branch, commit: ri.commit }, null, 2)}\n`);
|
|
112
120
|
d.event('repaired', { layer, layerWhy: r.answer.layerWhy, summary: r.answer.summary, cause: r.answer.cause, branch, commit: ri.commit }, attempt.n);
|
|
@@ -77,14 +77,50 @@ function validate(input) {
|
|
|
77
77
|
out.classify = {};
|
|
78
78
|
if (need('classify', cl, 'runtime')) out.classify.runtime = str('classify.runtime', cl.runtime);
|
|
79
79
|
if (need('classify', cl, 'refusals')) out.classify.refusals = rxs('classify.refusals', cl.refusals);
|
|
80
|
+
// THE LINES THAT MEAN "THE MACHINERY GAVE NO VERDICT" (optional; none = a failed leg is never taken for a fault): a pipeline's own knowledge, matched against the leg's evidence tail
|
|
81
|
+
out.classify.faultMarks = cl.faultMarks === undefined ? [] : rxs('classify.faultMarks', cl.faultMarks);
|
|
82
|
+
// WHICH FAILED LEGS CALL THE DOCTOR (operator, 2026-10-20: "let the doctor fix most issues … unless the pipeline decides it"): "faults-only" (default) = a crash, an agentic abort or a marked
|
|
83
|
+
// fault; "all-but-refusals" = every failed leg except an operator abort, an external failure and the pipeline's own refusals (classify.refusals)
|
|
84
|
+
out.classify.policy = cl.policy === undefined ? 'faults-only' : cl.policy;
|
|
85
|
+
if (!['faults-only', 'all-but-refusals'].includes(out.classify.policy)) bad('classify.policy', 'must be "faults-only" or "all-but-refusals"');
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
const se = p.settings;
|
|
83
89
|
if (se) {
|
|
84
90
|
out.settings = {};
|
|
85
91
|
for (const k of ['on', 'restarts', 'repo', 'budget']) if (need('settings', se, k)) { const v = str(`settings.${k}`, se[k]); if (v !== null && !ENV_NAME.test(v)) bad(`settings.${k}`, `"${v}" must be an environment variable name (A-Z, 0-9, _)`); else out.settings[k] = v; }
|
|
86
|
-
|
|
87
|
-
if (
|
|
92
|
+
// the release setting is optional, and required by a release section: a pipeline that publishes says which launch setting is the operator's yes
|
|
93
|
+
if (has(se, 'release')) { const v = str('settings.release', se.release); if (v !== null && !ENV_NAME.test(v)) bad('settings.release', `"${v}" must be an environment variable name (capital letters, digits, underscores)`); else if (v !== null) out.settings.release = v; }
|
|
94
|
+
const names = ['on', 'restarts', 'repo', 'budget', 'release'].map((k) => se[k]).filter(Boolean);
|
|
95
|
+
if (new Set(names).size !== names.length) bad('settings', 'the setting names must be different');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// THE RELEASE (operator, 2026-10-20: "get doctor to commit and update npm and update install as well"): optional. The pipeline lists, as data, the commands that turn the promoted
|
|
99
|
+
// repairs into a published version and put it in the install; the doctor runs them in order, stops at the first failure, and runs them only when the operator's setting says yes.
|
|
100
|
+
if (has(p, 'release')) {
|
|
101
|
+
const r = p.release; out.release = {};
|
|
102
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) bad('release', 'an object is required');
|
|
103
|
+
else {
|
|
104
|
+
const TOKENS = new Set(['node', 'repo', 'worktree', 'install', 'run', 'package', 'version', 'tag']);
|
|
105
|
+
const cmd = (path, v) => {
|
|
106
|
+
const x = strs(path, v, { nonEmpty: true }); if (!x) return null;
|
|
107
|
+
for (const w of x) for (const m of w.matchAll(/\{([a-zA-Z]+)\}/g)) if (!TOKENS.has(m[1])) bad(path, `unknown token {${m[1]}} (known: ${[...TOKENS].map((t) => `{${t}}`).join(' ')})`);
|
|
108
|
+
return x;
|
|
109
|
+
};
|
|
110
|
+
if (need('release', r, 'versionFile')) out.release.versionFile = rel('release.versionFile', r.versionFile);
|
|
111
|
+
if (need('release', r, 'tag')) { out.release.tag = str('release.tag', r.tag); if (out.release.tag && !out.release.tag.includes('{version}')) bad('release.tag', 'must contain {version}'); }
|
|
112
|
+
if (need('release', r, 'steps')) {
|
|
113
|
+
if (!Array.isArray(r.steps) || !r.steps.length) bad('release.steps', 'a non-empty list of { name, command } is required');
|
|
114
|
+
else out.release.steps = r.steps.map((st, i) => ({ name: str(`release.steps[${i}].name`, st && st.name), command: cmd(`release.steps[${i}].command`, st && st.command) }));
|
|
115
|
+
}
|
|
116
|
+
if (need('release', r, 'reinstall')) {
|
|
117
|
+
const ri = r.reinstall || {};
|
|
118
|
+
out.release.reinstall = { command: cmd('release.reinstall.command', ri.command), retries: 0, delaySeconds: 0 };
|
|
119
|
+
if (!Number.isInteger(ri.retries) || ri.retries < 0) bad('release.reinstall.retries', 'a whole number, 0 or more, is required'); else out.release.reinstall.retries = ri.retries;
|
|
120
|
+
if (typeof ri.delaySeconds !== 'number' || ri.delaySeconds < 0) bad('release.reinstall.delaySeconds', 'a number of seconds, 0 or more, is required'); else out.release.reinstall.delaySeconds = ri.delaySeconds;
|
|
121
|
+
}
|
|
122
|
+
if (!has(se || {}, 'release')) bad('settings.release', 'a release section needs the launch setting that switches it on');
|
|
123
|
+
}
|
|
88
124
|
}
|
|
89
125
|
|
|
90
126
|
if (p.restart) { if (need('restart', p.restart, 'args')) out.restart = { args: strs('restart.args', p.restart.args, { nonEmpty: true }) }; }
|
|
@@ -98,6 +134,8 @@ function validate(input) {
|
|
|
98
134
|
if (need('logs', lg, 'dir')) out.logs.dir = rel('logs.dir', lg.dir);
|
|
99
135
|
if (need('logs', lg, 'alerts')) out.logs.alerts = file('logs.alerts', lg.alerts);
|
|
100
136
|
if (need('logs', lg, 'evidence')) { const v = strs('logs.evidence', lg.evidence, { nonEmpty: false }); if (v) { v.forEach((d, n) => rel(`logs.evidence[${n}]`, d)); out.logs.evidence = v; } }
|
|
137
|
+
// the line each leg of a run starts with (optional): the evidence the doctor DECIDES on is what follows its last occurrence — the logs are cumulative across legs
|
|
138
|
+
if (has(lg, 'legStart')) out.logs.legStart = rx('logs.legStart', lg.legStart);
|
|
101
139
|
if (need('logs', lg, 'evidenceTailLines')) { if (!Number.isInteger(lg.evidenceTailLines) || lg.evidenceTailLines < 1) bad('logs.evidenceTailLines', 'a positive whole number is required'); else out.logs.evidenceTailLines = lg.evidenceTailLines; }
|
|
102
140
|
}
|
|
103
141
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// THE DOCTOR'S RELEASE (operator, 2026-10-20: "get doctor to commit and update npm and update install as well"). A verified repair is in the install and, once promoted, at the repository's HEAD. A release makes
|
|
3
|
+
// it a published version: the pipeline's profile lists the commands (a version bump, a commit, a tag, a push, a publish — whatever that pipeline's registry needs) and the command that puts the
|
|
4
|
+
// published package in the install again. Nothing here knows npm, git hosting or any pipeline: the steps are the profile's data, the tokens are {node} {repo} {worktree} {install} {run} {package}
|
|
5
|
+
// {version} {tag}, and the operator's launch setting (profile settings.release = yes) is the standing yes — without it nothing runs and nothing is written. It runs once, at the END of a leg, for
|
|
6
|
+
// every repair of this run not yet released; stops at the first failing step (no rollback — it says how far it got); and never runs on a dirty checkout or an install that is not at HEAD.
|
|
7
|
+
// release({ profile, runDir, outDir, install, values, runId, deps? }) → { released, why?, stopped?, version?, tag? }
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const { settings, open } = require('./state.js');
|
|
12
|
+
const { installedMismatches, checkComponent } = require('./repair.js');
|
|
13
|
+
|
|
14
|
+
const git = (repo, ...a) => spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
15
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
16
|
+
const sleep = (s) => new Promise((r) => setTimeout(r, Math.round(s * 1000)));
|
|
17
|
+
|
|
18
|
+
function fill(words, tokens) { return words.map((w) => w.replace(/\{([a-zA-Z]+)\}/g, (m, k) => (k in tokens ? tokens[k] : m))); }
|
|
19
|
+
const tail = (r) => `${r.stderr || ''}${r.stdout || ''}`.trim().slice(-400);
|
|
20
|
+
|
|
21
|
+
async function release({ profile, runDir, outDir, install, values, runId, deps }) {
|
|
22
|
+
const rc = profile.release; const cfg = settings(values, profile);
|
|
23
|
+
if (!rc || !cfg.release) return { released: false, why: 'the release is off' };
|
|
24
|
+
const alert = (text) => { try { fs.mkdirSync(outDir, { recursive: true }); fs.appendFileSync(path.join(outDir, profile.logs.alerts), `${new Date().toISOString()} DOCTOR ${text}\n`); } catch { /* the doctor's own log has it */ } };
|
|
25
|
+
if (!cfg.on) { alert(`release not run: ${cfg.problem || 'the doctor is off'}`); return { released: false, why: cfg.problem || 'the doctor is off' }; }
|
|
26
|
+
const d = open(runDir, cfg, profile);
|
|
27
|
+
const skip = (why) => { d.event('release-skipped', { why }); alert(`release skipped: ${why}`); return { released: false, why }; };
|
|
28
|
+
|
|
29
|
+
const recFile = path.join(install, profile.install.dest, profile.install.patchRecord);
|
|
30
|
+
const rec = readJson(recFile, null);
|
|
31
|
+
const mine = ((rec && rec.patches) || []).filter((p) => p.run === runId && !p.released);
|
|
32
|
+
if (!mine.length) return { released: false, why: 'nothing to release: no repair of this run in the install, or all are released already' };
|
|
33
|
+
|
|
34
|
+
const comp = checkComponent({ repo: cfg.repo, profile, protectedRepos: [] });
|
|
35
|
+
if (!comp.ok) return skip(comp.why);
|
|
36
|
+
const repo = comp.root;
|
|
37
|
+
if (git(repo, 'status', '--porcelain', '--untracked-files=no').stdout.trim()) return skip('the repository has uncommitted changes — a release needs a clean checkout');
|
|
38
|
+
const stale = installedMismatches({ repo, branch: 'HEAD', install, profile });
|
|
39
|
+
if (stale.length) return skip(`the install is not at the repository's HEAD (${stale.slice(0, 4).join(', ')}) — the repairs were not promoted`);
|
|
40
|
+
for (const p of mine) if (git(repo, 'merge-base', '--is-ancestor', p.commit, 'HEAD').status !== 0) return skip(`the repair ${String(p.commit).slice(0, 9)} is not part of HEAD`);
|
|
41
|
+
|
|
42
|
+
const version = () => { const j = readJson(path.join(repo, rc.versionFile), {}); return String(j.version || ''); };
|
|
43
|
+
const tokens = () => { const v = version(); return { node: process.execPath, repo, worktree: repo, install, run: runId, package: profile.component.package, version: v, tag: rc.tag.replace('{version}', v) }; };
|
|
44
|
+
const done = [];
|
|
45
|
+
const run = (words) => { const c = fill(words, tokens()); return (deps && deps.spawn ? deps.spawn : spawnSync)(c[0], c.slice(1), { cwd: repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); };
|
|
46
|
+
const stop = (step, r) => {
|
|
47
|
+
d.event('release-failed', { step, status: r.status, tail: tail(r), done });
|
|
48
|
+
alert(`release stopped at ${step} (exit ${r.status}): ${tail(r).slice(-200)} — done so far: ${done.length ? done.join(', ') : 'nothing'}; the repository is at ${git(repo, 'rev-parse', '--short', 'HEAD').stdout.trim()}; nothing is rolled back`);
|
|
49
|
+
return { released: false, stopped: step, why: `${step} failed`, done };
|
|
50
|
+
};
|
|
51
|
+
d.event('release-start', { repairs: mine.map((p) => p.commit), from: version() });
|
|
52
|
+
for (const st of rc.steps) {
|
|
53
|
+
const r = run(st.command);
|
|
54
|
+
if (r.status !== 0) return stop(st.name, r);
|
|
55
|
+
done.push(st.name);
|
|
56
|
+
}
|
|
57
|
+
const ver = version(); const tag = tokens().tag;
|
|
58
|
+
let last = null;
|
|
59
|
+
for (let n = 0; n <= rc.reinstall.retries; n += 1) {
|
|
60
|
+
last = run(rc.reinstall.command);
|
|
61
|
+
if (last.status === 0) break;
|
|
62
|
+
if (n < rc.reinstall.retries) await sleep(rc.reinstall.delaySeconds);
|
|
63
|
+
}
|
|
64
|
+
if (!last || last.status !== 0) return stop('reinstall', last || { status: null, stdout: '', stderr: 'not run' });
|
|
65
|
+
done.push('reinstall');
|
|
66
|
+
const left = installedMismatches({ repo, branch: 'HEAD', install, profile });
|
|
67
|
+
if (left.length) { d.event('release-failed', { step: 'verify', mismatches: left, done }); alert(`release stopped at verify: the install differs from HEAD after the reinstall (${left.slice(0, 4).join(', ')})`); return { released: false, stopped: 'verify', why: 'the install differs from HEAD after the reinstall', done }; }
|
|
68
|
+
|
|
69
|
+
const at = new Date().toISOString();
|
|
70
|
+
const fresh = readJson(recFile, rec) || rec;
|
|
71
|
+
fresh.patches = (fresh.patches || []).map((p) => (p.run === runId && !p.released ? { ...p, released: { version: ver, tag, at } } : p));
|
|
72
|
+
fs.writeFileSync(recFile, `${JSON.stringify(fresh, null, 2)}\n`);
|
|
73
|
+
d.event('released', { version: ver, tag, repairs: mine.length, done });
|
|
74
|
+
alert(`released ${profile.component.package}@${ver} (${tag}): ${mine.length} repair(s) of ${runId} — ${done.join(' → ')}; the install is the published package`);
|
|
75
|
+
return { released: true, version: ver, tag, done };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { release };
|
|
@@ -163,4 +163,19 @@ function repoChanges(before, after) {
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
/**
|
|
167
|
+
* PROMOTE (operator, 2026-10-20): after a verified repair is installed, the repository's main line is fast-forwarded to the repair commit, so HEAD equals what is installed — without it the next
|
|
168
|
+
* repair is refused (repo-not-installed). Local and reversible; never forced: a detached HEAD, uncommitted changes, or a main line that moved since the repair branched are said and left alone.
|
|
169
|
+
*/
|
|
170
|
+
function promote({ repo, commit }) {
|
|
171
|
+
const cur = git(repo, 'symbolic-ref', '-q', '--short', 'HEAD');
|
|
172
|
+
if (cur.status !== 0) return bad('the repository is on a detached HEAD — there is no main line to move');
|
|
173
|
+
if (git(repo, 'status', '--porcelain', '--untracked-files=no').stdout.trim()) return bad('the repository has uncommitted changes — a fast-forward would be merged over them');
|
|
174
|
+
const from = git(repo, 'rev-parse', 'HEAD').stdout.trim();
|
|
175
|
+
if (git(repo, 'merge-base', '--is-ancestor', 'HEAD', commit).status !== 0) return bad(`the main line (${cur.stdout.trim()}) moved since the repair branched — it is not an ancestor of the repair, so no fast-forward is possible`);
|
|
176
|
+
const m = git(repo, 'merge', '--ff-only', '-q', commit);
|
|
177
|
+
if (m.status !== 0) return bad(`the fast-forward failed: ${(m.stderr || m.stdout).trim().slice(-300)}`);
|
|
178
|
+
return { ok: true, from, to: commit, branch: cur.stdout.trim() };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { promote, verifyRepair, reinstall, installedMismatches, packaged, checkComponent, snapshotRepos, repoChanges, expand, hostWrap };
|
|
@@ -17,7 +17,7 @@ function settings(values, profile) {
|
|
|
17
17
|
const wants = v[n.on] === 'on';
|
|
18
18
|
const lacks = [!repo && `${n.repo} — the checkout of ${profile.component.package} it repairs`, !budget && `${n.budget} — a spending cap in dollars for one repair session (a positive number)`].filter(Boolean);
|
|
19
19
|
const problem = wants && lacks.length ? `${n.on}=on needs ${lacks.join(' and ')}` : null;
|
|
20
|
-
return { on: wants && !!repo && !!budget, restarts, repo, budget, problem };
|
|
20
|
+
return { on: wants && !!repo && !!budget, restarts, repo, budget, problem, release: !!n.release && v[n.release] === 'yes' };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amsd-pipeline-shared",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "The AMSD pipeline's shared components, one source for the bash pipeline and its Claude workflows: the model-call gateway (llm-handler.sh — retry, ladder, self-heal, cost) and the libraries it runs.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"bin": {
|