@lifeaitools/rdc-skills 0.35.23 → 0.35.25
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/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/self-test.yml +34 -34
- package/MANIFEST.md +224 -224
- package/RELEASE.md +53 -11
- package/deploy/install-systemd.sh +29 -6
- package/deploy/systemd/rdc-skills-mcp-update.service +15 -0
- package/deploy/systemd/rdc-skills-mcp-update.timer +9 -0
- package/deploy/update-from-tag.sh +382 -0
- package/guides/agents/backend.md +102 -102
- package/guides/agents/content.md +94 -94
- package/guides/agents/cs2.md +56 -56
- package/guides/agents/data.md +86 -86
- package/guides/agents/design.md +77 -77
- package/guides/agents/frontend.md +91 -91
- package/guides/agents/infrastructure.md +81 -81
- package/guides/agents/setup.md +272 -272
- package/guides/agents/verify.md +119 -119
- package/guides/agents/viz.md +106 -106
- package/guides/orchestration-epic.md +17 -17
- package/guides/output-contract.md +8 -0
- package/guides/work-contract.md +194 -0
- package/package.json +3 -2
- package/scripts/lib/guide-content-rules.mjs +49 -0
- package/scripts/self-test.mjs +1439 -1459
- package/scripts/test-guide-validator.mjs +25 -24
- package/skills/behavior-audit/agents/openai.yaml +4 -4
- package/skills/build/SKILL.md +81 -29
- package/skills/fixit/SKILL.md +16 -0
- package/skills/open/SKILL.md +35 -5
- package/skills/overnight/SKILL.md +3 -1
- package/skills/plan/SKILL.md +21 -23
- package/skills/tests/onramp.test.json +101 -101
- package/skills/tests/rdc-env.test.json +12 -12
- package/skills/tests/rdc-new-model.test.json +12 -12
- package/skills/tests/rdc-refactor.test.json +29 -29
- package/skills/tests/rdc-regen-media.test.json +29 -29
- package/tests/deploy-updater.test.mjs +608 -0
- package/.rdc/evidence/orchestrator-rework-strikes/a9fad8b4716ee35e5f5d0047.json +0 -1
- package/.rdc/last_seen.json +0 -8
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// deploy/update-from-tag.sh — the public MCP follows release tags by itself.
|
|
2
|
+
//
|
|
3
|
+
// Behavioral: a real origin, a real deploy clone, and stub systemctl/npm/curl
|
|
4
|
+
// that model the running MCP the way it actually behaves — `git_sha` is fixed
|
|
5
|
+
// when the process starts, `version` is re-read from package.json on every
|
|
6
|
+
// request. So a restart that did not take, a release that never comes up, and a
|
|
7
|
+
// checkout moved without a restart are each observable exactly as on the host,
|
|
8
|
+
// and every outcome is proved by the commit the checkout ends on and what
|
|
9
|
+
// /health reports, not by a log line.
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const UPDATER = path.join(REPO_ROOT, 'deploy', 'update-from-tag.sh');
|
|
20
|
+
const read = (relative) => fs.readFileSync(path.join(REPO_ROOT, relative), 'utf8');
|
|
21
|
+
|
|
22
|
+
function bashExecutable() {
|
|
23
|
+
if (process.platform !== 'win32') return 'bash';
|
|
24
|
+
// `bash` on a Windows PATH can be WSL's System32 shim, which cannot see these paths.
|
|
25
|
+
const git = 'C:/Program Files/Git/bin/bash.exe';
|
|
26
|
+
return fs.existsSync(git) ? git : 'bash';
|
|
27
|
+
}
|
|
28
|
+
const BASH = bashExecutable();
|
|
29
|
+
const slash = (p) => p.replace(/\\/g, '/');
|
|
30
|
+
// Git for Windows' bash.exe prepends /mingw64/bin and /usr/bin, so the stubs go
|
|
31
|
+
// on PATH from inside the shell, in its own path form.
|
|
32
|
+
const posix = (p) => (process.platform === 'win32' ? slash(p).replace(/^([A-Za-z]):/, (_, d) => `/${d.toLowerCase()}`) : p);
|
|
33
|
+
|
|
34
|
+
function sandbox() {
|
|
35
|
+
const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'rdc-skills-updater-')));
|
|
36
|
+
const gitConfig = path.join(dir, 'gitconfig');
|
|
37
|
+
fs.writeFileSync(gitConfig, '[user]\n\tname = updater-test\n\temail = updater-test@example.invalid\n[init]\n\tdefaultBranch = master\n');
|
|
38
|
+
const env = { ...process.env, GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' };
|
|
39
|
+
const run = (cwd, args) => {
|
|
40
|
+
const r = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
|
|
41
|
+
assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`);
|
|
42
|
+
return r.stdout.trim();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const origin = path.join(dir, 'origin.git');
|
|
46
|
+
const author = path.join(dir, 'author');
|
|
47
|
+
const deploy = path.join(dir, 'deploy');
|
|
48
|
+
const units = path.join(dir, 'units');
|
|
49
|
+
const libexec = path.join(dir, 'libexec');
|
|
50
|
+
const stateDir = path.join(dir, 'state');
|
|
51
|
+
const stubs = path.join(dir, 'stubs');
|
|
52
|
+
const svc = path.join(dir, 'svc');
|
|
53
|
+
for (const d of [author, units, stubs, svc]) fs.mkdirSync(d, { recursive: true });
|
|
54
|
+
run(dir, ['init', '--bare', '-q', origin]);
|
|
55
|
+
run(author, ['init', '-q']);
|
|
56
|
+
run(author, ['remote', 'add', 'origin', slash(origin)]);
|
|
57
|
+
|
|
58
|
+
const write = (relative, body) => {
|
|
59
|
+
const file = path.join(author, relative);
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
fs.writeFileSync(file, body);
|
|
62
|
+
};
|
|
63
|
+
// release(version, { tag }) — tag: true → `v<version>`, a string → that name, false → untagged.
|
|
64
|
+
const release = (version, { lock = `lock-${version}`, unit = 'unit-v1', broken = false, tag = true, branch = 'master' } = {}) => {
|
|
65
|
+
write('.gitignore', 'node_modules/\ngit-sha.json\n');
|
|
66
|
+
write('package.json', JSON.stringify({ name: 'rdc-skills-fixture', version }, null, 2) + '\n');
|
|
67
|
+
write('package-lock.json', JSON.stringify({ marker: lock }) + '\n');
|
|
68
|
+
write('deploy/systemd/rdc-skills-mcp.service', `# ${unit}\n`);
|
|
69
|
+
write('deploy/update-from-tag.sh', `# updater as of ${version}\n`);
|
|
70
|
+
if (broken) write('BROKEN', 'this release never answers /health\n');
|
|
71
|
+
else fs.rmSync(path.join(author, 'BROKEN'), { force: true });
|
|
72
|
+
run(author, ['add', '-A']);
|
|
73
|
+
run(author, ['commit', '-q', '--allow-empty', '-m', `release ${version}`]);
|
|
74
|
+
const name = tag === true ? `v${version}` : tag;
|
|
75
|
+
if (name) run(author, ['tag', name]);
|
|
76
|
+
run(author, ['push', '-q', 'origin', `HEAD:${branch}`, ...(name ? [`refs/tags/${name}`] : [])]);
|
|
77
|
+
return run(author, ['rev-parse', 'HEAD']);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const stub = (name, body) => {
|
|
81
|
+
const file = path.join(stubs, name);
|
|
82
|
+
fs.writeFileSync(file, `#!/usr/bin/env bash\n${body}\n`);
|
|
83
|
+
fs.chmodSync(file, 0o755);
|
|
84
|
+
};
|
|
85
|
+
const calls = path.join(dir, 'calls.log');
|
|
86
|
+
const started = path.join(svc, 'started-sha');
|
|
87
|
+
// restart: the process starts on whatever commit the checkout holds — unless
|
|
88
|
+
// this release is BROKEN (it never answers), or the restart silently does not
|
|
89
|
+
// take (svc/no-restart), in which case the old process keeps running.
|
|
90
|
+
stub('systemctl', [
|
|
91
|
+
`echo "systemctl $*" >> "${slash(calls)}"`,
|
|
92
|
+
'if [[ "$1" == restart ]]; then',
|
|
93
|
+
// svc/restart-fail-once: the restart stops the old process and fails to start.
|
|
94
|
+
` if [[ -f "${slash(svc)}/restart-fail-once" ]]; then rm -f "${slash(svc)}/restart-fail-once" "${slash(started)}"; exit 1; fi`,
|
|
95
|
+
` [[ -f "${slash(svc)}/no-restart" ]] && exit 0`,
|
|
96
|
+
` if [[ -f "${slash(deploy)}/BROKEN" ]]; then rm -f "${slash(started)}"; exit 0; fi`,
|
|
97
|
+
` git -C "${slash(deploy)}" rev-parse HEAD > "${slash(started)}"`,
|
|
98
|
+
'fi',
|
|
99
|
+
].join('\n'));
|
|
100
|
+
// npm: svc/npm-fail fails every install (a registry outage); svc/npm-fail-once fails one.
|
|
101
|
+
stub('npm', [
|
|
102
|
+
`echo "npm $*" >> "${slash(calls)}"`,
|
|
103
|
+
`if [[ -f "${slash(svc)}/npm-fail" ]]; then echo "npm error code ETIMEDOUT" >&2; echo "npm error network request to https://registry.npmjs.org failed" >&2; exit 1; fi`,
|
|
104
|
+
// A release whose own lockfile can never install.
|
|
105
|
+
'if grep -q uninstallable package-lock.json; then echo "npm error code ETARGET" >&2; echo "npm error notarget No matching version found for $(cat package-lock.json)" >&2; exit 1; fi',
|
|
106
|
+
`if [[ -f "${slash(svc)}/npm-fail-once" ]]; then rm -f "${slash(svc)}/npm-fail-once"; echo "npm error code EINTEGRITY" >&2; exit 1; fi`,
|
|
107
|
+
'rm -rf node_modules; mkdir -p node_modules',
|
|
108
|
+
].join('\n'));
|
|
109
|
+
// /health: git_sha fixed at process start; version read from disk per request.
|
|
110
|
+
// svc/curl-fail-once: one request times out (a slow answer).
|
|
111
|
+
stub('curl', [
|
|
112
|
+
`if [[ -f "${slash(svc)}/curl-fail-once" ]]; then rm -f "${slash(svc)}/curl-fail-once"; exit 28; fi`,
|
|
113
|
+
`[[ -f "${slash(started)}" ]] || exit 7`,
|
|
114
|
+
`ver="$(node -p "require('fs').readFileSync('${slash(deploy)}/package.json','utf8').match(/\\"version\\": \\"([^\\"]+)/)[1]")"`,
|
|
115
|
+
`printf '{"status":"ok","version":"%s","git_sha":"%s"}' "$ver" "$(tr -d '\\r\\n' < "${slash(started)}")"`,
|
|
116
|
+
].join('\n'));
|
|
117
|
+
|
|
118
|
+
const updaterEnv = (overrides = {}) => ({
|
|
119
|
+
...env,
|
|
120
|
+
RDC_TEST_STUBS: posix(stubs),
|
|
121
|
+
RDC_TEST_UPDATER: slash(UPDATER),
|
|
122
|
+
RDC_SKILLS_DEPLOY_ROOT: slash(deploy),
|
|
123
|
+
RDC_SKILLS_EXPECTED_ROOT: slash(deploy),
|
|
124
|
+
RDC_SKILLS_UNIT_DIR: slash(units),
|
|
125
|
+
RDC_SKILLS_LIBEXEC_DIR: slash(libexec),
|
|
126
|
+
RDC_SKILLS_STATE_DIR: slash(stateDir),
|
|
127
|
+
RDC_SKILLS_LOCK_FILE: slash(path.join(dir, 'update.lock')),
|
|
128
|
+
RDC_SKILLS_HEALTH_URL: 'http://127.0.0.1:1/health',
|
|
129
|
+
RDC_SKILLS_HEALTH_DEADLINE: '0',
|
|
130
|
+
RDC_SKILLS_HEALTH_INTERVAL: '0',
|
|
131
|
+
...overrides,
|
|
132
|
+
});
|
|
133
|
+
const update = (overrides) => {
|
|
134
|
+
const r = spawnSync(BASH, ['-c', 'PATH="$RDC_TEST_STUBS:$PATH" exec bash "$RDC_TEST_UPDATER"'], { env: updaterEnv(overrides), encoding: 'utf8', input: '' });
|
|
135
|
+
return { status: r.status, out: `${r.stdout}${r.stderr}` };
|
|
136
|
+
};
|
|
137
|
+
const clearCalls = () => fs.rmSync(calls, { force: true });
|
|
138
|
+
const cloneOnly = () => run(dir, ['clone', '-q', '--branch', 'master', slash(origin), slash(deploy)]);
|
|
139
|
+
// The service as an operator left it: started by hand on whatever is checked out.
|
|
140
|
+
const startService = () => {
|
|
141
|
+
const r = spawnSync(BASH, ['-c', `"${posix(path.join(stubs, 'systemctl'))}" restart rdc-skills-mcp.service`], { env: updaterEnv(), encoding: 'utf8' });
|
|
142
|
+
assert.equal(r.status, 0, r.stderr);
|
|
143
|
+
clearCalls();
|
|
144
|
+
};
|
|
145
|
+
// A host brought up by the bootstrap: cloned, then converged and proved once.
|
|
146
|
+
const bootstrap = () => {
|
|
147
|
+
cloneOnly();
|
|
148
|
+
const r = update();
|
|
149
|
+
assert.equal(r.status, 0, `bootstrap did not converge: ${r.out}`);
|
|
150
|
+
clearCalls();
|
|
151
|
+
return r;
|
|
152
|
+
};
|
|
153
|
+
const head = () => run(deploy, ['rev-parse', 'HEAD']);
|
|
154
|
+
const servingSha = () => (fs.existsSync(started) ? fs.readFileSync(started, 'utf8').trim() : null);
|
|
155
|
+
const callLog = () => (fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '');
|
|
156
|
+
const restarts = () => (callLog().match(/systemctl restart/g) || []).length;
|
|
157
|
+
const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
const failedList = () => {
|
|
159
|
+
const f = path.join(stateDir, 'failed');
|
|
160
|
+
return fs.existsSync(f) ? fs.readFileSync(f, 'utf8').split('\n').filter(Boolean) : [];
|
|
161
|
+
};
|
|
162
|
+
return { release, bootstrap, cloneOnly, startService, update, head, servingSha, callLog, restarts, clearCalls, failedList, run, deploy, author, origin, units, libexec, svc, started, cleanup };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test('a fresh checkout converges on first run: installs, restarts, and proves the tag', (t) => {
|
|
166
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
167
|
+
const v1 = s.release('1.0.0');
|
|
168
|
+
s.run(path.dirname(s.deploy), ['clone', '-q', '--branch', 'master', slash(s.origin), slash(s.deploy)]);
|
|
169
|
+
const r = s.update();
|
|
170
|
+
assert.equal(r.status, 0, r.out);
|
|
171
|
+
assert.equal(s.servingSha(), v1, 'the service was restarted onto the tag');
|
|
172
|
+
assert.match(s.callLog(), /npm ci --omit=dev --no-audit --no-fund --ignore-scripts/);
|
|
173
|
+
assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.0.0\n',
|
|
174
|
+
'the proved release refreshed the updater copy outside the checkout');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a proved host at the newest tag is left alone', (t) => {
|
|
178
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
179
|
+
s.release('1.0.0');
|
|
180
|
+
s.bootstrap();
|
|
181
|
+
const r = s.update();
|
|
182
|
+
assert.equal(r.status, 0, r.out);
|
|
183
|
+
assert.match(r.out, /current at v1\.0\.0/);
|
|
184
|
+
assert.equal(s.callLog(), '', 'nothing installed, nothing restarted');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a new tag is fast-forwarded, reinstalled when the lock moved, restarted, and proved', (t) => {
|
|
188
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
189
|
+
s.release('1.9.0');
|
|
190
|
+
s.bootstrap();
|
|
191
|
+
// v1.10.0 sorts BEFORE v1.9.0 lexically; the updater must pick it by version.
|
|
192
|
+
const v110 = s.release('1.10.0');
|
|
193
|
+
const r = s.update();
|
|
194
|
+
assert.equal(r.status, 0, r.out);
|
|
195
|
+
assert.equal(s.head(), v110);
|
|
196
|
+
assert.equal(s.servingSha(), v110);
|
|
197
|
+
assert.match(s.callLog(), /npm ci/);
|
|
198
|
+
assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.10.0\n');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a lock file that did not change is not reinstalled', (t) => {
|
|
202
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
203
|
+
s.release('2.0.0', { lock: 'same' });
|
|
204
|
+
s.bootstrap();
|
|
205
|
+
const v201 = s.release('2.0.1', { lock: 'same' });
|
|
206
|
+
const r = s.update();
|
|
207
|
+
assert.equal(r.status, 0, r.out);
|
|
208
|
+
assert.equal(s.servingSha(), v201);
|
|
209
|
+
assert.doesNotMatch(s.callLog(), /npm ci/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an untagged commit on master is not deployed — the tag is the promotion', (t) => {
|
|
213
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
214
|
+
const v1 = s.release('1.0.0');
|
|
215
|
+
s.bootstrap();
|
|
216
|
+
s.release('1.0.1', { tag: false });
|
|
217
|
+
const r = s.update();
|
|
218
|
+
assert.equal(r.status, 0, r.out);
|
|
219
|
+
assert.equal(s.head(), v1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a newer release tag that is not on master is not deployed', (t) => {
|
|
223
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
224
|
+
const v1 = s.release('1.0.0');
|
|
225
|
+
s.bootstrap();
|
|
226
|
+
s.release('9.0.0', { branch: 'side' });
|
|
227
|
+
const r = s.update();
|
|
228
|
+
assert.equal(r.status, 0, r.out);
|
|
229
|
+
assert.equal(s.head(), v1);
|
|
230
|
+
assert.equal(s.servingSha(), v1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('pre-release and stray v* tags are never served and never outrank a release', (t) => {
|
|
234
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
235
|
+
const v3 = s.release('3.0.0');
|
|
236
|
+
s.bootstrap();
|
|
237
|
+
s.release('3.1.0-rc.1', { tag: 'v3.1.0-rc.1' });
|
|
238
|
+
s.release('3.1.0-wip', { tag: 'v99-test' });
|
|
239
|
+
const r = s.update();
|
|
240
|
+
assert.equal(r.status, 0, r.out);
|
|
241
|
+
assert.match(r.out, /current at v3\.0\.0/, 'v99-test and v3.1.0-rc.1 both sort above v3.0.0 and are ignored');
|
|
242
|
+
assert.equal(s.servingSha(), v3, 'still serving the last plain release');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a release that never answers is rolled back, then skipped until a newer tag', (t) => {
|
|
246
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
247
|
+
const good = s.release('3.0.0');
|
|
248
|
+
s.bootstrap();
|
|
249
|
+
s.release('3.1.0', { broken: true });
|
|
250
|
+
|
|
251
|
+
const first = s.update();
|
|
252
|
+
assert.equal(first.status, 5, first.out);
|
|
253
|
+
assert.equal(s.head(), good, 'checkout ends on the release that answered');
|
|
254
|
+
assert.equal(s.servingSha(), good, '/health proves the rollback took');
|
|
255
|
+
|
|
256
|
+
s.clearCalls();
|
|
257
|
+
const again = s.update();
|
|
258
|
+
assert.equal(again.status, 7, again.out);
|
|
259
|
+
assert.equal(s.restarts(), 0, 'a failed release is not retried every tick');
|
|
260
|
+
assert.equal(s.head(), good);
|
|
261
|
+
|
|
262
|
+
const fixed = s.release('3.2.0');
|
|
263
|
+
const next = s.update();
|
|
264
|
+
assert.equal(next.status, 0, next.out);
|
|
265
|
+
assert.equal(s.servingSha(), fixed);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('an install outage is not a bad release: nothing is recorded, and the release ships once the outage ends', (t) => {
|
|
269
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
270
|
+
const good = s.release('2.0.0');
|
|
271
|
+
s.bootstrap();
|
|
272
|
+
const next = s.release('2.1.0');
|
|
273
|
+
fs.writeFileSync(path.join(s.svc, 'npm-fail'), '');
|
|
274
|
+
// Longer than the install-error limit: a registry outage is never counted.
|
|
275
|
+
for (let i = 0; i < 4; i += 1) {
|
|
276
|
+
const during = s.update();
|
|
277
|
+
assert.equal(during.status, 6, during.out);
|
|
278
|
+
}
|
|
279
|
+
assert.equal(s.head(), good, 'the checkout is back on the release that was serving');
|
|
280
|
+
assert.equal(s.servingSha(), good);
|
|
281
|
+
assert.deepEqual(s.failedList(), [], 'an outage says nothing about the release');
|
|
282
|
+
|
|
283
|
+
fs.rmSync(path.join(s.svc, 'npm-fail'));
|
|
284
|
+
const after = s.update();
|
|
285
|
+
assert.equal(after.status, 0, after.out);
|
|
286
|
+
assert.equal(s.servingSha(), next);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('a release that never reaches the registry is judged bad once the network-error window passes', (t) => {
|
|
290
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
291
|
+
const good = s.release('2.0.0');
|
|
292
|
+
s.bootstrap();
|
|
293
|
+
const next = s.release('2.1.0', { lock: 'network-only' });
|
|
294
|
+
fs.writeFileSync(path.join(s.svc, 'npm-fail'), '');
|
|
295
|
+
const r = s.update({ RDC_SKILLS_NETWORK_ERROR_WINDOW: '0' });
|
|
296
|
+
assert.equal(r.status, 6, r.out); // bad, but its own return to 2.0.0 hits the same outage
|
|
297
|
+
assert.deepEqual(s.failedList(), [next]);
|
|
298
|
+
assert.equal(s.servingSha(), good);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('only npm\'s own error lines count as a network failure', (t) => {
|
|
302
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
303
|
+
s.release('2.0.0');
|
|
304
|
+
s.bootstrap();
|
|
305
|
+
// A lockfile problem whose message merely mentions a network-ish package name.
|
|
306
|
+
const next = s.release('2.1.0', { lock: 'uninstallable network-utils' });
|
|
307
|
+
const statuses = [s.update(), s.update(), s.update()].map((r) => r.status);
|
|
308
|
+
assert.deepEqual(statuses, [6, 6, 5], 'counted like any install error');
|
|
309
|
+
assert.deepEqual(s.failedList(), [next]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('a one-off install failure is retried on the next run, not skipped until a newer tag', (t) => {
|
|
313
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
314
|
+
const good = s.release('2.0.0');
|
|
315
|
+
s.bootstrap();
|
|
316
|
+
const next = s.release('2.1.0');
|
|
317
|
+
fs.writeFileSync(path.join(s.svc, 'npm-fail-once'), '');
|
|
318
|
+
const first = s.update();
|
|
319
|
+
assert.equal(first.status, 6, first.out);
|
|
320
|
+
assert.equal(s.servingSha(), good, 'the rollback reinstalled and restarted the previous release');
|
|
321
|
+
const second = s.update();
|
|
322
|
+
assert.equal(second.status, 0, second.out);
|
|
323
|
+
assert.equal(s.servingSha(), next);
|
|
324
|
+
assert.deepEqual(s.failedList(), []);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('bootstrap with no history: a bad release returns to the commit that was serving', (t) => {
|
|
328
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
329
|
+
const serving = s.release('1.0.0');
|
|
330
|
+
s.cloneOnly();
|
|
331
|
+
s.startService();
|
|
332
|
+
const bad = s.release('1.1.0', { broken: true });
|
|
333
|
+
// The operator's bootstrap step: fast-forward the checkout to the new tag.
|
|
334
|
+
s.run(s.deploy, ['fetch', '-q', '--tags', 'origin']);
|
|
335
|
+
s.run(s.deploy, ['merge', '-q', '--ff-only', 'v1.1.0']);
|
|
336
|
+
|
|
337
|
+
const first = s.update();
|
|
338
|
+
assert.equal(first.status, 5, first.out);
|
|
339
|
+
assert.equal(s.head(), serving);
|
|
340
|
+
assert.equal(s.servingSha(), serving, '/health proves the host is back on what it served before');
|
|
341
|
+
|
|
342
|
+
s.clearCalls();
|
|
343
|
+
for (let i = 0; i < 2; i += 1) {
|
|
344
|
+
const again = s.update();
|
|
345
|
+
assert.equal(again.status, 7, again.out);
|
|
346
|
+
}
|
|
347
|
+
assert.equal(s.restarts(), 0, 'a held, healthy release is not restarted');
|
|
348
|
+
assert.deepEqual(s.failedList(), [bad], 'recorded once, however many runs');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('a bad release with nothing to return to fails every run, and is recorded once', (t) => {
|
|
352
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
353
|
+
s.release('1.0.0', { broken: true });
|
|
354
|
+
s.cloneOnly(); // never served anything: no history, and /health does not answer
|
|
355
|
+
const bad = s.head();
|
|
356
|
+
for (let i = 0; i < 3; i += 1) {
|
|
357
|
+
const r = s.update();
|
|
358
|
+
assert.equal(r.status, 6, r.out);
|
|
359
|
+
}
|
|
360
|
+
assert.deepEqual(s.failedList(), [bad]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('a skipped release still holds what is checked out: a service that died is brought back', (t) => {
|
|
364
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
365
|
+
const good = s.release('3.0.0');
|
|
366
|
+
s.bootstrap();
|
|
367
|
+
s.release('3.1.0', { broken: true });
|
|
368
|
+
assert.equal(s.update().status, 5);
|
|
369
|
+
fs.rmSync(s.started); // the process crashed after the rollback
|
|
370
|
+
const r = s.update();
|
|
371
|
+
assert.equal(r.status, 7, r.out);
|
|
372
|
+
assert.equal(s.servingSha(), good);
|
|
373
|
+
assert.equal(s.restarts() > 0, true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('the proved release is never marked failed, even when it does not come back up', (t) => {
|
|
377
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
378
|
+
s.release('1.0.0');
|
|
379
|
+
s.bootstrap();
|
|
380
|
+
fs.rmSync(s.started); // it crashed
|
|
381
|
+
fs.writeFileSync(path.join(s.svc, 'no-restart'), ''); // and the restart does not take
|
|
382
|
+
const r = s.update();
|
|
383
|
+
assert.equal(r.status, 6, r.out);
|
|
384
|
+
assert.deepEqual(s.failedList(), [], 'it served before; a slow or broken host is not a bad release');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('one slow /health answer does not restart a healthy service', (t) => {
|
|
388
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
389
|
+
s.release('1.0.0');
|
|
390
|
+
s.bootstrap();
|
|
391
|
+
fs.writeFileSync(path.join(s.svc, 'curl-fail-once'), '');
|
|
392
|
+
const r = s.update();
|
|
393
|
+
assert.equal(r.status, 0, r.out);
|
|
394
|
+
assert.match(r.out, /current at v1\.0\.0/);
|
|
395
|
+
assert.equal(s.restarts(), 0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('a restart that silently does not take is not reported as a deploy', (t) => {
|
|
399
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
400
|
+
const v1 = s.release('4.0.0');
|
|
401
|
+
s.bootstrap();
|
|
402
|
+
s.release('4.1.0');
|
|
403
|
+
fs.writeFileSync(path.join(s.svc, 'no-restart'), '');
|
|
404
|
+
const r = s.update();
|
|
405
|
+
assert.notEqual(r.status, 0, r.out);
|
|
406
|
+
assert.equal(s.servingSha(), v1, 'the old process is still the one serving');
|
|
407
|
+
assert.equal(s.head(), v1, 'the checkout is back on what is actually running');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('a checkout moved without a restart (an interrupted run) converges instead of reading as current', (t) => {
|
|
411
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
412
|
+
const v1 = s.release('5.0.0');
|
|
413
|
+
s.bootstrap();
|
|
414
|
+
const v2 = s.release('5.1.0');
|
|
415
|
+
s.run(s.deploy, ['fetch', '-q', 'origin']);
|
|
416
|
+
s.run(s.deploy, ['reset', '-q', '--hard', v2]);
|
|
417
|
+
assert.equal(s.servingSha(), v1, 'precondition: the service never restarted');
|
|
418
|
+
const r = s.update();
|
|
419
|
+
assert.equal(r.status, 0, r.out);
|
|
420
|
+
assert.match(r.out, /converging/);
|
|
421
|
+
assert.match(s.callLog(), /npm ci/, 'the lock moved with the interrupted checkout, so it is reinstalled');
|
|
422
|
+
assert.equal(s.servingSha(), v2);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('a tag moved on origin does not block later releases', (t) => {
|
|
426
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
427
|
+
s.release('6.0.0');
|
|
428
|
+
s.bootstrap();
|
|
429
|
+
// Re-created on an unrelated commit: not a fast-forward of where it was, so
|
|
430
|
+
// only a forced fetch can follow it.
|
|
431
|
+
const unrelated = s.run(s.author, ['commit-tree', 'HEAD^{tree}', '-m', 'v6.0.0 re-created elsewhere']);
|
|
432
|
+
s.run(s.author, ['tag', '-f', 'v6.0.0', unrelated]);
|
|
433
|
+
s.run(s.author, ['push', '-q', '-f', 'origin', 'refs/tags/v6.0.0']);
|
|
434
|
+
const v61 = s.release('6.1.0');
|
|
435
|
+
const r = s.update();
|
|
436
|
+
assert.equal(r.status, 0, r.out);
|
|
437
|
+
assert.equal(s.servingSha(), v61);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('tracked changes on the host are preserved and nothing moves', (t) => {
|
|
441
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
442
|
+
const v1 = s.release('7.0.0');
|
|
443
|
+
s.bootstrap();
|
|
444
|
+
s.release('7.1.0');
|
|
445
|
+
fs.appendFileSync(path.join(s.deploy, 'package.json'), '\n');
|
|
446
|
+
const r = s.update();
|
|
447
|
+
assert.equal(r.status, 3, r.out);
|
|
448
|
+
assert.equal(s.head(), v1);
|
|
449
|
+
assert.match(fs.readFileSync(path.join(s.deploy, 'package.json'), 'utf8'), /\n\n$/, 'the local edit survives');
|
|
450
|
+
assert.equal(s.callLog(), '');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('a host commit that the tag does not descend from is refused, not overwritten', (t) => {
|
|
454
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
455
|
+
s.release('8.0.0');
|
|
456
|
+
s.bootstrap();
|
|
457
|
+
s.release('8.1.0');
|
|
458
|
+
s.run(s.deploy, ['commit', '-q', '--allow-empty', '-m', 'hotfix made on the host']);
|
|
459
|
+
const local = s.head();
|
|
460
|
+
const r = s.update();
|
|
461
|
+
assert.equal(r.status, 4, r.out);
|
|
462
|
+
assert.equal(s.head(), local, 'the host commit is preserved');
|
|
463
|
+
assert.notEqual(s.servingSha(), local, 'and never served');
|
|
464
|
+
assert.equal(s.restarts(), 0, 'the service is left as it is');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('a higher tag on an older commit is not a release: the proved one is held, and a real newer tag ships', (t) => {
|
|
468
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
469
|
+
const old = s.release('1.0.0');
|
|
470
|
+
const current = s.release('2.0.0');
|
|
471
|
+
s.bootstrap();
|
|
472
|
+
s.run(s.author, ['tag', 'v2.0.1', old]);
|
|
473
|
+
s.run(s.author, ['push', '-q', 'origin', 'refs/tags/v2.0.1']);
|
|
474
|
+
const r = s.update();
|
|
475
|
+
assert.equal(r.status, 9, r.out);
|
|
476
|
+
assert.equal(s.servingSha(), current, 'never downgraded');
|
|
477
|
+
assert.equal(s.restarts(), 0);
|
|
478
|
+
const next = s.release('3.0.0');
|
|
479
|
+
const after = s.update();
|
|
480
|
+
assert.equal(after.status, 0, after.out);
|
|
481
|
+
assert.equal(s.servingSha(), next);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('with no history, a service running a host-made commit is not adopted, and the commit is kept', (t) => {
|
|
485
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
486
|
+
s.release('1.0.0');
|
|
487
|
+
s.cloneOnly();
|
|
488
|
+
s.run(s.deploy, ['commit', '-q', '--allow-empty', '-m', 'made on the host']);
|
|
489
|
+
const local = s.head();
|
|
490
|
+
s.startService();
|
|
491
|
+
s.release('1.1.0');
|
|
492
|
+
const r = s.update();
|
|
493
|
+
assert.equal(r.status, 4, r.out);
|
|
494
|
+
assert.doesNotMatch(r.out, /adopted/);
|
|
495
|
+
assert.equal(s.head(), local);
|
|
496
|
+
assert.equal(s.servingSha(), local, 'the service is left as it is');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('a failed refresh of the updater copy is reported, and retried until it lands', (t) => {
|
|
500
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
501
|
+
s.release('1.0.0');
|
|
502
|
+
s.bootstrap();
|
|
503
|
+
s.release('1.1.0');
|
|
504
|
+
const blocked = path.join(s.svc, 'not-a-directory');
|
|
505
|
+
fs.writeFileSync(blocked, '');
|
|
506
|
+
const first = s.update({ RDC_SKILLS_LIBEXEC_DIR: slash(path.join(blocked, 'libexec')) });
|
|
507
|
+
assert.equal(first.status, 8, first.out);
|
|
508
|
+
const second = s.update();
|
|
509
|
+
assert.equal(second.status, 0, second.out);
|
|
510
|
+
assert.equal(fs.readFileSync(path.join(s.libexec, 'update-from-tag.sh'), 'utf8'), '# updater as of 1.1.0\n');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('a pull to untagged master is moved back to the release, not served', (t) => {
|
|
514
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
515
|
+
const v1 = s.release('1.0.0', { lock: 'same' });
|
|
516
|
+
s.bootstrap();
|
|
517
|
+
const untagged = s.release('1.0.1', { lock: 'same', tag: false });
|
|
518
|
+
// The old manual procedure: git pull --ff-only origin master.
|
|
519
|
+
s.run(s.deploy, ['fetch', '-q', 'origin']);
|
|
520
|
+
s.run(s.deploy, ['merge', '-q', '--ff-only', untagged]);
|
|
521
|
+
const r = s.update();
|
|
522
|
+
assert.equal(r.status, 0, r.out);
|
|
523
|
+
assert.equal(s.head(), v1);
|
|
524
|
+
assert.equal(s.servingSha(), v1);
|
|
525
|
+
assert.equal(s.restarts(), 0, 'it was still serving the release; only the checkout moved back');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a release whose lockfile never installs is retried twice, then treated as bad', (t) => {
|
|
529
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
530
|
+
const good = s.release('2.0.0');
|
|
531
|
+
s.bootstrap();
|
|
532
|
+
const broken = s.release('2.1.0', { lock: 'uninstallable' });
|
|
533
|
+
const statuses = [s.update(), s.update(), s.update()].map((r) => r.status);
|
|
534
|
+
assert.deepEqual(statuses, [6, 6, 5]);
|
|
535
|
+
assert.equal(s.servingSha(), good);
|
|
536
|
+
assert.deepEqual(s.failedList(), [broken]);
|
|
537
|
+
s.clearCalls();
|
|
538
|
+
const held = s.update();
|
|
539
|
+
assert.equal(held.status, 7, held.out);
|
|
540
|
+
assert.equal(s.restarts(), 0);
|
|
541
|
+
assert.doesNotMatch(s.callLog(), /npm ci/);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('bootstrap: a restart that fails on the new release returns to the adopted serving commit', (t) => {
|
|
545
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
546
|
+
const serving = s.release('1.0.0');
|
|
547
|
+
s.cloneOnly();
|
|
548
|
+
s.startService();
|
|
549
|
+
const next = s.release('1.1.0');
|
|
550
|
+
s.run(s.deploy, ['fetch', '-q', '--tags', 'origin']);
|
|
551
|
+
s.run(s.deploy, ['merge', '-q', '--ff-only', 'v1.1.0']);
|
|
552
|
+
fs.writeFileSync(path.join(s.svc, 'restart-fail-once'), '');
|
|
553
|
+
const first = s.update();
|
|
554
|
+
assert.equal(first.status, 6, first.out);
|
|
555
|
+
assert.equal(s.servingSha(), serving, 'the way back survived the failed restart');
|
|
556
|
+
const second = s.update();
|
|
557
|
+
assert.equal(second.status, 0, second.out);
|
|
558
|
+
assert.equal(s.servingSha(), next);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('changed unit files are installed and systemd reloaded', (t) => {
|
|
562
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
563
|
+
s.release('9.0.0', { unit: 'unit-v1' });
|
|
564
|
+
s.bootstrap();
|
|
565
|
+
s.release('9.1.0', { unit: 'unit-v2' });
|
|
566
|
+
const r = s.update();
|
|
567
|
+
assert.equal(r.status, 0, r.out);
|
|
568
|
+
assert.match(fs.readFileSync(path.join(s.units, 'rdc-skills-mcp.service'), 'utf8'), /unit-v2/);
|
|
569
|
+
assert.match(s.callLog(), /systemctl daemon-reload/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('a stale git-sha.json stamp in the checkout is removed so the proof can pass', (t) => {
|
|
573
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
574
|
+
s.release('10.0.0');
|
|
575
|
+
s.bootstrap();
|
|
576
|
+
const v2 = s.release('10.1.0');
|
|
577
|
+
fs.writeFileSync(path.join(s.deploy, 'git-sha.json'), '{"sha":"0000000"}\n');
|
|
578
|
+
const r = s.update();
|
|
579
|
+
assert.equal(r.status, 0, r.out);
|
|
580
|
+
assert.equal(fs.existsSync(path.join(s.deploy, 'git-sha.json')), false);
|
|
581
|
+
assert.equal(s.servingSha(), v2);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('the updater refuses any checkout other than the host path', (t) => {
|
|
585
|
+
const s = sandbox(); t.after(s.cleanup);
|
|
586
|
+
s.release('11.0.0');
|
|
587
|
+
s.bootstrap();
|
|
588
|
+
const r = s.update({ RDC_SKILLS_EXPECTED_ROOT: '/srv/regen/rdc-skills' });
|
|
589
|
+
assert.equal(r.status, 2, r.out);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('the units and installer run the updater from outside the checkout', () => {
|
|
593
|
+
const timer = read('deploy/systemd/rdc-skills-mcp-update.timer');
|
|
594
|
+
const service = read('deploy/systemd/rdc-skills-mcp-update.service');
|
|
595
|
+
const installer = read('deploy/install-systemd.sh');
|
|
596
|
+
const updater = read('deploy/update-from-tag.sh');
|
|
597
|
+
assert.match(timer, /OnUnitActiveSec=/);
|
|
598
|
+
assert.doesNotMatch(timer, /Persistent=/, 'Persistent= only applies to OnCalendar=');
|
|
599
|
+
assert.match(service, /Type=oneshot/);
|
|
600
|
+
assert.match(service, /^ExecStart=\/usr\/local\/libexec\/rdc-skills\/update-from-tag\.sh$/m);
|
|
601
|
+
assert.match(service, /^StateDirectory=rdc-skills-update$/m);
|
|
602
|
+
assert.match(installer, /install -D -m 0755 deploy\/update-from-tag\.sh "\$libexec_dir\/update-from-tag\.sh"/);
|
|
603
|
+
assert.match(installer, /systemctl start rdc-skills-mcp-update\.service/, 'the bootstrap restarts into the release and proves it');
|
|
604
|
+
assert.match(installer, /systemctl enable --now rdc-skills-mcp-update\.timer/);
|
|
605
|
+
assert.match(installer, /--ignore-scripts/);
|
|
606
|
+
// The script replaces itself mid-run; only a body behind a final call is safe.
|
|
607
|
+
assert.match(updater.trimEnd(), /\nmain "\$@"; exit \$\?$/);
|
|
608
|
+
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"sessionId":"01a07616-15d1-7d13-abf2-fab776bfd75b","emitterId":"transcript:c:/users/daveladouceur/.codex/sessions/2026/09/06/rollout-2026-09-06t03-39-14-01a07616-15d1-7d13-abf2-fab776bfd75b.jsonl","strikeKey":"a9fad8b4716ee35e5f5d0047","strikes":0,"ts":"2026-09-06T09:41:35.743Z"}
|
package/.rdc/last_seen.json
DELETED