@nickysagan/issue-orchestrator 0.1.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 +314 -0
- package/bin/supervisor.mjs +614 -0
- package/package.json +19 -0
- package/src/labels.mjs +68 -0
- package/src/managedContainer.mjs +120 -0
- package/src/reviewGate.mjs +308 -0
- package/src/reviewMarker.mjs +80 -0
- package/src/workerLogs.mjs +175 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Durable per-attempt worker logs.
|
|
2
|
+
//
|
|
3
|
+
// A tmux worker's output lives and dies with its pane, so a window that
|
|
4
|
+
// vanishes takes the only evidence of why with it. Every launch reserves a
|
|
5
|
+
// file here and streams its combined output into it, which lets the supervisor
|
|
6
|
+
// surface a bounded, redacted tail when it later notices the worker is gone.
|
|
7
|
+
//
|
|
8
|
+
// Everything is best effort: a log that cannot be written must never cost a
|
|
9
|
+
// worker, so every operation degrades to `null` rather than throwing.
|
|
10
|
+
import { mkdir as fsMkdir, readFile as fsReadFile, readdir as fsReaddir, unlink as fsUnlink } from "node:fs/promises";
|
|
11
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
// Under the *common* git dir, so a log outlives the issue worktree it
|
|
14
|
+
// describes, needs no `.gitignore` entry, and can never be tracked.
|
|
15
|
+
export const LOG_DIR_NAME = join("issue-orchestrator", "logs");
|
|
16
|
+
|
|
17
|
+
// Retention: at most this many attempt files per (role, issue) survive a
|
|
18
|
+
// launch, counting the attempt about to be written. The issue requires a bound
|
|
19
|
+
// preserving at least the latest three.
|
|
20
|
+
export const KEEP_ATTEMPTS = 3;
|
|
21
|
+
|
|
22
|
+
// Tail bounds, sized to sit comfortably inside a GitHub comment.
|
|
23
|
+
export const TAIL_LINES = 40;
|
|
24
|
+
export const TAIL_CHARS = 4000;
|
|
25
|
+
|
|
26
|
+
// Roles are an allowlist, not a passthrough: a later repair worker adds one
|
|
27
|
+
// string here and needs no other change, while no caller-supplied value can
|
|
28
|
+
// ever reach the path.
|
|
29
|
+
export const WORKER_ROLES = new Set(["issue", "review"]);
|
|
30
|
+
|
|
31
|
+
const REDACTIONS = [
|
|
32
|
+
// Whole PEM blocks first, before any inner rule can fragment them. The body
|
|
33
|
+
// is bounded well above any real key so an unmatched BEGIN cannot make the
|
|
34
|
+
// lazy scan backtrack across the whole log.
|
|
35
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,20000}?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED]"],
|
|
36
|
+
// A worker killed mid-print leaves a BEGIN with no END, which the balanced
|
|
37
|
+
// rule cannot match. Everything after that marker is key material, so the
|
|
38
|
+
// only safe reading is to redact to the end of the text.
|
|
39
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*/g, "[REDACTED]"],
|
|
40
|
+
[/\bgithub_pat_[A-Za-z0-9_]{10,}/g, "[REDACTED]"],
|
|
41
|
+
[/\bgh[pousr]_[A-Za-z0-9_]{10,}/g, "[REDACTED]"],
|
|
42
|
+
[/(\bauthorization\s*:\s*)(bearer|token)\s+\S+/gi, "$1$2 [REDACTED]"],
|
|
43
|
+
[/x-access-token:[^@\s]+@/gi, "x-access-token:[REDACTED]@"],
|
|
44
|
+
// Inherited environment values. The name must be shell-style uppercase so
|
|
45
|
+
// ordinary prose and YAML (`key: value`) stay readable. An unquoted value
|
|
46
|
+
// runs to the end of the line rather than to the first space, because
|
|
47
|
+
// `GH_TOKEN=my secret value` is one secret, not one word plus prose; the line
|
|
48
|
+
// already contains a secret, so over-redacting its remainder is the safe
|
|
49
|
+
// trade. Only horizontal whitespace surrounds the separator, which keeps
|
|
50
|
+
// every alternative line-scoped and stops the rest-of-line match from
|
|
51
|
+
// reaching past a newline into the next, innocent line.
|
|
52
|
+
[
|
|
53
|
+
/\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*)([^\S\n]*[=:][^\S\n]*)("[^"\n]*"|'[^'\n]*'|[^\n]*)/g,
|
|
54
|
+
"$1$2[REDACTED]",
|
|
55
|
+
],
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Applied when output is surfaced rather than when it is written: the criterion
|
|
59
|
+
// is about surfaced tails, and filtering the live stream would add a process to
|
|
60
|
+
// every worker command for the chance of mangling a diagnostic.
|
|
61
|
+
export function redactSecrets(text) {
|
|
62
|
+
let out = String(text);
|
|
63
|
+
for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function attemptPattern(role, number) {
|
|
68
|
+
return new RegExp(`^${role}-${number}\\.(\\d+)\\.log$`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function boundedTail(contents) {
|
|
72
|
+
const lines = String(contents).replace(/\n+$/, "").split("\n");
|
|
73
|
+
// Redact before the character bound, never after: cutting first can shear a
|
|
74
|
+
// secret's prefix off (`ghs_XXXX` -> `XXXX`), leaving a remnant no pattern
|
|
75
|
+
// recognises. A cut through a `[REDACTED]` marker is harmless by comparison.
|
|
76
|
+
const tail = redactSecrets(lines.slice(-TAIL_LINES).join("\n"));
|
|
77
|
+
return tail.length > TAIL_CHARS ? tail.slice(-TAIL_CHARS) : tail;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createWorkerLogs({
|
|
81
|
+
exec,
|
|
82
|
+
cwd = process.cwd(),
|
|
83
|
+
fs = {},
|
|
84
|
+
log = () => {},
|
|
85
|
+
} = {}) {
|
|
86
|
+
const { mkdir = fsMkdir, readFile = fsReadFile, readdir = fsReaddir, unlink = fsUnlink } = fs;
|
|
87
|
+
let dirPromise;
|
|
88
|
+
|
|
89
|
+
// The same identity rule the tmux adapter applies before shell
|
|
90
|
+
// interpolation, so a path is only ever assembled from validated parts.
|
|
91
|
+
function identity(role, number) {
|
|
92
|
+
if (!WORKER_ROLES.has(role)) return null;
|
|
93
|
+
const n = Number(number);
|
|
94
|
+
if (!Number.isInteger(n) || n <= 0) return null;
|
|
95
|
+
return { role, number: n };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `git rev-parse` answers relative to the process cwd, so a bare `.git` is
|
|
99
|
+
// resolved against it. Cached: the answer cannot change while running.
|
|
100
|
+
async function logDir() {
|
|
101
|
+
if (!dirPromise) {
|
|
102
|
+
dirPromise = (async () => {
|
|
103
|
+
const { stdout } = await exec("git", ["rev-parse", "--git-common-dir"]);
|
|
104
|
+
const common = stdout.trim();
|
|
105
|
+
if (!common) throw new Error("git rev-parse --git-common-dir returned nothing");
|
|
106
|
+
return join(isAbsolute(common) ? common : resolve(cwd, common), LOG_DIR_NAME);
|
|
107
|
+
})();
|
|
108
|
+
dirPromise.catch(() => { dirPromise = undefined; });
|
|
109
|
+
}
|
|
110
|
+
return dirPromise;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function existingAttempts(dir, role, number) {
|
|
114
|
+
const pattern = attemptPattern(role, number);
|
|
115
|
+
const found = [];
|
|
116
|
+
for (const name of await readdir(dir)) {
|
|
117
|
+
const m = name.match(pattern);
|
|
118
|
+
if (m) found.push({ name, attempt: Number(m[1]) });
|
|
119
|
+
}
|
|
120
|
+
return found.sort((a, b) => a.attempt - b.attempt);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reserve the next attempt file for a worker about to launch. Returns the
|
|
124
|
+
// absolute path, or `null` when the log cannot be prepared — in which case
|
|
125
|
+
// the caller launches the worker anyway.
|
|
126
|
+
async function prepare(role, number) {
|
|
127
|
+
const id = identity(role, number);
|
|
128
|
+
if (!id) {
|
|
129
|
+
log(`worker log skipped: invalid worker identity (role=${String(role)}, number=${String(number)})`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
let dir;
|
|
133
|
+
let attempts;
|
|
134
|
+
try {
|
|
135
|
+
dir = await logDir();
|
|
136
|
+
await mkdir(dir, { recursive: true });
|
|
137
|
+
attempts = await existingAttempts(dir, id.role, id.number);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log(`worker log unavailable for ${id.role}-${id.number} (continuing without it): ${err.message}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pruning is opportunistic — an undeletable old attempt must not block the
|
|
144
|
+
// new one, it only leaves retention temporarily over its bound.
|
|
145
|
+
for (const stale of attempts.slice(0, Math.max(0, attempts.length - (KEEP_ATTEMPTS - 1)))) {
|
|
146
|
+
try {
|
|
147
|
+
await unlink(join(dir, stale.name));
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log(`worker log retention could not remove ${stale.name}: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const attempt = attempts.length ? attempts.at(-1).attempt + 1 : 1;
|
|
154
|
+
return join(dir, `${id.role}-${id.number}.${attempt}.log`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The newest recorded attempt for a worker, with its tail already bounded and
|
|
158
|
+
// redacted. `null` when there is nothing to show — including on any read
|
|
159
|
+
// failure, because diagnostics must never break the poll that surfaces them.
|
|
160
|
+
async function diagnostics(role, number) {
|
|
161
|
+
const id = identity(role, number);
|
|
162
|
+
if (!id) return null;
|
|
163
|
+
try {
|
|
164
|
+
const dir = await logDir();
|
|
165
|
+
const attempts = await existingAttempts(dir, id.role, id.number);
|
|
166
|
+
if (attempts.length === 0) return null;
|
|
167
|
+
const path = join(dir, attempts.at(-1).name);
|
|
168
|
+
return { path, tail: boundedTail(await readFile(path, "utf8")) };
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { prepare, diagnostics };
|
|
175
|
+
}
|