@bridge4dev/runner 0.13.1 → 0.26.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.
Files changed (49) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auth-relay.d.ts +33 -3
  11. package/dist/auth-relay.js +199 -16
  12. package/dist/auto-resume.d.ts +18 -0
  13. package/dist/auto-resume.js +104 -0
  14. package/dist/commit-message.d.ts +51 -0
  15. package/dist/commit-message.js +224 -0
  16. package/dist/config.d.ts +29 -6
  17. package/dist/config.js +15 -0
  18. package/dist/crash-note.d.ts +54 -0
  19. package/dist/crash-note.js +105 -0
  20. package/dist/environment.d.ts +171 -0
  21. package/dist/environment.js +409 -0
  22. package/dist/git.d.ts +81 -0
  23. package/dist/git.js +301 -15
  24. package/dist/gitops.d.ts +489 -12
  25. package/dist/gitops.js +1717 -96
  26. package/dist/index.js +715 -8
  27. package/dist/paths.d.ts +35 -0
  28. package/dist/paths.js +45 -0
  29. package/dist/policy.d.ts +63 -0
  30. package/dist/policy.js +412 -10
  31. package/dist/protocol.d.ts +382 -60
  32. package/dist/protocol.js +104 -1
  33. package/dist/recipe-schema.d.ts +310 -0
  34. package/dist/recipe-schema.js +103 -0
  35. package/dist/recipe.d.ts +94 -0
  36. package/dist/recipe.js +238 -0
  37. package/dist/self-update.d.ts +21 -0
  38. package/dist/self-update.js +73 -1
  39. package/dist/service-unit.d.ts +61 -2
  40. package/dist/service-unit.js +150 -14
  41. package/dist/supervisor.d.ts +108 -1
  42. package/dist/supervisor.js +1045 -57
  43. package/dist/verify-queue.d.ts +17 -0
  44. package/dist/verify-queue.js +100 -0
  45. package/dist/verify.d.ts +203 -0
  46. package/dist/verify.js +788 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,104 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { stateDir } from './paths.js';
4
+ /**
5
+ * How often a session is allowed to have its interrupted turn picked up again
6
+ * automatically.
7
+ *
8
+ * Session 18 (QA-112). When the runner process dies mid-turn, everything the
9
+ * agent was doing is abandoned and the session is parked with «send a message to
10
+ * continue». On 2026-07-30 that cost the owner two full runs of
11
+ * `timeout 900 pnpm -r typecheck`: the process died, they typed «продолжай», and
12
+ * the fifteen-minute command started from zero — twice, four minutes apart.
13
+ *
14
+ * Continuing by itself is only safe with a ceiling. A runner stuck in a crash
15
+ * loop would otherwise relaunch the same agent on every restart, five seconds
16
+ * apart, forever — and each relaunch costs real tokens and may repeat a
17
+ * side effect. So: at most `MAX_ATTEMPTS` inside `WINDOW_MS`, per session,
18
+ * recorded on disk because the whole point is that the process does not survive.
19
+ */
20
+ const MAX_ATTEMPTS = 2;
21
+ const WINDOW_MS = 30 * 60_000;
22
+ function ledgerPath() {
23
+ return path.join(stateDir(), 'auto-resume.json');
24
+ }
25
+ function read() {
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(ledgerPath(), 'utf8'));
28
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
29
+ return {};
30
+ return parsed;
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function write(ledger) {
37
+ try {
38
+ const dir = stateDir();
39
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ const tmp = path.join(dir, `.auto-resume.json.${process.pid}.tmp`);
41
+ fs.writeFileSync(tmp, JSON.stringify(ledger), { mode: 0o600 });
42
+ fs.renameSync(tmp, ledgerPath());
43
+ }
44
+ catch {
45
+ /* Failing to record an attempt must not stop the session from continuing:
46
+ the ceiling is a safety net, not a precondition. */
47
+ }
48
+ }
49
+ /**
50
+ * May this session's interrupted turn be picked up automatically right now?
51
+ *
52
+ * Records the attempt when it says yes — the caller does not have to remember to,
53
+ * and a caller that forgot would silently remove the ceiling.
54
+ */
55
+ export function claimAutoResume(sessionId, now = Date.now()) {
56
+ const ledger = read();
57
+ const recent = (ledger[sessionId]?.at ?? []).filter((at) => now - at < WINDOW_MS);
58
+ if (recent.length >= MAX_ATTEMPTS) {
59
+ // Keep the pruned list so the window slides instead of being reset by a
60
+ // refusal, then let the caller fall back to asking the human.
61
+ ledger[sessionId] = { at: recent };
62
+ write(ledger);
63
+ return false;
64
+ }
65
+ ledger[sessionId] = { at: [...recent, now] };
66
+ write(ledger);
67
+ return true;
68
+ }
69
+ /**
70
+ * Forget a session's history — called when a HUMAN sends a message.
71
+ *
72
+ * Without this, two automatic continuations early in a long session would use up
73
+ * the allowance for the rest of its life. A person typing into the session is the
74
+ * clearest possible signal that the work is on track again.
75
+ */
76
+ export function clearAutoResume(sessionId) {
77
+ const ledger = read();
78
+ if (!(sessionId in ledger))
79
+ return;
80
+ // Rebuilt rather than `delete`d: the ledger is a plain record keyed by session
81
+ // id, and a dynamic delete on it is exactly the pattern the lint rule is for.
82
+ write(Object.fromEntries(Object.entries(ledger).filter(([id]) => id !== sessionId)));
83
+ }
84
+ /** Drop entries for sessions that no longer exist, and anything past the window. */
85
+ export function pruneAutoResume(liveSessionIds, now = Date.now()) {
86
+ const ledger = read();
87
+ const kept = {};
88
+ let changed = false;
89
+ for (const [sessionId, entry] of Object.entries(ledger)) {
90
+ const recent = entry.at.filter((at) => now - at < WINDOW_MS);
91
+ // Nothing recent AND the session is gone → forget it entirely. A live
92
+ // session keeps its (possibly empty) entry so its budget is honest.
93
+ if (recent.length === 0 && !liveSessionIds.has(sessionId)) {
94
+ changed = true;
95
+ continue;
96
+ }
97
+ if (recent.length !== entry.at.length)
98
+ changed = true;
99
+ kept[sessionId] = { at: recent };
100
+ }
101
+ if (changed)
102
+ write(kept);
103
+ }
104
+ //# sourceMappingURL=auto-resume.js.map
@@ -0,0 +1,51 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ export interface ProposeCommitMessageInput {
3
+ worktreePath: string;
4
+ workspacePath: string;
5
+ branch: string;
6
+ baseBranch?: string;
7
+ /** Ticket numbers to reference; the API substitutes them, never the model. */
8
+ tickets?: number[];
9
+ /** Subjects of the commits already on the branch, newest first. */
10
+ commitSubjects?: string[];
11
+ /** Project settings, straight from the workspace row. */
12
+ language?: string;
13
+ convention?: string;
14
+ }
15
+ export interface ProposeCommitMessageResult {
16
+ ok: boolean;
17
+ message?: string;
18
+ /** Files removed from the prompt because they are on the secret denylist. */
19
+ redactedFiles?: number;
20
+ diffTruncated?: boolean;
21
+ error?: string;
22
+ }
23
+ /**
24
+ * Drop whole file sections that are on the secret denylist.
25
+ *
26
+ * `gitDiff` throws in this situation; here that would mean «no proposal at all
27
+ * because the branch touches `.env.example`». Removing the file and saying so
28
+ * is both safer (the bytes never leave) and more useful.
29
+ */
30
+ export declare function stripProtectedFiles(diff: string): {
31
+ diff: string;
32
+ redacted: number;
33
+ };
34
+ /**
35
+ * The one-shot itself.
36
+ *
37
+ * `maxTurns: 1` and every tool refused: this run must read nothing, write
38
+ * nothing and call nothing. It gets the diff in its prompt and answers with
39
+ * text. `settingSources: []` for the same reason the session adapter uses it —
40
+ * a `.claude/settings.json` inside the worktree is a file the agent can write.
41
+ */
42
+ export declare function proposeCommitMessage(input: ProposeCommitMessageInput, queryFn?: typeof query): Promise<ProposeCommitMessageResult>;
43
+ /**
44
+ * What comes back is text a model wrote, so it is treated as such: fences off,
45
+ * an opening «Here is the commit message:» off, masked, capped.
46
+ *
47
+ * The trailer and signature filtering lives on the API side, where it also
48
+ * catches what a human pastes into the box — one filter, one place.
49
+ */
50
+ export declare function cleanProposal(text: string): string;
51
+ //# sourceMappingURL=commit-message.d.ts.map
@@ -0,0 +1,224 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { scrubbedEnv } from './adapters/claude.js';
3
+ import { gitBranchDiff } from './gitops.js';
4
+ import { isSecretPath, maskString } from './policy.js';
5
+ /**
6
+ * «Propose a commit message» — level 1 (session 14).
7
+ *
8
+ * A ONE-SHOT run, not a message into the live session. Sending it to the open
9
+ * session would cost three things at once: the feed fills with a request nobody
10
+ * made, the session's context window pays for the whole diff, and the call
11
+ * collides with the worktree lock the agent is already holding mid-turn.
12
+ *
13
+ * It always uses the Claude SDK, whichever agent the session runs. The SDK
14
+ * ships its own CLI, so it is present on every runner — a Codex session gets a
15
+ * proposal too, instead of the button being greyed out on half the machines.
16
+ *
17
+ * The diff is masked and stripped of protected files BEFORE it becomes prompt
18
+ * text. A model prompt is an outbound channel exactly like the Files panel, and
19
+ * QA-105 is the reminder that the two have to be held to the same rule.
20
+ */
21
+ /** How much diff is worth a commit message. Beyond this it is a code review. */
22
+ const DIFF_PROMPT_CAP = 60_000;
23
+ /** The whole answer is one commit message; anything longer is a monologue. */
24
+ const MESSAGE_CAP = 4_000;
25
+ /** A model that has not answered by now is not going to. */
26
+ const PROPOSE_TIMEOUT_MS = 90_000;
27
+ /**
28
+ * Drop whole file sections that are on the secret denylist.
29
+ *
30
+ * `gitDiff` throws in this situation; here that would mean «no proposal at all
31
+ * because the branch touches `.env.example`». Removing the file and saying so
32
+ * is both safer (the bytes never leave) and more useful.
33
+ */
34
+ export function stripProtectedFiles(diff) {
35
+ const sections = diff.split(/(?=^diff --git )/m);
36
+ let redacted = 0;
37
+ const kept = sections.filter((section) => {
38
+ // The first section of a diff is whatever came before the first header —
39
+ // usually empty. It carries no file, so there is nothing to protect.
40
+ if (!/^diff --git /.test(section))
41
+ return true;
42
+ const paths = diffHeaderPaths(section);
43
+ // Fail CLOSED. A header shape we cannot read is a file we cannot check,
44
+ // and the two outcomes are not comparable: dropping it costs a slightly
45
+ // less informed commit message, keeping it puts an unexamined file into a
46
+ // prompt that leaves the machine. Session 15 found this with the shape
47
+ // that actually occurs — git QUOTES a header whose path contains `"`, a
48
+ // backslash, a tab or a newline (`diff --git "a/we\"ird/.env" …`), and
49
+ // `core.quotePath=false` does not turn that off. It only suppresses the
50
+ // octal escaping of non-ASCII.
51
+ if (!paths) {
52
+ redacted += 1;
53
+ return false;
54
+ }
55
+ if (paths.some(isSecretPath)) {
56
+ redacted += 1;
57
+ return false;
58
+ }
59
+ return true;
60
+ });
61
+ return { diff: kept.join(''), redacted };
62
+ }
63
+ /**
64
+ * The `a/` and `b/` paths out of a `diff --git` header, quoted or not.
65
+ *
66
+ * Git writes the quoted form whenever a path contains a character it cannot
67
+ * put in the line as-is, and escapes it the way C does. Unescaping is enough
68
+ * here: these strings are only ever compared against the secret-path denylist,
69
+ * never used to open anything.
70
+ */
71
+ function diffHeaderPaths(section) {
72
+ const line = section.split('\n', 1)[0] ?? '';
73
+ const plain = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
74
+ if (plain)
75
+ return [plain[1] ?? '', plain[2] ?? ''];
76
+ const quoted = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/.exec(line);
77
+ if (quoted)
78
+ return [unescapeGitPath(quoted[1] ?? ''), unescapeGitPath(quoted[2] ?? '')];
79
+ // One side quoted and the other not — git does not write that, and a header
80
+ // we cannot account for is one the caller must treat as unknown.
81
+ return null;
82
+ }
83
+ function unescapeGitPath(value) {
84
+ return value.replace(/\\([0-7]{3}|.)/g, (_match, escaped) => {
85
+ if (/^[0-7]{3}$/.test(escaped))
86
+ return String.fromCharCode(parseInt(escaped, 8));
87
+ const simple = { n: '\n', t: '\t', r: '\r', '\\': '\\', '"': '"' };
88
+ return simple[escaped] ?? escaped;
89
+ });
90
+ }
91
+ function buildPrompt(input, diff) {
92
+ const language = input.language?.trim() || 'English';
93
+ const convention = input.convention?.trim() ||
94
+ 'Conventional Commits: `type(scope): subject`, where type is one of feat/fix/docs/refactor/test/chore/perf/build/ci.';
95
+ const commits = (input.commitSubjects ?? []).slice(0, 20);
96
+ return [
97
+ 'Write ONE git commit message for the change below. Output the message and nothing else —',
98
+ 'no preamble, no explanation, no code fences.',
99
+ '',
100
+ 'Rules:',
101
+ `- Language: ${language}.`,
102
+ `- Convention: ${convention}`,
103
+ '- The subject line is at most 72 characters and does not end with a period.',
104
+ '- After the subject: a blank line, then 1–3 sentences on what changed and why, then a blank',
105
+ ' line and up to 5 bullets of the notable points. Skip the bullets if there is nothing to say.',
106
+ '- Describe what the diff actually does. Do not speculate about intent you cannot see.',
107
+ '- Do NOT add ticket references, `Co-Authored-By`, `Generated with`, emoji or any trailer —',
108
+ ' DevBridge appends the trailers itself and strips anything else.',
109
+ '',
110
+ `Branch: ${input.branch}${input.baseBranch ? ` (forked from ${input.baseBranch})` : ''}`,
111
+ ...(commits.length
112
+ ? ['', 'Commits already on this branch, newest first:', ...commits.map((s) => `- ${s}`)]
113
+ : []),
114
+ '',
115
+ 'Diff:',
116
+ '```diff',
117
+ diff,
118
+ '```',
119
+ ].join('\n');
120
+ }
121
+ /**
122
+ * The one-shot itself.
123
+ *
124
+ * `maxTurns: 1` and every tool refused: this run must read nothing, write
125
+ * nothing and call nothing. It gets the diff in its prompt and answers with
126
+ * text. `settingSources: []` for the same reason the session adapter uses it —
127
+ * a `.claude/settings.json` inside the worktree is a file the agent can write.
128
+ */
129
+ export async function proposeCommitMessage(input, queryFn = query) {
130
+ const raw = await gitBranchDiff({
131
+ worktreePath: input.worktreePath,
132
+ workspacePath: input.workspacePath,
133
+ sessionBranch: input.branch,
134
+ ...(input.baseBranch ? { baseBranch: input.baseBranch } : {}),
135
+ maxBytes: DIFF_PROMPT_CAP,
136
+ }).catch(() => null);
137
+ if (!raw)
138
+ return { ok: false, error: 'Could not read the diff of this branch' };
139
+ const { diff, redacted } = stripProtectedFiles(raw.diff);
140
+ if (!diff.trim()) {
141
+ return {
142
+ ok: false,
143
+ error: redacted > 0
144
+ ? 'Everything this branch changed is on the protected-paths list — nothing can be described'
145
+ : 'There is nothing to describe yet — this branch matches its base branch',
146
+ redactedFiles: redacted,
147
+ };
148
+ }
149
+ let text = '';
150
+ try {
151
+ const run = queryFn({
152
+ prompt: buildPrompt(input, diff),
153
+ options: {
154
+ cwd: input.worktreePath,
155
+ env: scrubbedEnv(),
156
+ settingSources: [],
157
+ maxTurns: 1,
158
+ // Belt and braces: an empty allowlist is a statement of intent, the
159
+ // callback is the mechanism. Both, because this run has no human
160
+ // attached to answer a permission prompt.
161
+ allowedTools: [],
162
+ canUseTool: () => Promise.resolve({
163
+ behavior: 'deny',
164
+ message: 'This run only writes a commit message — it may not use tools.',
165
+ }),
166
+ systemPrompt: {
167
+ type: 'preset',
168
+ preset: 'claude_code',
169
+ append: 'You are writing a single git commit message for DevBridge. Answer with the message text only.',
170
+ },
171
+ },
172
+ });
173
+ const deadline = setTimeout(() => void run.interrupt?.().catch(() => undefined), PROPOSE_TIMEOUT_MS);
174
+ deadline.unref();
175
+ try {
176
+ for await (const message of run) {
177
+ if (message.type !== 'assistant')
178
+ continue;
179
+ for (const block of message.message.content) {
180
+ if (block.type === 'text')
181
+ text += block.text;
182
+ }
183
+ }
184
+ }
185
+ finally {
186
+ clearTimeout(deadline);
187
+ }
188
+ }
189
+ catch (error) {
190
+ return {
191
+ ok: false,
192
+ error: maskString(String(error instanceof Error ? error.message : error)).slice(0, 300),
193
+ };
194
+ }
195
+ const message = cleanProposal(text);
196
+ if (!message)
197
+ return { ok: false, error: 'The agent returned nothing usable' };
198
+ return {
199
+ ok: true,
200
+ message,
201
+ ...(redacted > 0 ? { redactedFiles: redacted } : {}),
202
+ ...(raw.truncated ? { diffTruncated: true } : {}),
203
+ };
204
+ }
205
+ /**
206
+ * What comes back is text a model wrote, so it is treated as such: fences off,
207
+ * an opening «Here is the commit message:» off, masked, capped.
208
+ *
209
+ * The trailer and signature filtering lives on the API side, where it also
210
+ * catches what a human pastes into the box — one filter, one place.
211
+ */
212
+ export function cleanProposal(text) {
213
+ let value = maskString(text).trim();
214
+ const fenced = /^```(?:[a-z]*)\n([\s\S]*?)\n?```$/i.exec(value);
215
+ if (fenced?.[1])
216
+ value = fenced[1].trim();
217
+ // `here(?:'s| is)` — with the apostrophe attached, not spaced. «Here's the
218
+ // commit message:» is the commonest preamble a model writes and the original
219
+ // `here (?:is|'s)` required a space before it, so that one form survived into
220
+ // the box (session 15).
221
+ value = value.replace(/^(?:here(?:'s| is)[^\n:]*:|commit message:)\s*\n+/i, '').trim();
222
+ return value.slice(0, MESSAGE_CAP);
223
+ }
224
+ //# sourceMappingURL=commit-message.js.map
package/dist/config.d.ts CHANGED
@@ -47,6 +47,23 @@ declare const ConfigSchema: z.ZodObject<{
47
47
  }, {
48
48
  max_sessions: number;
49
49
  }>>;
50
+ /**
51
+ * Session 14: the machine owner's veto over running project recipes.
52
+ *
53
+ * Verification executes commands a DevBridge manager approved — a build, a
54
+ * test run, possibly a deploy. That is a real execution channel on somebody
55
+ * else's server, and the person who owns the server gets the last word on
56
+ * whether it exists at all. `enabled = false` here means the capability is
57
+ * not even announced, so the dashboard never draws the card: a control that
58
+ * is switched off must not look like a control that is broken.
59
+ */
60
+ verify: z.ZodOptional<z.ZodObject<{
61
+ enabled: z.ZodDefault<z.ZodBoolean>;
62
+ }, "strip", z.ZodTypeAny, {
63
+ enabled: boolean;
64
+ }, {
65
+ enabled?: boolean | undefined;
66
+ }>>;
50
67
  }, "strip", z.ZodTypeAny, {
51
68
  api: {
52
69
  url: string;
@@ -57,16 +74,19 @@ declare const ConfigSchema: z.ZodObject<{
57
74
  name: string;
58
75
  token: string;
59
76
  };
60
- codex?: {
61
- auth: "link" | "own";
62
- } | undefined;
63
77
  mcp?: {
64
78
  url: string;
65
79
  token: string;
66
80
  } | undefined;
81
+ codex?: {
82
+ auth: "link" | "own";
83
+ } | undefined;
67
84
  limits?: {
68
85
  max_sessions: number;
69
86
  } | undefined;
87
+ verify?: {
88
+ enabled: boolean;
89
+ } | undefined;
70
90
  }, {
71
91
  api: {
72
92
  url: string;
@@ -77,16 +97,19 @@ declare const ConfigSchema: z.ZodObject<{
77
97
  name: string;
78
98
  token: string;
79
99
  };
80
- codex?: {
81
- auth?: "link" | "own" | undefined;
82
- } | undefined;
83
100
  mcp?: {
84
101
  url: string;
85
102
  token: string;
86
103
  } | undefined;
104
+ codex?: {
105
+ auth?: "link" | "own" | undefined;
106
+ } | undefined;
87
107
  limits?: {
88
108
  max_sessions: number;
89
109
  } | undefined;
110
+ verify?: {
111
+ enabled?: boolean | undefined;
112
+ } | undefined;
90
113
  }>;
91
114
  export type RunnerConfig = z.infer<typeof ConfigSchema>;
92
115
  export declare function loadConfig(): RunnerConfig | null;
package/dist/config.js CHANGED
@@ -44,6 +44,21 @@ const ConfigSchema = z.object({
44
44
  max_sessions: z.number().int().min(1).max(64),
45
45
  })
46
46
  .optional(),
47
+ /**
48
+ * Session 14: the machine owner's veto over running project recipes.
49
+ *
50
+ * Verification executes commands a DevBridge manager approved — a build, a
51
+ * test run, possibly a deploy. That is a real execution channel on somebody
52
+ * else's server, and the person who owns the server gets the last word on
53
+ * whether it exists at all. `enabled = false` here means the capability is
54
+ * not even announced, so the dashboard never draws the card: a control that
55
+ * is switched off must not look like a control that is broken.
56
+ */
57
+ verify: z
58
+ .object({
59
+ enabled: z.boolean().default(true),
60
+ })
61
+ .optional(),
47
62
  });
48
63
  export function loadConfig() {
49
64
  const file = configFilePath();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Why the previous runner process died — written by the dying process, read once
3
+ * by its successor, reported in `hello`.
4
+ *
5
+ * The 2026-07-30 incident (QA-112) could not be classified after the fact: three
6
+ * live sessions were parked with «Runner reconnected», and the only evidence the
7
+ * daemon had even restarted was that its in-memory session map was empty. The
8
+ * process itself left nothing, and the machine's journal is unreachable from
9
+ * DevBridge. This file closes that gap for the two causes that matter:
10
+ *
11
+ * - an unhandled error, which the process can describe itself;
12
+ * - a kernel OOM kill, which it cannot — the note is absent and the cgroup's
13
+ * `memory.events` counter is read instead (`readOomKills`).
14
+ *
15
+ * "Absent note + oom_kill went up" is therefore a positive OOM diagnosis rather
16
+ * than a shrug, which is exactly the distinction the incident needed.
17
+ */
18
+ export interface CrashNote {
19
+ kind: string;
20
+ detail: string;
21
+ at: string;
22
+ version: string;
23
+ activeSessionIds: string[];
24
+ }
25
+ /** What the successor reports upstream. `kind: 'oom'` is inferred, not written. */
26
+ export interface LastExit {
27
+ kind: string;
28
+ at?: string;
29
+ detail?: string;
30
+ version?: string;
31
+ /** Sessions the dead process was running — how much the death actually cost. */
32
+ activeSessionIds?: string[];
33
+ /** Cumulative `oom_kill` from the service cgroup, when readable. */
34
+ oomKills?: number;
35
+ }
36
+ export declare function recordCrash(note: CrashNote): void;
37
+ /**
38
+ * `oom_kill` from the cgroup this process lives in.
39
+ *
40
+ * cgroup v2 exposes it in `memory.events` of the service slice. The counter is
41
+ * cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
42
+ * (the cgroup outlives the process), so the interesting quantity is the delta —
43
+ * hence the small sidecar file.
44
+ */
45
+ export declare function readOomKills(readFile?: (p: string) => string): number | null;
46
+ /**
47
+ * Read and CONSUME the note left by the previous process.
48
+ *
49
+ * Consuming matters: without it every reconnect for the rest of the daemon's
50
+ * life would keep reporting a death that happened once, and the dashboard would
51
+ * show a permanent crash badge on a healthy server.
52
+ */
53
+ export declare function takeLastExit(readOom?: () => number | null): LastExit | null;
54
+ //# sourceMappingURL=crash-note.d.ts.map
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { stateDir } from './paths.js';
4
+ function crashNotePath() {
5
+ return path.join(stateDir(), 'crash.json');
6
+ }
7
+ function oomCounterPath() {
8
+ return path.join(stateDir(), 'oom-kills');
9
+ }
10
+ export function recordCrash(note) {
11
+ const dir = stateDir();
12
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
13
+ // Written in place, not atomically: a rename needs a second syscall and this
14
+ // runs on a process that is already failing. A truncated note still carries
15
+ // its `kind`, and a missing one is handled as "unknown" upstream.
16
+ fs.writeFileSync(crashNotePath(), JSON.stringify(note), { mode: 0o600 });
17
+ }
18
+ /**
19
+ * `oom_kill` from the cgroup this process lives in.
20
+ *
21
+ * cgroup v2 exposes it in `memory.events` of the service slice. The counter is
22
+ * cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
23
+ * (the cgroup outlives the process), so the interesting quantity is the delta —
24
+ * hence the small sidecar file.
25
+ */
26
+ export function readOomKills(readFile = (p) => fs.readFileSync(p, 'utf8')) {
27
+ try {
28
+ // `/proc/self/cgroup` on v2 is a single line: `0::/user.slice/.../x.service`
29
+ const rel = readFile('/proc/self/cgroup')
30
+ .split('\n')
31
+ .map((line) => line.split(':'))
32
+ .find((parts) => parts[0] === '0' && parts[1] === '')?.[2];
33
+ if (!rel)
34
+ return null;
35
+ const events = readFile(path.join('/sys/fs/cgroup', rel, 'memory.events'));
36
+ const line = events.split('\n').find((l) => l.startsWith('oom_kill '));
37
+ if (!line)
38
+ return null;
39
+ const value = Number.parseInt(line.slice('oom_kill '.length).trim(), 10);
40
+ return Number.isFinite(value) ? value : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Read and CONSUME the note left by the previous process.
48
+ *
49
+ * Consuming matters: without it every reconnect for the rest of the daemon's
50
+ * life would keep reporting a death that happened once, and the dashboard would
51
+ * show a permanent crash badge on a healthy server.
52
+ */
53
+ export function takeLastExit(readOom = readOomKills) {
54
+ let note;
55
+ try {
56
+ note = JSON.parse(fs.readFileSync(crashNotePath(), 'utf8'));
57
+ }
58
+ catch {
59
+ note = null;
60
+ }
61
+ try {
62
+ fs.rmSync(crashNotePath(), { force: true });
63
+ }
64
+ catch {
65
+ /* a note we cannot delete is better than refusing to start */
66
+ }
67
+ const oomKills = readOom();
68
+ let previousOomKills;
69
+ try {
70
+ previousOomKills = Number.parseInt(fs.readFileSync(oomCounterPath(), 'utf8').trim(), 10);
71
+ if (!Number.isFinite(previousOomKills))
72
+ previousOomKills = null;
73
+ }
74
+ catch {
75
+ previousOomKills = null;
76
+ }
77
+ if (oomKills !== null) {
78
+ try {
79
+ fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
80
+ fs.writeFileSync(oomCounterPath(), String(oomKills), { mode: 0o600 });
81
+ }
82
+ catch {
83
+ /* the absolute count is still reported below */
84
+ }
85
+ }
86
+ const oomSinceLastStart = oomKills !== null && previousOomKills !== null ? oomKills - previousOomKills : null;
87
+ if (note) {
88
+ return {
89
+ kind: note.kind,
90
+ at: note.at,
91
+ detail: note.detail,
92
+ version: note.version,
93
+ activeSessionIds: note.activeSessionIds,
94
+ ...(oomKills === null ? {} : { oomKills }),
95
+ };
96
+ }
97
+ // No note. If the cgroup's OOM counter moved since we last looked, the kernel
98
+ // killed something in this service — the honest answer the incident lacked.
99
+ if (oomSinceLastStart !== null && oomSinceLastStart > 0) {
100
+ return { kind: 'oom', oomKills: oomKills ?? undefined };
101
+ }
102
+ // First ever start, a clean stop, or a SIGKILL from outside. Nothing to claim.
103
+ return null;
104
+ }
105
+ //# sourceMappingURL=crash-note.js.map