blun-king-cli 9.1.589 → 9.1.595
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/CHANGELOG.md +36 -0
- package/README.md +12 -0
- package/agent-spine-plugin/CHANGELOG.md +1 -0
- package/agent-spine-plugin/docs/host-integration.md +15 -1
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install-hook.js +6 -5
- package/agent-spine-plugin/scripts/check-install-king.js +37 -0
- package/agent-spine-plugin/scripts/check-install.js +6 -1
- package/agent-spine-plugin/scripts/release-check.js +3 -2
- package/agent-spine-plugin/src/cli-agent.js +20 -1
- package/agent-spine-plugin/src/cli.js +1 -0
- package/agent-spine-plugin/src/index.js +1 -1
- package/agent-spine-plugin/src/lib/delivery-command-actions.js +16 -7
- package/agent-spine-plugin/src/lib/gateway-control.js +69 -1
- package/agent-spine-plugin/src/lib/gateway-host-fencing.js +92 -0
- package/agent-spine-plugin/src/lib/gateway-host-lifecycle.js +9 -1
- package/agent-spine-plugin/src/lib/gateway-prepared-host.js +28 -0
- package/agent-spine-plugin/src/lib/gateway-runs.js +46 -10
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1 -1
- package/agent-spine-plugin/src/lib/gateway-state.js +3 -1
- package/agent-spine-plugin/src/lib/hook-context.js +8 -3
- package/agent-spine-plugin/src/lib/hook-output.js +2 -3
- package/agent-spine-plugin/src/lib/hook-process-advisory.js +1 -2
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +17 -0
- package/agent-spine-plugin/src/lib/preflight.js +5 -19
- package/agent-spine-plugin/src/lib/source-roots.js +3 -2
- package/agent-spine-plugin/src/worker.js +30 -12
- package/bin/agentspine-king-goal-inbox.mjs +127 -0
- package/bin/agentspine-king-goal-intake.mjs +106 -0
- package/bin/agentspine-king-host-runner.mjs +109 -0
- package/bin/agentspine-king-snapshot-policy.cjs +80 -0
- package/bin/agentspine-king-status-policy.mjs +226 -0
- package/bin/agentspine-king-worker-host.mjs +160 -0
- package/bin/core-bootstrap.js +20 -3
- package/bin/launcher-mode.js +38 -1
- package/bin/launcher-restart-policy.cjs +131 -0
- package/bin/launcher-runtime.js +48 -9
- package/bin/runtime-exit-ledger.cjs +144 -0
- package/bin/runtime-exit-ledger.d.cts +23 -0
- package/bin/windows-node-crash-dump.cjs +289 -0
- package/blun.mjs +83241 -74234
- package/bundled-agent-sources.json +109 -34
- package/codebase-index/codebase_index.py +470 -0
- package/package.json +6 -1
- package/telegram-plugin/dist/bridge.mjs +390 -4
- package/worker-host.mjs +348023 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFile, mkdir, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { createGoalHostFactory } from './agentspine-king-host-runner.mjs';
|
|
7
|
+
import { ingestGoalInbox } from './agentspine-king-goal-inbox.mjs';
|
|
8
|
+
import { createGoalStatusReporter, goalResultRejectionCode } from './agentspine-king-status-policy.mjs';
|
|
9
|
+
import snapshotPolicy from './agentspine-king-snapshot-policy.cjs';
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.resolve(import.meta.dirname, '..');
|
|
12
|
+
const required = ['blunHome', 'profile', 'projectRoot', 'projectId', 'standFile', 'expectedStandSha256',
|
|
13
|
+
'expectedSnapshotSha256', 'exchangeDirectory', 'telegramQueueFile', 'instructionSenderId', 'instructionChatId',
|
|
14
|
+
'logicalPersonaId', 'runtimePersonaId', 'groupId', 'ownerSubjectId', 'permission', 'gatewayStateDirectory'];
|
|
15
|
+
|
|
16
|
+
export async function loadWorkerConfig(file) {
|
|
17
|
+
const config = JSON.parse(await readFile(path.resolve(file), 'utf8'));
|
|
18
|
+
if (config?.schema !== 'blun.king-goal-worker-config/v1'
|
|
19
|
+
|| required.some(key => typeof config[key] !== 'string' || !config[key])
|
|
20
|
+
|| (config.instructionThreadId !== null && config.instructionThreadId !== undefined && (typeof config.instructionThreadId !== 'string'
|
|
21
|
+
|| !/^[1-9][0-9]*$/.test(config.instructionThreadId) || !Number.isSafeInteger(Number(config.instructionThreadId))))
|
|
22
|
+
|| !['manual', 'auto', 'yolo'].includes(config.permission)) throw new Error('KING_WORKER_CONFIG_INVALID');
|
|
23
|
+
for (const key of ['blunHome', 'projectRoot', 'exchangeDirectory', 'telegramQueueFile', 'gatewayStateDirectory']) {
|
|
24
|
+
if (!path.isAbsolute(config[key])) throw new Error('KING_WORKER_ABSOLUTE_PATH_REQUIRED');
|
|
25
|
+
}
|
|
26
|
+
const blunHome = await realpath(config.blunHome);
|
|
27
|
+
const relative = path.relative(blunHome, path.resolve(config.gatewayStateDirectory));
|
|
28
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith('..' + path.sep)) throw new Error('KING_WORKER_STATE_OUTSIDE_APP_HOME');
|
|
29
|
+
const state = await realpath(config.gatewayStateDirectory);
|
|
30
|
+
const canonicalRelative = path.relative(blunHome, state);
|
|
31
|
+
if (path.isAbsolute(canonicalRelative) || canonicalRelative === '..' || canonicalRelative.startsWith('..' + path.sep)) {
|
|
32
|
+
throw new Error('KING_WORKER_STATE_OUTSIDE_APP_HOME');
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({ ...config, blunHome, gatewayStateDirectory: state, projectRoot: await realpath(config.projectRoot) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runGuardedWorkerTick(context) {
|
|
38
|
+
const { config, gateway, hostFactory, env, adapter } = context;
|
|
39
|
+
const snapshot = snapshotPolicy.verifyWorkspaceSnapshot({ root: config.projectRoot, projectId: config.projectId,
|
|
40
|
+
standFile: config.standFile, expectedStandSha256: config.expectedStandSha256,
|
|
41
|
+
expectedSnapshotSha256: config.expectedSnapshotSha256 });
|
|
42
|
+
const personas = await gateway.loadPersonaRuntime(snapshot.root);
|
|
43
|
+
const persona = personas.runtime.personas.find(item => item.personaId === config.runtimePersonaId && item.status === 'active');
|
|
44
|
+
const binding = personas.policy.bindings.find(item => item.id === persona?.bindingId && item.active);
|
|
45
|
+
if (!binding || binding.profileId !== config.profile || binding.groupId !== config.groupId) {
|
|
46
|
+
throw new Error('KING_WORKER_IDENTITY_MISMATCH');
|
|
47
|
+
}
|
|
48
|
+
const { withOwnedFileLock } = await import(pathToFileURL(path.join(packageRoot,
|
|
49
|
+
'agent-spine-plugin/src/lib/owned-file-lock.js')).href);
|
|
50
|
+
const statusReporter = createGoalStatusReporter({
|
|
51
|
+
stateFile: path.join(config.blunHome, 'worker', 'goal-status-v2.json'),
|
|
52
|
+
scope: { logicalPersonaId: config.logicalPersonaId, runtimePersonaId: config.runtimePersonaId,
|
|
53
|
+
projectId: config.projectId, groupId: config.groupId, ownerSubjectId: config.ownerSubjectId,
|
|
54
|
+
chatId: config.instructionChatId, senderId: config.instructionSenderId,
|
|
55
|
+
threadId: config.instructionThreadId ?? null },
|
|
56
|
+
withOwnedFileLock, loadPolicy: () => gateway.loadChannelPolicy(snapshot.root), adapter,
|
|
57
|
+
});
|
|
58
|
+
const statusReports = [];
|
|
59
|
+
const reportCommittedStatus = async () => {
|
|
60
|
+
const context = await gateway.gatewayContext({ root: snapshot.root, agentId: config.runtimePersonaId });
|
|
61
|
+
if (!context.enabled || context.killSwitch) return;
|
|
62
|
+
await statusReporter.reconcile(context);
|
|
63
|
+
statusReports.push(...await statusReporter.flush());
|
|
64
|
+
};
|
|
65
|
+
// Reconcile the saved goal state first: a prior process may have stopped after committing its tick.
|
|
66
|
+
await reportCommittedStatus();
|
|
67
|
+
const intake = await ingestGoalInbox({ exchangeDirectory: config.exchangeDirectory,
|
|
68
|
+
queueFile: config.telegramQueueFile, checkpointFile: path.join(config.blunHome, 'worker', 'goal-queue-checkpoint.json'),
|
|
69
|
+
instructionSenderId: config.instructionSenderId, instructionChatId: config.instructionChatId,
|
|
70
|
+
instructionThreadId: config.instructionThreadId,
|
|
71
|
+
logicalPersonaId: config.logicalPersonaId, runtimePersonaId: config.runtimePersonaId,
|
|
72
|
+
projectId: config.projectId, groupId: config.groupId, ownerSubjectId: config.ownerSubjectId,
|
|
73
|
+
snapshot, assignGoal: gateway.assignGoal,
|
|
74
|
+
recordResult: result => result.status === 'rejected'
|
|
75
|
+
? statusReporter.record({ kind: 'intake-rejected', eventId: result.eventId,
|
|
76
|
+
messageId: result.messageId, error: result.error }) : undefined });
|
|
77
|
+
const result = await gateway.runWorkerTick({ root: snapshot.root, workerId: 'gateway-worker:blun:' + process.pid,
|
|
78
|
+
agentId: config.runtimePersonaId, projectId: config.projectId, groupId: config.groupId,
|
|
79
|
+
goalOnly: true, env,
|
|
80
|
+
hostFactory: async item => {
|
|
81
|
+
const host = await hostFactory(item);
|
|
82
|
+
return { ...host, async run() {
|
|
83
|
+
try { return await host.run(); }
|
|
84
|
+
catch (error) {
|
|
85
|
+
const rejection = goalResultRejectionCode(error);
|
|
86
|
+
if (rejection) {
|
|
87
|
+
// Record the concrete parser failure before the gateway replaces it with an execution hold.
|
|
88
|
+
await statusReporter.record({ kind: 'result-rejected', eventId: `${item.queueId}:${item.attempts}`,
|
|
89
|
+
goalId: item.goal.goalId, goalStepId: item.goalStep?.stepId ?? null,
|
|
90
|
+
attempt: item.attempts, error: rejection });
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
} };
|
|
95
|
+
},
|
|
96
|
+
adapter: { send: item => adapter.send(item) } });
|
|
97
|
+
await reportCommittedStatus();
|
|
98
|
+
return { ...result, intake, statusReports };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function runWorkerHost(config, { once = false, signal, onResult = () => {} } = {}) {
|
|
102
|
+
const gateway = await import(pathToFileURL(path.join(packageRoot, 'agent-spine-plugin/src/index.js')).href);
|
|
103
|
+
const { createKingWorkerHarness, LocalKaos } = await import(pathToFileURL(path.join(packageRoot, 'worker-host.mjs')).href);
|
|
104
|
+
const env = { ...process.env, BLUN_HOME: config.blunHome, BLUN_PROFILE: config.profile };
|
|
105
|
+
const harness = createKingWorkerHarness({ homeDir: config.blunHome, configPath: config.settingsPath });
|
|
106
|
+
const hostFactory = createGoalHostFactory({ harness, workDir: config.projectRoot, model: config.model,
|
|
107
|
+
permission: config.permission, tickTimeoutMs: config.tickTimeoutMs, cleanupTimeoutMs: config.cleanupTimeoutMs,
|
|
108
|
+
sessionOptions: { kaos: (await LocalKaos.create()).withCwd(config.projectRoot).withEnv({
|
|
109
|
+
BLUN_HOME: config.blunHome, BLUN_PROFILE: config.profile, AGENTSPINE_STATE_DIR: config.gatewayStateDirectory,
|
|
110
|
+
}) },
|
|
111
|
+
signal });
|
|
112
|
+
const adapter = gateway.createTelegramAdapter({ root: config.projectRoot, env });
|
|
113
|
+
try {
|
|
114
|
+
do {
|
|
115
|
+
if (signal?.aborted) return { status: 'stopped', processed: false };
|
|
116
|
+
const result = await runGuardedWorkerTick({ config, gateway, hostFactory, env, adapter });
|
|
117
|
+
await mkdir(path.join(config.blunHome, 'worker'), { recursive: true });
|
|
118
|
+
await appendFile(path.join(config.blunHome, 'worker', 'worker.log'), JSON.stringify({
|
|
119
|
+
schema: 'blun.king-goal-worker-log/v1', at: new Date().toISOString(), status: result.status,
|
|
120
|
+
queueId: result.queueId ?? null, hostSettlement: result.hostSettlement ?? null,
|
|
121
|
+
statusReports: result.statusReports,
|
|
122
|
+
intake: result.intake.map(item => ({ status: item.status, messageId: item.messageId, error: item.error }))
|
|
123
|
+
}) + '\n', { mode: 0o600 });
|
|
124
|
+
await onResult(result);
|
|
125
|
+
if (once || result.status === 'stopped' || result.status === 'blocked') return result;
|
|
126
|
+
await delay(result.processed ? 1000 : 15000, undefined, { signal }).catch(error => {
|
|
127
|
+
if (error.name !== 'AbortError') throw error;
|
|
128
|
+
});
|
|
129
|
+
} while (!signal?.aborted);
|
|
130
|
+
return { status: 'stopped', processed: false };
|
|
131
|
+
} finally {
|
|
132
|
+
let quiet = true;
|
|
133
|
+
for (const session of harness.sessions.values()) {
|
|
134
|
+
const proof = await session.quiesce({ deadlineAt: Date.now() + (config.cleanupTimeoutMs ?? 10000) }).catch(() => null);
|
|
135
|
+
if (!proof || proof.status !== 'quiescent' || proof.remaining.length) quiet = false;
|
|
136
|
+
}
|
|
137
|
+
// Never turn a cleanup deadline into a successful host termination acknowledgement.
|
|
138
|
+
if (quiet) await harness.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
const index = args.indexOf('--config');
|
|
145
|
+
if (index < 0 || !args[index + 1] || args[index + 1].startsWith('--')) throw new Error('--config requires a path');
|
|
146
|
+
const config = await loadWorkerConfig(args[index + 1]);
|
|
147
|
+
process.env.BLUN_HOME = config.blunHome;
|
|
148
|
+
process.env.BLUN_PROFILE = config.profile;
|
|
149
|
+
process.env.AGENTSPINE_STATE_DIR = config.gatewayStateDirectory;
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const stop = () => controller.abort();
|
|
152
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
153
|
+
try { await runWorkerHost(config, { once: args.includes('--once'), signal: controller.signal,
|
|
154
|
+
onResult: result => process.stdout.write(JSON.stringify(result) + '\n') }); }
|
|
155
|
+
finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
159
|
+
main().catch(error => { process.stderr.write(String(error.message).slice(0, 500) + '\n'); process.exitCode = 1; });
|
|
160
|
+
}
|
package/bin/core-bootstrap.js
CHANGED
|
@@ -5,22 +5,39 @@ const path = require('node:path');
|
|
|
5
5
|
const { pathToFileURL } = require('node:url');
|
|
6
6
|
|
|
7
7
|
const { acquireSharedRuntimeLease } = require('./update-lease');
|
|
8
|
+
const { installWindowsNodeCrashDump } = require('./windows-node-crash-dump.cjs');
|
|
9
|
+
const { recordRuntimeExit } = require('./runtime-exit-ledger.cjs');
|
|
8
10
|
|
|
9
11
|
const CORE_LOADED_MESSAGE = 'blun-core-bootstrap-loaded';
|
|
10
12
|
|
|
11
13
|
async function runCoreBootstrap() {
|
|
14
|
+
let diagnostics;
|
|
15
|
+
try { diagnostics = installWindowsNodeCrashDump({ homeDir: process.env.BLUN_HOME }); } catch {}
|
|
12
16
|
const packageRoot = path.resolve(__dirname, '..');
|
|
13
17
|
const mainPath = path.join(packageRoot, 'blun.mjs');
|
|
14
|
-
|
|
15
|
-
if (!leaseResult.acquired) throw new Error('RUNTIME_PROTECTION_UNAVAILABLE');
|
|
18
|
+
let leaseResult;
|
|
16
19
|
|
|
17
20
|
try {
|
|
21
|
+
leaseResult = await acquireSharedRuntimeLease({ packageRoot });
|
|
22
|
+
if (!leaseResult.acquired) throw new Error('RUNTIME_PROTECTION_UNAVAILABLE');
|
|
18
23
|
leaseResult.lease.assertOwned();
|
|
19
24
|
process.argv = [process.execPath, mainPath, ...process.argv.slice(2)];
|
|
20
25
|
await import(pathToFileURL(mainPath).href);
|
|
21
26
|
leaseResult.lease.assertOwned();
|
|
22
27
|
} catch (error) {
|
|
23
|
-
|
|
28
|
+
try {
|
|
29
|
+
if (diagnostics?.enabled) diagnostics.write(error, 'bootstrap-failure');
|
|
30
|
+
else recordRuntimeExit({ homeDir: process.env.BLUN_HOME, source: 'bootstrap',
|
|
31
|
+
kind: 'bootstrap-failure', phase: 'startup', error });
|
|
32
|
+
} catch {}
|
|
33
|
+
try { diagnostics?.dispose(); } catch {}
|
|
34
|
+
try { await leaseResult?.lease?.release(); }
|
|
35
|
+
catch (leaseError) {
|
|
36
|
+
try {
|
|
37
|
+
recordRuntimeExit({ homeDir: process.env.BLUN_HOME, source: 'bootstrap',
|
|
38
|
+
kind: 'lease-release-failure', phase: 'cleanup', error: leaseError });
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
24
41
|
throw error;
|
|
25
42
|
}
|
|
26
43
|
|
package/bin/launcher-mode.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
3
6
|
const LAUNCHER_MODES = Object.freeze({
|
|
4
7
|
BLUN: 'blun',
|
|
5
8
|
KING: 'king',
|
|
@@ -22,9 +25,43 @@ function launcherModeFromArgv(argv) {
|
|
|
22
25
|
return command === LAUNCHER_MODES.KING ? LAUNCHER_MODES.KING : LAUNCHER_MODES.BLUN;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
function
|
|
28
|
+
function runtimePolicyEnvironment(blunDir) {
|
|
29
|
+
if (blunDir === undefined) return {};
|
|
30
|
+
let policy;
|
|
31
|
+
try {
|
|
32
|
+
if (!path.isAbsolute(blunDir)) throw new Error('Invalid home');
|
|
33
|
+
const file = path.join(blunDir, 'runtime-policy.json');
|
|
34
|
+
const stat = fs.lstatSync(file);
|
|
35
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error('Invalid file');
|
|
36
|
+
policy = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT') return {};
|
|
39
|
+
throw new Error('Invalid BLUN runtime policy');
|
|
40
|
+
}
|
|
41
|
+
const review = policy?.responseReview;
|
|
42
|
+
if (policy?.schema !== 'blun.runtime-policy/v1'
|
|
43
|
+
|| Object.keys(policy).sort().join(',') !== 'responseReview,schema'
|
|
44
|
+
|| !review || Object.keys(review).sort().join(',') !== 'guardVersion,language,required'
|
|
45
|
+
|| review.required !== true || typeof review.language !== 'string'
|
|
46
|
+
|| !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(review.language)
|
|
47
|
+
|| /^(auto|all)$/i.test(review.language)
|
|
48
|
+
|| typeof review.guardVersion !== 'string' || !/^\d+\.\d+\.\d+$/.test(review.guardVersion)) {
|
|
49
|
+
throw new Error('Invalid BLUN runtime policy');
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
BLUN_LANGUAGE_GUARD_MANDATORY: '1',
|
|
53
|
+
BLUN_LANGUAGE_GUARD_LANGUAGE: review.language,
|
|
54
|
+
BLUN_LANGUAGE_GUARD_VERSION: review.guardVersion,
|
|
55
|
+
BLUN_TELEGRAM_LANGUAGE_GATE: 'required',
|
|
56
|
+
BLUN_TELEGRAM_OUTPUT_LOCALE: review.language,
|
|
57
|
+
AGENTSPINE_STATE_DIR: path.join(blunDir, 'state', 'agent-spine'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createLauncherEnvironment(sourceEnv, mode, publicPackageVersion, blunDir) {
|
|
26
62
|
return {
|
|
27
63
|
...sourceEnv,
|
|
64
|
+
...runtimePolicyEnvironment(blunDir),
|
|
28
65
|
BLUN_NO_AUTO_UPDATE: '1',
|
|
29
66
|
BLUN_PUBLIC_PACKAGE_VERSION: publicPackageVersion,
|
|
30
67
|
BLUN_TELEGRAM_ATTACH: mode === LAUNCHER_MODES.KING ? 'on' : 'off',
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const RESTART_WINDOW_MS = 60 * 60 * 1000;
|
|
8
|
+
const MAX_RESTARTS_PER_WINDOW = 3;
|
|
9
|
+
const RESTART_DELAYS_MS = Object.freeze([1000, 3000, 5000]);
|
|
10
|
+
const INTENTIONAL_STOP_TTL_MS = 2 * 60 * 1000;
|
|
11
|
+
const OPERATOR_SIGNALS = new Set(['SIGHUP', 'SIGINT', 'SIGTERM']);
|
|
12
|
+
|
|
13
|
+
function restartBudgetPath(homeDir) {
|
|
14
|
+
return path.join(path.resolve(homeDir), 'diagnostics', 'launcher-restart-budget.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function intentionalStopMarkerPath(homeDir) {
|
|
18
|
+
return path.join(path.resolve(homeDir), 'diagnostics', 'intentional-core-stop.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function launcherStatusQueuePath(homeDir, stateDir) {
|
|
22
|
+
return path.join(stateDir ? path.resolve(stateDir) : path.join(path.resolve(homeDir), 'channels', 'telegram'),
|
|
23
|
+
'launcher-status-queue.jsonl');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function atomicWriteJson(file, value, fsImpl = fs) {
|
|
27
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
28
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
fsImpl.writeFileSync(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
31
|
+
fsImpl.renameSync(temporary, file);
|
|
32
|
+
} finally {
|
|
33
|
+
try { fsImpl.rmSync(temporary, { force: true }); } catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function claimLauncherRestart(options) {
|
|
38
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
39
|
+
const fsImpl = options.fsImpl || fs;
|
|
40
|
+
const file = restartBudgetPath(options.homeDir);
|
|
41
|
+
const denied = { allowed: false, attempt: MAX_RESTARTS_PER_WINDOW, limit: MAX_RESTARTS_PER_WINDOW, windowMs: RESTART_WINDOW_MS };
|
|
42
|
+
let attempts = [];
|
|
43
|
+
try {
|
|
44
|
+
const stat = fsImpl.lstatSync(file);
|
|
45
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return denied;
|
|
46
|
+
const saved = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
47
|
+
if (saved?.schema !== 'blun.launcher-restart-budget/v1' || !Array.isArray(saved.attempts)
|
|
48
|
+
|| saved.attempts.some((value) => !Number.isFinite(value) || value > nowMs)) return denied;
|
|
49
|
+
attempts = saved.attempts.filter((value) => value > nowMs - RESTART_WINDOW_MS);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code !== 'ENOENT') return denied;
|
|
52
|
+
}
|
|
53
|
+
if (attempts.length >= MAX_RESTARTS_PER_WINDOW) return { ...denied, attempt: attempts.length };
|
|
54
|
+
attempts.push(nowMs);
|
|
55
|
+
try {
|
|
56
|
+
atomicWriteJson(file, {
|
|
57
|
+
schema: 'blun.launcher-restart-budget/v1', updatedAt: new Date(nowMs).toISOString(), attempts,
|
|
58
|
+
}, fsImpl);
|
|
59
|
+
} catch {
|
|
60
|
+
// Never restart without a durable budget claim.
|
|
61
|
+
return denied;
|
|
62
|
+
}
|
|
63
|
+
return { allowed: true, attempt: attempts.length, limit: MAX_RESTARTS_PER_WINDOW, windowMs: RESTART_WINDOW_MS };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isWindowsFailFastExitCode(code) {
|
|
67
|
+
if (!Number.isInteger(code)) return false;
|
|
68
|
+
const unsigned = code >>> 0;
|
|
69
|
+
return unsigned >= 0xC0000000 && unsigned !== 0xC000013A;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAbnormalCoreExit(result, loaded) {
|
|
73
|
+
if (OPERATOR_SIGNALS.has(result?.signal)) return false;
|
|
74
|
+
return Boolean(result?.error) || (loaded === true && isWindowsFailFastExitCode(result?.code));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeIntentionalStopMarker(options) {
|
|
78
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
79
|
+
const file = intentionalStopMarkerPath(options.homeDir);
|
|
80
|
+
atomicWriteJson(file, {
|
|
81
|
+
schema: 'blun.intentional-core-stop/v1', pid: options.pid,
|
|
82
|
+
createdAtMs: nowMs, expiresAtMs: nowMs + INTENTIONAL_STOP_TTL_MS,
|
|
83
|
+
}, options.fsImpl);
|
|
84
|
+
return file;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function consumeIntentionalStopMarker(options) {
|
|
88
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
89
|
+
const file = intentionalStopMarkerPath(options.homeDir);
|
|
90
|
+
const fsImpl = options.fsImpl || fs;
|
|
91
|
+
let marker;
|
|
92
|
+
try {
|
|
93
|
+
const stat = fsImpl.lstatSync(file);
|
|
94
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return false;
|
|
95
|
+
marker = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
96
|
+
fsImpl.rmSync(file);
|
|
97
|
+
} catch { return false; }
|
|
98
|
+
return marker?.schema === 'blun.intentional-core-stop/v1'
|
|
99
|
+
&& Number.isInteger(marker.pid) && marker.pid === options.childPid
|
|
100
|
+
&& Number.isFinite(marker.createdAtMs) && Number.isFinite(marker.expiresAtMs)
|
|
101
|
+
&& marker.createdAtMs <= nowMs && marker.expiresAtMs >= nowMs
|
|
102
|
+
&& marker.expiresAtMs - marker.createdAtMs <= INTENTIONAL_STOP_TTL_MS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function restartDelayMs(attempt) {
|
|
106
|
+
return RESTART_DELAYS_MS[Math.max(0, Math.min(RESTART_DELAYS_MS.length - 1, attempt - 1))];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function appendLauncherStatus(options) {
|
|
110
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
111
|
+
const exhausted = options.kind === 'launcher-restart-exhausted';
|
|
112
|
+
const signal = options.signal ? ` signal=${options.signal}` : '';
|
|
113
|
+
const event = {
|
|
114
|
+
schema: 'blun.launcher-status/v1', id: crypto.randomUUID(), createdAt: new Date(nowMs).toISOString(),
|
|
115
|
+
kind: options.kind, attempt: options.attempt, limit: options.limit,
|
|
116
|
+
exitCode: options.exitCode, signal: options.signal,
|
|
117
|
+
text: `[launcher.restart] ${exhausted ? 'stopped ' : ''}${options.attempt}/${options.limit} exit=${options.exitCode}${signal}`,
|
|
118
|
+
};
|
|
119
|
+
const file = launcherStatusQueuePath(options.homeDir, options.stateDir);
|
|
120
|
+
const fsImpl = options.fsImpl || fs;
|
|
121
|
+
fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
122
|
+
fsImpl.appendFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
123
|
+
return event;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
INTENTIONAL_STOP_TTL_MS, MAX_RESTARTS_PER_WINDOW, RESTART_DELAYS_MS, RESTART_WINDOW_MS,
|
|
128
|
+
appendLauncherStatus, claimLauncherRestart, consumeIntentionalStopMarker, intentionalStopMarkerPath,
|
|
129
|
+
isAbnormalCoreExit, isWindowsFailFastExitCode, launcherStatusQueuePath, restartBudgetPath,
|
|
130
|
+
restartDelayMs, writeIntentionalStopMarker,
|
|
131
|
+
};
|
package/bin/launcher-runtime.js
CHANGED
|
@@ -22,6 +22,11 @@ const {
|
|
|
22
22
|
seedStandardTools,
|
|
23
23
|
} = require('./standard-tools-bootstrap');
|
|
24
24
|
const { CORE_LOADED_MESSAGE } = require('./core-bootstrap');
|
|
25
|
+
const {
|
|
26
|
+
appendLauncherStatus, claimLauncherRestart, consumeIntentionalStopMarker,
|
|
27
|
+
isAbnormalCoreExit, restartDelayMs,
|
|
28
|
+
} = require('./launcher-restart-policy.cjs');
|
|
29
|
+
const { recordRuntimeExit } = require('./runtime-exit-ledger.cjs');
|
|
25
30
|
const { prepareManagedNodeRuntime } = require('./node-runtime');
|
|
26
31
|
const { repairConfiguredNativeModules } = require('./native-module-repair');
|
|
27
32
|
const { acquireSharedRuntimeLease, tryAcquireUpdateLease } = require('./update-lease');
|
|
@@ -109,6 +114,7 @@ function spawnProtectedCore(args, env, cwd) {
|
|
|
109
114
|
);
|
|
110
115
|
let loadedSettled = false;
|
|
111
116
|
let completedSettled = false;
|
|
117
|
+
let operatorSignal;
|
|
112
118
|
let resolveLoaded;
|
|
113
119
|
let resolveCompleted;
|
|
114
120
|
const loadTimer = setTimeout(() => {
|
|
@@ -129,11 +135,12 @@ function spawnProtectedCore(args, env, cwd) {
|
|
|
129
135
|
completedSettled = true;
|
|
130
136
|
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
131
137
|
settleLoaded(false);
|
|
132
|
-
resolveCompleted(result);
|
|
138
|
+
resolveCompleted(operatorSignal === undefined ? result : { ...result, signal: operatorSignal });
|
|
133
139
|
};
|
|
134
140
|
const signalHandlers = new Map();
|
|
135
141
|
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
|
136
142
|
const handler = () => {
|
|
143
|
+
operatorSignal = signal;
|
|
137
144
|
try {
|
|
138
145
|
child.kill(signal);
|
|
139
146
|
} catch {}
|
|
@@ -154,12 +161,45 @@ function spawnProtectedCore(args, env, cwd) {
|
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
async function superviseProtectedCore(args, env, cwd, releaseNotice) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
let noticeReleased = false;
|
|
165
|
+
for (;;) {
|
|
166
|
+
const core = spawnProtectedCore(args, env, cwd);
|
|
167
|
+
const loaded = await core.loaded;
|
|
168
|
+
if (!noticeReleased) {
|
|
169
|
+
noticeReleased = true;
|
|
170
|
+
await releaseNotice();
|
|
171
|
+
}
|
|
172
|
+
const result = await core.completed;
|
|
173
|
+
const exitCode = result.error ? 1 : exitCodeForChild(result, loaded);
|
|
174
|
+
const intentionalStop = consumeIntentionalStopMarker({
|
|
175
|
+
homeDir: env.BLUN_HOME, childPid: core.child.pid, nowMs: Date.now(),
|
|
176
|
+
});
|
|
177
|
+
recordRuntimeExit({
|
|
178
|
+
homeDir: env.BLUN_HOME, source: 'launcher', kind: result.error ? 'child-error' : 'child-exit',
|
|
179
|
+
exitCode, signal: result.signal, phase: loaded ? 'runtime' : 'startup',
|
|
180
|
+
cliVersion: env.BLUN_PUBLIC_PACKAGE_VERSION, profile: env.BLUN_PROFILE,
|
|
181
|
+
loaded, childPid: core.child.pid, error: result.error,
|
|
182
|
+
});
|
|
183
|
+
if (intentionalStop || !isAbnormalCoreExit(result, loaded)) {
|
|
184
|
+
if (result.error) throw result.error;
|
|
185
|
+
return exitCode;
|
|
186
|
+
}
|
|
187
|
+
const claim = claimLauncherRestart({ homeDir: env.BLUN_HOME, nowMs: Date.now() });
|
|
188
|
+
try {
|
|
189
|
+
appendLauncherStatus({
|
|
190
|
+
homeDir: env.BLUN_HOME,
|
|
191
|
+
stateDir: env.BLUN_TELEGRAM_STATE_DIR
|
|
192
|
+
|| path.join(env.BLUN_SHARED_HOME || env.BLUN_HOME, 'channels', 'telegram'),
|
|
193
|
+
nowMs: Date.now(), attempt: claim.attempt, limit: claim.limit, exitCode, signal: result.signal,
|
|
194
|
+
kind: claim.allowed ? 'launcher-restart' : 'launcher-restart-exhausted',
|
|
195
|
+
});
|
|
196
|
+
} catch {}
|
|
197
|
+
if (!claim.allowed) {
|
|
198
|
+
if (result.error) throw result.error;
|
|
199
|
+
return exitCode;
|
|
200
|
+
}
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, restartDelayMs(claim.attempt)));
|
|
202
|
+
}
|
|
163
203
|
}
|
|
164
204
|
|
|
165
205
|
function exitCodeForChild(result, loaded) {
|
|
@@ -403,9 +443,8 @@ async function runLauncher(options = {}) {
|
|
|
403
443
|
}
|
|
404
444
|
|
|
405
445
|
// --- 6. Start -----------------------------------------------------------
|
|
406
|
-
const env = createLauncherEnvironment(process.env, mode, readPackageVersion());
|
|
446
|
+
const env = createLauncherEnvironment(process.env, mode, readPackageVersion(), blunDir);
|
|
407
447
|
env.BLUN_HOME = blunDir;
|
|
408
|
-
env.BLUN_MODEL_MAX_COMPLETION_TOKENS = env.BLUN_MODEL_MAX_COMPLETION_TOKENS || '32768';
|
|
409
448
|
process.exitCode = await superviseProtectedCore(
|
|
410
449
|
ARGS,
|
|
411
450
|
env,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const DEFAULT_RETAIN_BYTES = 512 * 1024;
|
|
9
|
+
|
|
10
|
+
function runtimeExitLedgerPath(homeDir) {
|
|
11
|
+
return path.join(homeDir, 'diagnostics', 'runtime-exits.jsonl');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sanitizeExitText(input, maxChars = 2048) {
|
|
15
|
+
let value = String(input ?? '');
|
|
16
|
+
const homes = new Set([os.homedir(), process.env.USERPROFILE, process.env.HOME]
|
|
17
|
+
.filter((entry) => typeof entry === 'string' && entry.length > 2));
|
|
18
|
+
for (const home of homes) {
|
|
19
|
+
for (const variant of [path.normalize(home), home.replaceAll('\\', '/')]) {
|
|
20
|
+
const escaped = variant.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
21
|
+
value = value.replace(new RegExp(escaped, process.platform === 'win32' ? 'giu' : 'gu'), '$HOME');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
value = value
|
|
25
|
+
.replace(/[A-Za-z]:[\\/]Users[\\/][^\\/\s"']+/giu, '$HOME')
|
|
26
|
+
.replace(/\/home\/[^/\s"']+/gu, '$HOME')
|
|
27
|
+
.replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/giu, '$1<redacted>')
|
|
28
|
+
.replace(/\bsk-[A-Za-z0-9_-]{10,}\b/gu, '<redacted>')
|
|
29
|
+
.replace(/\b\d{6,}:[A-Za-z0-9_-]{20,}\b/gu, '<redacted>')
|
|
30
|
+
.replace(/("(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/giu, '$1"<redacted>"')
|
|
31
|
+
.replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)\s*[=:]\s*)[^\s,;}]+/giu, '$1<redacted>')
|
|
32
|
+
.replace(/(https?:\/\/[^\s/:@]+:)[^\s/@]+@/giu, '$1<redacted>@');
|
|
33
|
+
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...[truncated]`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildRuntimeExitRecord(input) {
|
|
37
|
+
const now = typeof input.now === 'function' ? input.now() : new Date();
|
|
38
|
+
const record = { schemaVersion: 1, ts: new Date(now).toISOString() };
|
|
39
|
+
for (const key of ['source', 'kind', 'signal', 'phase', 'cliVersion', 'profile', 'sessionId', 'lastTool', 'detail']) {
|
|
40
|
+
if (input[key] !== undefined && input[key] !== '') record[key] = sanitizeExitText(input[key]);
|
|
41
|
+
}
|
|
42
|
+
for (const [key, value] of Object.entries({ exitCode: input.exitCode, pid: input.pid ?? process.pid,
|
|
43
|
+
ppid: input.ppid ?? process.ppid, childPid: input.childPid })) {
|
|
44
|
+
if (Number.isInteger(value)) record[key] = value;
|
|
45
|
+
}
|
|
46
|
+
if (typeof input.loaded === 'boolean') record.loaded = input.loaded;
|
|
47
|
+
if (input.error !== undefined && input.error !== null) {
|
|
48
|
+
record.error = {
|
|
49
|
+
name: sanitizeExitText(input.error.name || 'Error', 160),
|
|
50
|
+
message: sanitizeExitText(input.error.message || input.error),
|
|
51
|
+
stack: sanitizeExitText(input.error.stack || '', 8192),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function directoryIdentity(directory) {
|
|
58
|
+
const stat = fs.lstatSync(directory, { bigint: true });
|
|
59
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Unsafe exit directory');
|
|
60
|
+
return stat;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sameIdentity(left, right) {
|
|
64
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function regularLedger(stat) {
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) throw new Error('Unsafe exit ledger');
|
|
69
|
+
return stat;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function trimLedger(descriptor, maxBytes, retainBytes, incomingBytes) {
|
|
73
|
+
const size = Number(regularLedger(fs.fstatSync(descriptor, { bigint: true })).size);
|
|
74
|
+
if (!Number.isSafeInteger(size)) throw new Error('Invalid exit ledger size');
|
|
75
|
+
if (size + incomingBytes <= maxBytes) return;
|
|
76
|
+
const keepBytes = Math.max(0, Math.min(size, retainBytes, maxBytes - incomingBytes));
|
|
77
|
+
const contents = Buffer.alloc(keepBytes);
|
|
78
|
+
const read = fs.readSync(descriptor, contents, 0, keepBytes, size - keepBytes);
|
|
79
|
+
const tail = contents.subarray(0, read);
|
|
80
|
+
const lineStart = size > keepBytes ? tail.indexOf(0x0a) + 1 : 0;
|
|
81
|
+
fs.ftruncateSync(descriptor, 0);
|
|
82
|
+
fs.writeFileSync(descriptor, lineStart > 0 || size === keepBytes ? tail.subarray(lineStart) : '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordRuntimeExit(input = {}) {
|
|
86
|
+
let descriptor;
|
|
87
|
+
let appendDescriptor;
|
|
88
|
+
try {
|
|
89
|
+
const homeDir = typeof input.homeDir === 'string' ? input.homeDir.trim() : '';
|
|
90
|
+
if (!homeDir || !path.isAbsolute(homeDir)) return false;
|
|
91
|
+
// Pin the private home and diagnostic directory before opening any output.
|
|
92
|
+
fs.mkdirSync(homeDir, { recursive: true, mode: 0o700 });
|
|
93
|
+
const homeIdentity = directoryIdentity(homeDir);
|
|
94
|
+
const file = runtimeExitLedgerPath(homeDir);
|
|
95
|
+
const directory = path.dirname(file);
|
|
96
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); }
|
|
97
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
98
|
+
const directoryStat = directoryIdentity(directory);
|
|
99
|
+
let before;
|
|
100
|
+
try { before = regularLedger(fs.lstatSync(file, { bigint: true })); }
|
|
101
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
102
|
+
const flags = fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0)
|
|
103
|
+
| (fs.constants.O_NONBLOCK ?? 0) | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL);
|
|
104
|
+
descriptor = fs.openSync(file, flags, 0o600);
|
|
105
|
+
const opened = regularLedger(fs.fstatSync(descriptor, { bigint: true }));
|
|
106
|
+
if ((before && !sameIdentity(before, opened))
|
|
107
|
+
|| !sameIdentity(opened, regularLedger(fs.lstatSync(file, { bigint: true })))
|
|
108
|
+
|| !sameIdentity(homeIdentity, directoryIdentity(homeDir))
|
|
109
|
+
|| !sameIdentity(directoryStat, directoryIdentity(directory))) throw new Error('Exit ledger changed');
|
|
110
|
+
const maxBytes = Math.max(1024, Number.isInteger(input.maxBytes) ? input.maxBytes : DEFAULT_MAX_BYTES);
|
|
111
|
+
const retainBytes = Math.max(0, Number.isInteger(input.retainBytes) ? input.retainBytes : DEFAULT_RETAIN_BYTES);
|
|
112
|
+
const record = buildRuntimeExitRecord(input);
|
|
113
|
+
let line = `${JSON.stringify(record)}\n`;
|
|
114
|
+
// Retain identity/exit fields even when a diagnostic detail exceeds the cap.
|
|
115
|
+
for (const key of ['error', 'detail', 'lastTool', 'sessionId', 'profile', 'cliVersion', 'phase', 'source', 'kind', 'signal']) {
|
|
116
|
+
if (Buffer.byteLength(line) <= maxBytes) break;
|
|
117
|
+
delete record[key];
|
|
118
|
+
record.truncated = true;
|
|
119
|
+
line = `${JSON.stringify(record)}\n`;
|
|
120
|
+
}
|
|
121
|
+
trimLedger(descriptor, maxBytes, retainBytes, Buffer.byteLength(line));
|
|
122
|
+
// Windows append-only handles cannot truncate. Keep both handles bound to
|
|
123
|
+
// the same checked inode, using the second handle only for atomic append.
|
|
124
|
+
appendDescriptor = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_APPEND
|
|
125
|
+
| (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
126
|
+
if (!sameIdentity(opened, regularLedger(fs.fstatSync(appendDescriptor, { bigint: true })))
|
|
127
|
+
|| !sameIdentity(opened, regularLedger(fs.lstatSync(file, { bigint: true })))
|
|
128
|
+
|| !sameIdentity(homeIdentity, directoryIdentity(homeDir))
|
|
129
|
+
|| !sameIdentity(directoryStat, directoryIdentity(directory))) throw new Error('Exit ledger changed');
|
|
130
|
+
fs.appendFileSync(appendDescriptor, line, { encoding: 'utf8' });
|
|
131
|
+
try { fs.fchmodSync(descriptor, 0o600); } catch {}
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
} finally {
|
|
136
|
+
if (appendDescriptor !== undefined) { try { fs.closeSync(appendDescriptor); } catch {} }
|
|
137
|
+
if (descriptor !== undefined) { try { fs.closeSync(descriptor); } catch {} }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
DEFAULT_MAX_BYTES, DEFAULT_RETAIN_BYTES, buildRuntimeExitRecord, recordRuntimeExit,
|
|
143
|
+
runtimeExitLedgerPath, sanitizeExitText,
|
|
144
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface RuntimeExitInput {
|
|
2
|
+
homeDir?: string;
|
|
3
|
+
source?: string;
|
|
4
|
+
kind?: string;
|
|
5
|
+
signal?: string;
|
|
6
|
+
phase?: string;
|
|
7
|
+
cliVersion?: string;
|
|
8
|
+
profile?: string;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
lastTool?: string;
|
|
11
|
+
detail?: string;
|
|
12
|
+
exitCode?: number;
|
|
13
|
+
pid?: number;
|
|
14
|
+
ppid?: number;
|
|
15
|
+
childPid?: number;
|
|
16
|
+
loaded?: boolean;
|
|
17
|
+
error?: unknown;
|
|
18
|
+
maxBytes?: number;
|
|
19
|
+
retainBytes?: number;
|
|
20
|
+
now?: () => Date | number | string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function recordRuntimeExit(input?: RuntimeExitInput): boolean;
|