pincer-workflow 0.4.0 → 0.5.0
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/README.md +7 -5
- package/bin/pincer.js +42 -5
- package/package.json +2 -2
- package/template/.agents/skills/pincer-code/SKILL.md +51 -4
- package/template/.agents/skills/pincer-evaluate/SKILL.md +29 -2
- package/template/.agents/skills/pincer-narrow/SKILL.md +11 -2
- package/template/.agents/skills/pincer-plan/SKILL.md +9 -1
- package/template/.agents/skills/pincer-release/SKILL.md +14 -3
- package/template/.agents/skills/pincer-status/SKILL.md +17 -2
- package/template/.claude/commands/pincer-code.md +51 -4
- package/template/.claude/commands/pincer-evaluate.md +29 -2
- package/template/.claude/commands/pincer-narrow.md +11 -2
- package/template/.claude/commands/pincer-plan.md +9 -1
- package/template/.claude/commands/pincer-release.md +14 -3
- package/template/.claude/commands/pincer-status.md +17 -2
- package/template/.claude/hooks/hook-policy.cjs +17 -3
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +51 -4
- package/template/.github/prompts/pincer-evaluate.prompt.md +29 -2
- package/template/.github/prompts/pincer-narrow.prompt.md +11 -2
- package/template/.github/prompts/pincer-plan.prompt.md +9 -1
- package/template/.github/prompts/pincer-release.prompt.md +14 -3
- package/template/.github/prompts/pincer-status.prompt.md +17 -2
- package/template/AGENTS.md +6 -0
- package/template/docs/dry-run-checklist.md +46 -3
- package/template/docs/release-checklist.md +2 -1
- package/template/docs/runtime-contracts.md +437 -0
- package/template/scripts/pincer-evidence.cjs +5 -223
- package/template/scripts/pincer-runtime/evidence.cjs +391 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/identity.cjs +146 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +289 -0
- package/template/scripts/pincer-runtime/migrate.cjs +127 -0
- package/template/scripts/pincer-runtime/parse.cjs +297 -0
- package/template/scripts/pincer-runtime/readiness.cjs +89 -0
- package/template/scripts/pincer-runtime/runner.cjs +224 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +292 -0
- package/template/scripts/pincer-runtime/status.cjs +358 -0
- package/template/scripts/pincer-runtime.cjs +350 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- package/template/scripts/pincer-ticket-lib.sh +0 -321
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — output sanitizer (docs/runtime-contracts.md, "Capture and
|
|
3
|
+
// sanitization"). A safety net applied to every captured line and to command
|
|
4
|
+
// display text before anything is persisted: documented secret patterns are
|
|
5
|
+
// replaced by [redacted] and counted. It is not a guarantee; checks must avoid
|
|
6
|
+
// printing secrets, and no raw log is ever exported.
|
|
7
|
+
const PATTERNS = [
|
|
8
|
+
// Bearer tokens first, so an `Authorization: Bearer <token>` header keeps the
|
|
9
|
+
// token, not the word Bearer, as the redacted value.
|
|
10
|
+
{ re: /((?:bearer|basic|token|digest)\s+)[A-Za-z0-9\-._~+/=]+/gi, replace: '$1[redacted]' },
|
|
11
|
+
// key/secret/password/token/authorization assignments and JSON fields; a
|
|
12
|
+
// quoted value is redacted up to its closing quote, an unquoted one to the
|
|
13
|
+
// next whitespace or separator. Bounded quantifiers: an unbounded prefix would
|
|
14
|
+
// backtrack quadratically on long lines.
|
|
15
|
+
{ re: /((?:^|[^A-Za-z0-9_.-])[A-Za-z0-9_.-]{0,64}?(?:key|secret|password|passwd|token|authorization)[A-Za-z0-9_.-]{0,64}["']?[ \t]{0,8}[:=][ \t]{0,8})(?:"([^"\n]{0,512})"|'([^'\n]{0,512})'|([^\s"',;]+))/gi, replace: (m, head, dq, sq, bare) => `${head}${dq !== undefined ? '"[redacted]"' : sq !== undefined ? "'[redacted]'" : '[redacted]'}` },
|
|
16
|
+
{ re: /AKIA[0-9A-Z]{16}/g, replace: '[redacted]' },
|
|
17
|
+
{ re: /gh[pousr]_[A-Za-z0-9]{20,}/g, replace: '[redacted]' },
|
|
18
|
+
];
|
|
19
|
+
const PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
20
|
+
const PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
|
|
21
|
+
|
|
22
|
+
// Stateful: PEM private key blocks span lines. `state` is a plain object the
|
|
23
|
+
// caller keeps per stream.
|
|
24
|
+
function sanitizeLine(line, state = {}) {
|
|
25
|
+
let redactions = 0;
|
|
26
|
+
if (state.inPem) {
|
|
27
|
+
if (PEM_END.test(line)) state.inPem = false;
|
|
28
|
+
return { text: '[redacted]', redactions: 1 };
|
|
29
|
+
}
|
|
30
|
+
if (PEM_BEGIN.test(line)) {
|
|
31
|
+
state.inPem = !PEM_END.test(line);
|
|
32
|
+
return { text: '[redacted]', redactions: 1 };
|
|
33
|
+
}
|
|
34
|
+
let text = line;
|
|
35
|
+
for (const { re, replace } of PATTERNS) {
|
|
36
|
+
text = text.replace(re, (...args) => {
|
|
37
|
+
redactions++;
|
|
38
|
+
return typeof replace === 'function' ? replace(...args) : replace.replace(/\$(\d)/g, (_, n) => args[Number(n)]);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return { text, redactions };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sanitizeText(text) {
|
|
45
|
+
const state = {};
|
|
46
|
+
let redactions = 0;
|
|
47
|
+
const lines = String(text).split('\n').map(line => { const r = sanitizeLine(line, state); redactions += r.redactions; return r.text; });
|
|
48
|
+
return { text: lines.join('\n'), redactions };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A Verification block must not carry an inline secret literal: the block is
|
|
52
|
+
// recorded as display text and would persist the value. `$references` and
|
|
53
|
+
// empty values are fine.
|
|
54
|
+
// An assignment anywhere in the line (after a word boundary, so `env TOKEN=x cmd`
|
|
55
|
+
// and `true; TOKEN=x cmd` count); `$VAR`, `"$VAR"`, `$(…)` and backtick
|
|
56
|
+
// references are allowed, literals are not.
|
|
57
|
+
const INLINE_SECRET = /(?:^|[\s;&|(])(?:export\s+)?[A-Za-z0-9_]*(?:KEY|SECRET|PASSWORD|PASSWD|TOKEN)[A-Za-z0-9_]*=(?!["']?\$|["']?`|\s|$)\S/i;
|
|
58
|
+
function inlineSecretLine(commands) {
|
|
59
|
+
const index = commands.findIndex(line => INLINE_SECRET.test(line));
|
|
60
|
+
return index === -1 ? null : index + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { sanitizeLine, sanitizeText, inlineSecretLine, PATTERNS };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — the source manifest (docs/runtime-contracts.md, "Source
|
|
3
|
+
// manifest"): a versioned SHA-256 identity of the inputs a verification ran
|
|
4
|
+
// against. Tracked plus untracked non-ignored files, modes, deletions; tickets
|
|
5
|
+
// and PRDs normalized; fixed and configured exclusions; secret paths, symlinks
|
|
6
|
+
// and submodules refused rather than silently omitted.
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const parse = require('./parse.cjs');
|
|
10
|
+
const { atomicWrite, tryGit } = require('./fsutil.cjs');
|
|
11
|
+
|
|
12
|
+
const SCHEMA = 1;
|
|
13
|
+
const FIXED_EXCLUDES = ['.git/', '.pincer/', 'NOTES.md', '.prd/evidence/', '.prd/changes/'];
|
|
14
|
+
const EXCLUDE_FILE = '.prd/source-exclude';
|
|
15
|
+
const PROTECTED_PREFIXES = ['tickets/'];
|
|
16
|
+
const NUL = String.fromCharCode(0);
|
|
17
|
+
|
|
18
|
+
const isTicket = p => /^tickets\/T-[0-9]+.*\.md$/.test(p);
|
|
19
|
+
const isPrd = p => parse.PRD_REF.test(p);
|
|
20
|
+
const isSecret = p => { const b = path.posix.basename(p); return b === '.env' || (b.startsWith('.env.') && b !== '.env.example'); };
|
|
21
|
+
const fixedExcluded = p => FIXED_EXCLUDES.some(rule => (rule.endsWith('/') ? p.startsWith(rule) : p === rule));
|
|
22
|
+
|
|
23
|
+
// Glob -> RegExp: `**` spans directories, `*` stays within a segment, `?` is one
|
|
24
|
+
// character; a pattern without `/` matches a basename anywhere; a trailing `/`
|
|
25
|
+
// matches a directory prefix.
|
|
26
|
+
function compilePattern(pattern) {
|
|
27
|
+
const escape = s => s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
const glob = s => escape(s).replace(/\*\*/g, 'DOUBLESTAR').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/DOUBLESTAR/g, '.*');
|
|
29
|
+
if (pattern.endsWith('/')) return new RegExp(`^${glob(pattern.slice(0, -1))}/`);
|
|
30
|
+
if (!pattern.includes('/')) return new RegExp(`(^|/)${glob(pattern)}$`);
|
|
31
|
+
return new RegExp(`^${glob(pattern.replace(/^\//, ''))}$`);
|
|
32
|
+
}
|
|
33
|
+
function readExcludeFile(root) {
|
|
34
|
+
const file = path.join(root, EXCLUDE_FILE);
|
|
35
|
+
if (!fs.existsSync(file)) return [];
|
|
36
|
+
return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const splitZ = buffer => (buffer ? buffer.toString('utf8').split(NUL).filter(Boolean) : []);
|
|
40
|
+
|
|
41
|
+
// Compute the manifest. Returns { schema, digest, files, excluded, limitations, problems }.
|
|
42
|
+
function snapshot(root) {
|
|
43
|
+
const problems = [];
|
|
44
|
+
const limitations = [];
|
|
45
|
+
const excluded = [];
|
|
46
|
+
const toplevel = tryGit(root, ['rev-parse', '--show-toplevel']);
|
|
47
|
+
if (toplevel.error) {
|
|
48
|
+
return { schema: SCHEMA, digest: null, files: [], excluded, limitations, problems: [{ code: 'UNSUPPORTED_INPUT', detail: 'not inside a git repository; the source identity needs git' }] };
|
|
49
|
+
}
|
|
50
|
+
const tracked = new Map(); // path -> mode from the index
|
|
51
|
+
const staged = tryGit(root, ['ls-files', '-z', '-s'], { buffer: true });
|
|
52
|
+
if (staged.error) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `git ls-files failed: ${staged.error}` });
|
|
53
|
+
else {
|
|
54
|
+
for (const entry of splitZ(staged.out)) {
|
|
55
|
+
const match = entry.match(/^(\d{6}) [0-9a-f]{40} \d\t([\s\S]*)$/);
|
|
56
|
+
if (match) tracked.set(match[2], match[1]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const listed = tryGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { buffer: true });
|
|
60
|
+
const paths = listed.error ? [] : [...new Set(splitZ(listed.out))];
|
|
61
|
+
const deleted = new Set(splitZ(tryGit(root, ['ls-files', '-z', '--deleted'], { buffer: true }).out));
|
|
62
|
+
const ignoredDirs = splitZ(tryGit(root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory'], { buffer: true }).out)
|
|
63
|
+
.filter(p => !p.startsWith('.pincer/') && p !== '.pincer/');
|
|
64
|
+
if (ignoredDirs.length) {
|
|
65
|
+
const shown = ignoredDirs.slice(0, 10).join(', ') + (ignoredDirs.length > 10 ? `, ... (${ignoredDirs.length} ignored paths)` : '');
|
|
66
|
+
limitations.push(`ignored paths are not part of the source identity: ${shown}`);
|
|
67
|
+
}
|
|
68
|
+
limitations.push('external services and installed toolchains are not part of the source identity');
|
|
69
|
+
|
|
70
|
+
const patterns = readExcludeFile(root).map(pattern => ({ pattern, regex: compilePattern(pattern) }));
|
|
71
|
+
const entries = [];
|
|
72
|
+
for (const p of paths) {
|
|
73
|
+
if (fixedExcluded(p)) { excluded.push(p); continue; }
|
|
74
|
+
if (isSecret(p)) { problems.push({ code: 'SECRET_PATH', detail: `${p}: secret file in the source view; remove it or ignore it (its contents were not read)` }); continue; }
|
|
75
|
+
const mode = tracked.get(p);
|
|
76
|
+
if (mode === '160000') { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: submodules are not supported` }); continue; }
|
|
77
|
+
const abs = path.join(root, p);
|
|
78
|
+
let stat = null;
|
|
79
|
+
try { stat = fs.lstatSync(abs); } catch { stat = null; }
|
|
80
|
+
if (mode === '120000' || (stat && stat.isSymbolicLink())) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: symbolic links are not supported` }); continue; }
|
|
81
|
+
const rule = patterns.find(({ regex }) => regex.test(p));
|
|
82
|
+
if (rule) {
|
|
83
|
+
if (PROTECTED_PREFIXES.some(prefix => p.startsWith(prefix)) || isPrd(p) || p === EXCLUDE_FILE) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches protected path ${p}` });
|
|
84
|
+
else if (tracked.has(p)) problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${EXCLUDE_FILE}: pattern "${rule.pattern}" matches tracked file ${p}; exclusions may cover untracked paths only` });
|
|
85
|
+
else excluded.push(p);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (deleted.has(p) || !stat) { entries.push({ path: p, deleted: true }); continue; }
|
|
89
|
+
if (stat.isDirectory()) continue;
|
|
90
|
+
let content;
|
|
91
|
+
try { content = fs.readFileSync(abs); } catch (error) { problems.push({ code: 'UNSUPPORTED_INPUT', detail: `${p}: cannot read (${error.code || error.message})` }); continue; }
|
|
92
|
+
let sha;
|
|
93
|
+
if (isTicket(p)) sha = parse.sha256(parse.normalizeTicket(content.toString('utf8')));
|
|
94
|
+
else if (isPrd(p)) sha = parse.sha256(parse.normalizePrd(content.toString('utf8')));
|
|
95
|
+
else sha = parse.sha256(content);
|
|
96
|
+
entries.push({ path: p, sha256: sha, mode: (stat.mode & 0o111) ? '100755' : '100644' });
|
|
97
|
+
}
|
|
98
|
+
entries.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
|
|
99
|
+
const digestInput = entries.map(e => `${e.deleted ? 'deleted' : e.mode} ${e.deleted ? '-' : e.sha256} ${e.path}\n`).join('');
|
|
100
|
+
const digest = problems.length ? null : parse.sha256(digestInput);
|
|
101
|
+
return { schema: SCHEMA, digest, files: entries, excluded, limitations, problems };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function storeManifest(root, manifest) {
|
|
105
|
+
if (!manifest.digest) throw new Error('cannot store a manifest with problems');
|
|
106
|
+
const file = path.join(root, '.pincer', 'runtime', 'manifests', `${manifest.digest}.json`);
|
|
107
|
+
if (!fs.existsSync(file)) atomicWrite(file, `${JSON.stringify(manifest, null, 2)}\n`, { journalDir: path.join(root, '.pincer', 'runtime', 'journal') });
|
|
108
|
+
return `.pincer/runtime/manifests/${manifest.digest}.json`;
|
|
109
|
+
}
|
|
110
|
+
function readManifest(root, digest) {
|
|
111
|
+
const file = path.join(root, '.pincer', 'runtime', 'manifests', `${digest}.json`);
|
|
112
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Paths that differ between two manifests, for diagnostics.
|
|
116
|
+
function diffManifests(before, after) {
|
|
117
|
+
const a = new Map(before.files.map(e => [e.path, e]));
|
|
118
|
+
const b = new Map(after.files.map(e => [e.path, e]));
|
|
119
|
+
const changed = [];
|
|
120
|
+
for (const [p, e] of b) {
|
|
121
|
+
const prior = a.get(p);
|
|
122
|
+
if (!prior) changed.push(`${p} (added)`);
|
|
123
|
+
else if (JSON.stringify(prior) !== JSON.stringify(e)) changed.push(`${p}${e.deleted ? ' (deleted)' : prior.mode !== e.mode ? ' (mode)' : ''}`);
|
|
124
|
+
}
|
|
125
|
+
for (const p of a.keys()) if (!b.has(p)) changed.push(`${p} (removed)`);
|
|
126
|
+
return changed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { SCHEMA, FIXED_EXCLUDES, EXCLUDE_FILE, snapshot, storeManifest, readManifest, diffManifests, compilePattern, isSecret };
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — local state store (docs/runtime-contracts.md, "Attempts").
|
|
3
|
+
// Everything under <root>/.pincer/runtime/ belongs to this worktree: an index
|
|
4
|
+
// with the authoritative sequence and current pointers, one record per attempt,
|
|
5
|
+
// content-addressed source manifests, an exclusive lock directory and a journal
|
|
6
|
+
// for atomic replacement. Nothing here is a second editable truth.
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
const { nowIso, atomicWrite, readJson } = require('./fsutil.cjs');
|
|
12
|
+
|
|
13
|
+
const RUNTIME_DIR = '.pincer/runtime';
|
|
14
|
+
const INDEX_SCHEMA = 1;
|
|
15
|
+
const LOCK_WAIT_MS = 10000;
|
|
16
|
+
const LOCK_POLL_MS = 100;
|
|
17
|
+
const GRACE_MS = 5000;
|
|
18
|
+
|
|
19
|
+
function paths(root) {
|
|
20
|
+
const dir = path.join(root, RUNTIME_DIR);
|
|
21
|
+
return {
|
|
22
|
+
dir,
|
|
23
|
+
index: path.join(dir, 'index.json'),
|
|
24
|
+
attempts: path.join(dir, 'attempts'),
|
|
25
|
+
manifests: path.join(dir, 'manifests'),
|
|
26
|
+
lock: path.join(dir, 'lock'),
|
|
27
|
+
owner: path.join(dir, 'lock', 'owner.json'),
|
|
28
|
+
journal: path.join(dir, 'journal'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function ensureLayout(root) {
|
|
32
|
+
const p = paths(root);
|
|
33
|
+
for (const dir of [p.dir, p.attempts, p.manifests, p.journal]) fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
const exists = root => fs.existsSync(paths(root).dir);
|
|
37
|
+
|
|
38
|
+
const emptyIndex = () => ({ schema: INDEX_SCHEMA, sequence: 0, current: {}, running: [] });
|
|
39
|
+
function validateIndex(doc) {
|
|
40
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'index must be a JSON object';
|
|
41
|
+
if (doc.schema !== INDEX_SCHEMA) return `unsupported index schema ${JSON.stringify(doc.schema)}`;
|
|
42
|
+
if (!Number.isInteger(doc.sequence) || doc.sequence < 0) return 'sequence must be a non-negative integer';
|
|
43
|
+
if (!doc.current || typeof doc.current !== 'object' || Array.isArray(doc.current)) return 'current must be an object';
|
|
44
|
+
if (!Array.isArray(doc.running) || !doc.running.every(id => typeof id === 'string')) return 'running must be an array of attempt IDs';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
// Read the index; a missing file is an empty index, a malformed one is an error
|
|
48
|
+
// (code INVALID) and is never overwritten by inspection.
|
|
49
|
+
function readIndex(root) {
|
|
50
|
+
const p = paths(root);
|
|
51
|
+
if (!fs.existsSync(p.index)) return { index: emptyIndex(), missing: true };
|
|
52
|
+
const read = readJson(p.index);
|
|
53
|
+
if (read.error) return { error: `${RUNTIME_DIR}/index.json: ${read.error}`, code: 'INVALID' };
|
|
54
|
+
const invalid = validateIndex(read.data);
|
|
55
|
+
if (invalid) return { error: `${RUNTIME_DIR}/index.json: ${invalid}`, code: 'INVALID' };
|
|
56
|
+
return { index: read.data };
|
|
57
|
+
}
|
|
58
|
+
function writeIndex(root, index) {
|
|
59
|
+
const p = ensureLayout(root);
|
|
60
|
+
atomicWrite(p.index, `${JSON.stringify(index, null, 2)}\n`, { journalDir: p.journal });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isAlive(pid) {
|
|
64
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
65
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
66
|
+
}
|
|
67
|
+
function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
68
|
+
|
|
69
|
+
class StateBusy extends Error {
|
|
70
|
+
constructor(message, owner) { super(message); this.code = 'STATE_BUSY'; this.owner = owner; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Acquire the exclusive lock: mkdir is atomic; a lock whose owner pid is dead on
|
|
74
|
+
// this host is reclaimed with a diagnostic; a live or foreign-host owner is never
|
|
75
|
+
// stolen. Returns a release function.
|
|
76
|
+
function acquireLock(root, { waitMs, command = 'runtime', log = message => process.stderr.write(`${message}\n`) } = {}) {
|
|
77
|
+
const p = ensureLayout(root);
|
|
78
|
+
const bound = waitMs ?? (Number(process.env.PINCER_LOCK_WAIT_MS) > 0 ? Number(process.env.PINCER_LOCK_WAIT_MS) : LOCK_WAIT_MS);
|
|
79
|
+
const deadline = Date.now() + bound;
|
|
80
|
+
const ownerJson = () => `${JSON.stringify({ pid: process.pid, ppid: process.ppid, host: os.hostname(), started: nowIso(), command }, null, 2)}\n`;
|
|
81
|
+
for (;;) {
|
|
82
|
+
// Build the lock directory with its owner file in a private location and
|
|
83
|
+
// rename it into place: a directory rename onto an existing lock fails, so
|
|
84
|
+
// acquisition is atomic and a waiter never sees an owner-less lock.
|
|
85
|
+
const staging = `${p.lock}.new.${process.pid}.${crypto.randomBytes(3).toString('hex')}`;
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(staging);
|
|
88
|
+
fs.writeFileSync(path.join(staging, 'owner.json'), ownerJson());
|
|
89
|
+
fs.renameSync(staging, p.lock);
|
|
90
|
+
let released = false;
|
|
91
|
+
return () => { if (released) return; released = true; try { fs.rmSync(p.lock, { recursive: true, force: true }); } catch { /* already gone */ } };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* nothing staged */ }
|
|
94
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EISDIR', 'EPERM'].includes(error.code)) throw error;
|
|
95
|
+
}
|
|
96
|
+
const owner = readJson(p.owner).data || null;
|
|
97
|
+
if (owner && owner.host === os.hostname() && !isAlive(owner.pid)) {
|
|
98
|
+
// Claim the stale directory by renaming it first; only the process that
|
|
99
|
+
// won the rename removes it, after confirming the owner is still the
|
|
100
|
+
// dead one it read (another waiter may have replaced the lock meanwhile).
|
|
101
|
+
const claim = `${p.lock}.stale.${process.pid}.${crypto.randomBytes(3).toString('hex')}`;
|
|
102
|
+
try { fs.renameSync(p.lock, claim); } catch { sleep(LOCK_POLL_MS); continue; }
|
|
103
|
+
const claimed = readJson(path.join(claim, 'owner.json')).data || null;
|
|
104
|
+
if (claimed && claimed.host === os.hostname() && !isAlive(claimed.pid)) {
|
|
105
|
+
log(`pincer: reclaiming stale lock left by pid ${claimed.pid} (${claimed.command || 'unknown command'}, started ${claimed.started || '?'}); the process is no longer running`);
|
|
106
|
+
try { fs.rmSync(claim, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
107
|
+
} else {
|
|
108
|
+
// A live holder's lock was renamed by mistake: give it back.
|
|
109
|
+
try { fs.renameSync(claim, p.lock); } catch { try { fs.rmSync(claim, { recursive: true, force: true }); } catch { /* gone */ } }
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (Date.now() >= deadline) {
|
|
114
|
+
const who = owner ? `pid ${owner.pid} on ${owner.host} (${owner.command || 'unknown command'}, started ${owner.started || '?'})` : 'an unknown owner (no owner.json)';
|
|
115
|
+
throw new StateBusy(`${RUNTIME_DIR}/lock is held by ${who}; retry, or run recover if that process died`, owner);
|
|
116
|
+
}
|
|
117
|
+
sleep(LOCK_POLL_MS);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function withLock(root, fn, options) {
|
|
121
|
+
const release = acquireLock(root, options);
|
|
122
|
+
try { return fn(); } finally { release(); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const compactTimestamp = () => nowIso().replace(/[-:]/g, '');
|
|
126
|
+
const attemptId = sequence => `${String(sequence).padStart(6, '0')}-${compactTimestamp()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
127
|
+
const contextKey = context => (context.kind === 'candidate' ? `candidate:${context.candidate}:${context.check}` : `ticket:${context.change}:${context.ticket}`);
|
|
128
|
+
|
|
129
|
+
const OUTCOMES = ['running', 'passed', 'failed', 'interrupted', 'timed_out', 'error'];
|
|
130
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
131
|
+
// Validate an attempt record read from disk against record schema 1 and, when
|
|
132
|
+
// given, the context `key` it is read for and the `pointedId` the index names.
|
|
133
|
+
// A record that is incomplete, malformed, written for another context or
|
|
134
|
+
// carrying another id than the pointer is never evidence: readiness reports
|
|
135
|
+
// ATTEMPT_ERROR and export refuses. An `interrupted` record may lack log
|
|
136
|
+
// digests (a `recover` that predates their recording). Returns null or a problem.
|
|
137
|
+
function validateAttempt(a, key, pointedId) {
|
|
138
|
+
const obj = v => v && typeof v === 'object' && !Array.isArray(v);
|
|
139
|
+
const str = v => typeof v === 'string' && v.length > 0;
|
|
140
|
+
const digestOrNull = v => v === null || (typeof v === 'string' && SHA256.test(v));
|
|
141
|
+
if (!obj(a)) return 'record is not a JSON object';
|
|
142
|
+
const bad = [];
|
|
143
|
+
if (a.schema !== 1) bad.push('schema');
|
|
144
|
+
if (a.runtime !== 1) bad.push('runtime');
|
|
145
|
+
if (!str(a.id)) bad.push('id');
|
|
146
|
+
if (!Number.isInteger(a.sequence) || a.sequence < 1) bad.push('sequence');
|
|
147
|
+
const c = a.context;
|
|
148
|
+
if (!obj(c) || !['ticket', 'candidate'].includes(c.kind) || !str(c.change) || !str(c.prd) || !str(c.prd_revision) || !str(c.base)
|
|
149
|
+
|| (c.kind === 'ticket' ? !str(c.ticket) || !str(c.ticket_digest) : !str(c.candidate) || !str(c.check))) bad.push('context');
|
|
150
|
+
if (!obj(a.check) || typeof a.check.digest !== 'string' || !SHA256.test(a.check.digest) || typeof a.check.display !== 'string'
|
|
151
|
+
|| !Number.isInteger(a.check.timeout_seconds) || a.check.timeout_seconds <= 0) bad.push('check');
|
|
152
|
+
if (!OUTCOMES.includes(a.outcome)) bad.push('outcome');
|
|
153
|
+
const finished = OUTCOMES.includes(a.outcome) && a.outcome !== 'running';
|
|
154
|
+
if (!(a.exit_code === null || Number.isInteger(a.exit_code))) bad.push('exit_code');
|
|
155
|
+
if (!(a.signal === null || str(a.signal))) bad.push('signal');
|
|
156
|
+
if (!obj(a.runner) || !str(a.runner.shell) || !Array.isArray(a.runner.args)) bad.push('runner');
|
|
157
|
+
if (!str(a.cwd)) bad.push('cwd');
|
|
158
|
+
if (!obj(a.environment)) bad.push('environment');
|
|
159
|
+
if (!str(a.started)) bad.push('started');
|
|
160
|
+
if (finished ? !str(a.finished) : a.finished !== null) bad.push('finished');
|
|
161
|
+
if (!obj(a.source) || !digestOrNull(a.source.before) || !digestOrNull(a.source.after)) bad.push('source');
|
|
162
|
+
if (!obj(a.artifacts)) bad.push('artifacts');
|
|
163
|
+
else {
|
|
164
|
+
for (const k of ['stdout', 'stderr']) {
|
|
165
|
+
const info = a.artifacts[k];
|
|
166
|
+
const expectedPath = str(a.id) ? `${RUNTIME_DIR}/attempts/${a.id}/${k}.log` : null;
|
|
167
|
+
const digestRequired = finished && a.outcome !== 'interrupted';
|
|
168
|
+
if (!obj(info) || info.path !== expectedPath || !(digestRequired ? typeof info.sha256 === 'string' && SHA256.test(info.sha256) : digestOrNull(info.sha256))) bad.push(`artifacts.${k}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (bad.length) return `record is incomplete or malformed: ${bad.join(', ')}`;
|
|
172
|
+
if (key && contextKey(c) !== key) return `record belongs to ${contextKey(c)}, not ${key}`;
|
|
173
|
+
if (pointedId && a.id !== pointedId) return `record ${a.id} is not the attempt the index points at (${pointedId})`;
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
// Compare an attempt's captured logs with local state: `missing` when a log is
|
|
177
|
+
// gone, `altered` when its content no longer matches the digest the record
|
|
178
|
+
// carries. Annotates and returns the record; never writes.
|
|
179
|
+
function inspectArtifacts(root, attempt) {
|
|
180
|
+
if (!attempt || !attempt.artifacts || typeof attempt.artifacts !== 'object') return attempt;
|
|
181
|
+
for (const k of ['stdout', 'stderr']) {
|
|
182
|
+
const info = attempt.artifacts[k];
|
|
183
|
+
if (!info || typeof info !== 'object' || typeof info.path !== 'string') continue;
|
|
184
|
+
let data;
|
|
185
|
+
try { data = fs.readFileSync(path.join(root, info.path)); } catch { attempt.artifacts[k] = { ...info, missing: true }; continue; }
|
|
186
|
+
if (attempt.outcome !== 'running' && typeof info.sha256 === 'string' && crypto.createHash('sha256').update(data).digest('hex') !== info.sha256) attempt.artifacts[k] = { ...info, altered: true };
|
|
187
|
+
}
|
|
188
|
+
return attempt;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function attemptFile(root, id) { return path.join(paths(root).attempts, `${id}.json`); }
|
|
192
|
+
function writeAttempt(root, attempt) {
|
|
193
|
+
const p = ensureLayout(root);
|
|
194
|
+
atomicWrite(attemptFile(root, attempt.id), `${JSON.stringify(attempt, null, 2)}\n`, { journalDir: p.journal });
|
|
195
|
+
}
|
|
196
|
+
function readAttempt(root, id) {
|
|
197
|
+
const read = readJson(attemptFile(root, id));
|
|
198
|
+
if (read.error) return { error: `${RUNTIME_DIR}/attempts/${id}.json: ${read.error}` };
|
|
199
|
+
return { attempt: read.data };
|
|
200
|
+
}
|
|
201
|
+
function listAttempts(root, key) {
|
|
202
|
+
const p = paths(root);
|
|
203
|
+
if (!fs.existsSync(p.attempts)) return [];
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const name of fs.readdirSync(p.attempts)) {
|
|
206
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
207
|
+
const read = readJson(path.join(p.attempts, name));
|
|
208
|
+
if (read.error || !read.data || typeof read.data !== 'object') continue;
|
|
209
|
+
if (!key || contextKey(read.data.context || {}) === key) out.push(read.data);
|
|
210
|
+
}
|
|
211
|
+
return out.sort((a, b) => (a.sequence || 0) - (b.sequence || 0));
|
|
212
|
+
}
|
|
213
|
+
// The attempt the index points at for a context. The pointer is the authority:
|
|
214
|
+
// a pointed-at record that is missing or unreadable yields null (readiness then
|
|
215
|
+
// reports EVIDENCE_MISSING) rather than an older record that may have passed.
|
|
216
|
+
// Only when the index carries no pointer at all is the highest sequence used.
|
|
217
|
+
function latestAttempt(root, key, index) {
|
|
218
|
+
const idx = index || readIndex(root).index;
|
|
219
|
+
const pointed = idx && idx.current && idx.current[key];
|
|
220
|
+
if (pointed) {
|
|
221
|
+
const read = readAttempt(root, pointed);
|
|
222
|
+
return read.attempt || null;
|
|
223
|
+
}
|
|
224
|
+
const all = listAttempts(root, key);
|
|
225
|
+
return all.length ? all[all.length - 1] : null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Diagnose and repair after a crash: finalize running attempts whose owner is
|
|
229
|
+
// dead on this host as interrupted, report the rest, remove stray journal files.
|
|
230
|
+
// Never promotes an unfinished record to passed.
|
|
231
|
+
function recover(root, options = {}) {
|
|
232
|
+
return withLock(root, () => {
|
|
233
|
+
const p = paths(root);
|
|
234
|
+
const read = readIndex(root);
|
|
235
|
+
if (read.error) { const e = new Error(read.error); e.code = 'INVALID'; throw e; }
|
|
236
|
+
const index = read.index;
|
|
237
|
+
const report = { finalized: [], live: [], foreign: [], missing: [], journal: [] };
|
|
238
|
+
const stillRunning = [];
|
|
239
|
+
for (const id of index.running) {
|
|
240
|
+
const attempt = readAttempt(root, id).attempt;
|
|
241
|
+
if (!attempt) { report.missing.push(id); continue; }
|
|
242
|
+
if (attempt.outcome !== 'running') continue;
|
|
243
|
+
const owner = attempt.owner || {};
|
|
244
|
+
if (owner.host !== os.hostname()) { report.foreign.push({ id, owner }); stillRunning.push(id); continue; }
|
|
245
|
+
if (isAlive(owner.pid)) { report.live.push({ id, owner }); stillRunning.push(id); continue; }
|
|
246
|
+
attempt.outcome = 'interrupted';
|
|
247
|
+
attempt.finished = nowIso();
|
|
248
|
+
attempt.limitations = [...(attempt.limitations || []), `finalized as interrupted by recover: owner pid ${owner.pid} was no longer running`];
|
|
249
|
+
// Record what the dead runner captured so the logs are bound to the
|
|
250
|
+
// record like every finalized attempt's (a missing log stays unrecorded).
|
|
251
|
+
for (const k of ['stdout', 'stderr']) {
|
|
252
|
+
const info = attempt.artifacts && attempt.artifacts[k];
|
|
253
|
+
if (!info || typeof info.path !== 'string') continue;
|
|
254
|
+
try { const data = fs.readFileSync(path.join(root, info.path)); attempt.artifacts[k] = { ...info, sha256: crypto.createHash('sha256').update(data).digest('hex'), bytes: data.length }; } catch { /* leave as recorded */ }
|
|
255
|
+
}
|
|
256
|
+
const childPid = attempt.child && attempt.child.pid;
|
|
257
|
+
if (childPid && isAlive(childPid)) {
|
|
258
|
+
// Terminate the orphaned group and wait for it here: an unref'd timer
|
|
259
|
+
// would never fire before the command exits.
|
|
260
|
+
const signalGroup = signal => { try { process.kill(-childPid, signal); } catch { try { process.kill(childPid, signal); } catch { /* gone */ } } };
|
|
261
|
+
signalGroup('SIGTERM');
|
|
262
|
+
const deadline = Date.now() + GRACE_MS;
|
|
263
|
+
while (isAlive(childPid) && Date.now() < deadline) sleep(LOCK_POLL_MS);
|
|
264
|
+
if (isAlive(childPid)) {
|
|
265
|
+
signalGroup('SIGKILL');
|
|
266
|
+
const hardDeadline = Date.now() + 2000;
|
|
267
|
+
while (isAlive(childPid) && Date.now() < hardDeadline) sleep(LOCK_POLL_MS);
|
|
268
|
+
attempt.limitations.push(`orphaned child process group ${childPid} ignored SIGTERM for ${GRACE_MS / 1000} s and was sent SIGKILL${isAlive(childPid) ? ' (still alive when recover returned)' : ''}`);
|
|
269
|
+
} else attempt.limitations.push(`orphaned child process group ${childPid} was sent SIGTERM and exited`);
|
|
270
|
+
}
|
|
271
|
+
writeAttempt(root, attempt);
|
|
272
|
+
report.finalized.push(id);
|
|
273
|
+
}
|
|
274
|
+
if (fs.existsSync(p.journal)) {
|
|
275
|
+
for (const name of fs.readdirSync(p.journal)) {
|
|
276
|
+
const file = path.join(p.journal, name);
|
|
277
|
+
try { fs.rmSync(file, { force: true }); report.journal.push(`${RUNTIME_DIR}/journal/${name}`); } catch { /* ignore */ }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (stillRunning.length !== index.running.length || report.missing.length) {
|
|
281
|
+
index.running = stillRunning;
|
|
282
|
+
writeIndex(root, index);
|
|
283
|
+
}
|
|
284
|
+
return report;
|
|
285
|
+
}, { command: 'recover', ...options });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
RUNTIME_DIR, INDEX_SCHEMA, LOCK_WAIT_MS, StateBusy,
|
|
290
|
+
paths, ensureLayout, exists, emptyIndex, readIndex, writeIndex, isAlive,
|
|
291
|
+
acquireLock, withLock, attemptId, contextKey, validateAttempt, inspectArtifacts, writeAttempt, readAttempt, listAttempts, latestAttempt, recover,
|
|
292
|
+
};
|