ultra-builder-pro-cli 0.20.0 → 0.22.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/.ultra-template/docs/research/README.md +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +10 -3
- package/.ultra-template/templates/task-context.md +19 -0
- package/CHANGELOG.md +106 -0
- package/README.md +215 -41
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +36 -3
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +43 -30
- package/bin/orchestrator.js +64 -24
- package/docs/AGENT-CONTEXT.md +89 -49
- package/docs/ARCHITECTURE.md +112 -45
- package/docs/ARTIFACT-AUTHORITY.md +274 -0
- package/docs/COMMIT-HASH-BACKFILL.md +33 -90
- package/docs/DECISIONS.md +14 -6
- package/docs/LEGACY-CLI-CROSSWALK.md +16 -6
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +6 -4
- package/docs/ROADMAP.md +20 -6
- package/docs/RUNTIME-COMPAT-MATRIX.md +29 -16
- package/docs/STATE-DB-ACCESS-POLICY.md +43 -13
- package/docs/WORKFLOW-LIFECYCLE.md +105 -50
- package/hooks/README.md +10 -8
- package/hooks/active_task_context.py +105 -18
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1127 -0
- package/mcp-server/lib/baseline-workflow.cjs +69 -28
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +142 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +345 -3
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +401 -11
- package/mcp-server/lib/git-bootstrap.cjs +458 -47
- package/mcp-server/lib/init-project.cjs +240 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +211 -42
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-authority.cjs +19 -3
- package/mcp-server/lib/state-db.cjs +263 -9
- package/mcp-server/lib/state-ops.cjs +254 -50
- package/mcp-server/lib/task-ledger.cjs +1342 -0
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +610 -58
- package/mcp-server/server.cjs +423 -42
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +34 -8
- package/skills/ultra-deliver/SKILL.md +26 -11
- package/skills/ultra-dev/SKILL.md +52 -10
- package/skills/ultra-doctor/SKILL.md +16 -3
- package/skills/ultra-init/SKILL.md +22 -11
- package/skills/ultra-plan/SKILL.md +58 -18
- package/skills/ultra-research/SKILL.md +37 -35
- package/skills/ultra-review/SKILL.md +14 -8
- package/skills/ultra-status/SKILL.md +18 -9
- package/skills/ultra-test/SKILL.md +11 -5
- package/skills/ultra-think/SKILL.md +6 -2
- package/skills/ultra-think/references/decision-dialogue.md +17 -6
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +21 -7
- package/spec/mcp-tools.yaml +629 -44
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/context-file.v4.5.schema.json +1 -1
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +51 -14
- package/spec/schemas/task-ledger.v1.schema.json +217 -0
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +1000 -82
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +32 -6
- package/ultra-tools/commands/system.cjs +129 -22
- package/.ultra-template/tasks/contexts/TEMPLATE.md +0 -19
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('node:child_process');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const runtimePaths = require('../mcp-server/lib/runtime-paths.cjs');
|
|
9
|
+
|
|
10
|
+
const JOURNAL_VERSION = 2;
|
|
11
|
+
const LEGACY_JOURNAL_VERSION = 1;
|
|
12
|
+
const MAX_JOURNAL_BYTES = 32 * 1024;
|
|
13
|
+
const MAX_LOCK_BYTES = 4 * 1024;
|
|
14
|
+
const LOCK_VERSION = 2;
|
|
15
|
+
const WORKER_FILENAME = 'session-close-journal-worker.cjs';
|
|
16
|
+
const TERMINAL_STATUSES = new Set(['completed', 'crashed']);
|
|
17
|
+
const JOURNAL_PHASES = new Set([
|
|
18
|
+
'prepared',
|
|
19
|
+
'worker_running',
|
|
20
|
+
'worktree_removed',
|
|
21
|
+
'removal_failed',
|
|
22
|
+
'recovery_failed',
|
|
23
|
+
]);
|
|
24
|
+
const PHASE_TRANSITIONS = Object.freeze({
|
|
25
|
+
prepared: new Set([
|
|
26
|
+
'worker_running',
|
|
27
|
+
'worktree_removed',
|
|
28
|
+
'removal_failed',
|
|
29
|
+
'recovery_failed',
|
|
30
|
+
]),
|
|
31
|
+
worker_running: new Set([
|
|
32
|
+
'worktree_removed',
|
|
33
|
+
'removal_failed',
|
|
34
|
+
'recovery_failed',
|
|
35
|
+
]),
|
|
36
|
+
removal_failed: new Set([
|
|
37
|
+
'worktree_removed',
|
|
38
|
+
'recovery_failed',
|
|
39
|
+
]),
|
|
40
|
+
worktree_removed: new Set(['recovery_failed']),
|
|
41
|
+
recovery_failed: new Set(),
|
|
42
|
+
});
|
|
43
|
+
const V1_KEYS = Object.freeze([
|
|
44
|
+
'created_at',
|
|
45
|
+
'error',
|
|
46
|
+
'phase',
|
|
47
|
+
'requested_status',
|
|
48
|
+
'sid',
|
|
49
|
+
'task_id',
|
|
50
|
+
'updated_at',
|
|
51
|
+
'version',
|
|
52
|
+
'worktree_path',
|
|
53
|
+
]);
|
|
54
|
+
const V2_KEYS = Object.freeze([...V1_KEYS, 'generation'].sort());
|
|
55
|
+
|
|
56
|
+
class SessionCloseJournalError extends Error {
|
|
57
|
+
constructor(code, message, { cause, details } = {}) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = 'SessionCloseJournalError';
|
|
60
|
+
this.code = code;
|
|
61
|
+
if (cause) this.cause = cause;
|
|
62
|
+
if (details) this.details = details;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertSid(sid) {
|
|
67
|
+
if (typeof sid !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(sid)) {
|
|
68
|
+
throw new SessionCloseJournalError(
|
|
69
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
70
|
+
`invalid session id for close journal: ${String(sid)}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return sid;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertTerminalStatus(status) {
|
|
77
|
+
if (!TERMINAL_STATUSES.has(status)) {
|
|
78
|
+
throw new SessionCloseJournalError(
|
|
79
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
80
|
+
`session close target must be completed or crashed, got ${String(status)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return status;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function journalRoot(repoRoot) {
|
|
87
|
+
return path.join(
|
|
88
|
+
runtimePaths.pathsFor(repoRoot).runtimeDir,
|
|
89
|
+
'recovery',
|
|
90
|
+
'session-close',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function journalPath(repoRoot, sid) {
|
|
95
|
+
return path.join(journalRoot(repoRoot), `${assertSid(sid)}.json`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function statIdentity(stat) {
|
|
99
|
+
return {
|
|
100
|
+
dev: String(stat.dev),
|
|
101
|
+
ino: String(stat.ino),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function identitiesEqual(left, right) {
|
|
106
|
+
return String(left?.dev) === String(right?.dev)
|
|
107
|
+
&& String(left?.ino) === String(right?.ino);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function assertRuntimeRoot(repoRoot) {
|
|
111
|
+
runtimePaths.validateProjectLayout(repoRoot, {
|
|
112
|
+
env: {},
|
|
113
|
+
validateRuntimeTree: true,
|
|
114
|
+
});
|
|
115
|
+
const runtimeDir = runtimePaths.pathsFor(repoRoot).runtimeDir;
|
|
116
|
+
const stat = fs.lstatSync(runtimeDir, {
|
|
117
|
+
bigint: true,
|
|
118
|
+
throwIfNoEntry: false,
|
|
119
|
+
});
|
|
120
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
121
|
+
throw new SessionCloseJournalError(
|
|
122
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
123
|
+
`session close runtime root must be a real directory: ${runtimeDir}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
runtimeDir,
|
|
128
|
+
identity: statIdentity(stat),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function decodeHelperResult(result) {
|
|
133
|
+
let payload;
|
|
134
|
+
try {
|
|
135
|
+
payload = JSON.parse(String(result.stdout || '').trim());
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new SessionCloseJournalError(
|
|
138
|
+
'SESSION_CLOSE_JOURNAL_HELPER_FAILED',
|
|
139
|
+
`session close journal helper returned invalid output: ${String(result.stderr || '').trim()}`,
|
|
140
|
+
{ cause: result.error || error },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!payload.ok) {
|
|
144
|
+
throw new SessionCloseJournalError(
|
|
145
|
+
payload.error?.code || 'SESSION_CLOSE_JOURNAL_HELPER_FAILED',
|
|
146
|
+
payload.error?.message || 'session close journal helper failed',
|
|
147
|
+
{ details: payload.error?.details },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return payload.value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function workerPath() {
|
|
154
|
+
const runtimeRoot = process.env.UBP_RUNTIME_ROOT
|
|
155
|
+
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
156
|
+
: null;
|
|
157
|
+
const candidate = runtimeRoot
|
|
158
|
+
? path.join(runtimeRoot, 'runtime', WORKER_FILENAME)
|
|
159
|
+
: path.join(__dirname, WORKER_FILENAME);
|
|
160
|
+
const stat = fs.lstatSync(candidate, { throwIfNoEntry: false });
|
|
161
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile()) {
|
|
162
|
+
throw new SessionCloseJournalError(
|
|
163
|
+
'SESSION_CLOSE_JOURNAL_HELPER_FAILED',
|
|
164
|
+
`session close journal worker is missing or unsafe: ${candidate}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return candidate;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runHelper(repoRoot, request) {
|
|
171
|
+
const { runtimeDir, identity } = assertRuntimeRoot(repoRoot);
|
|
172
|
+
const result = childProcess.spawnSync(
|
|
173
|
+
process.execPath,
|
|
174
|
+
[workerPath()],
|
|
175
|
+
{
|
|
176
|
+
cwd: runtimeDir,
|
|
177
|
+
input: JSON.stringify({ ...request, runtime_identity: identity }),
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
maxBuffer: 1024 * 1024,
|
|
180
|
+
windowsHide: true,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
if (result.error) {
|
|
184
|
+
throw new SessionCloseJournalError(
|
|
185
|
+
'SESSION_CLOSE_JOURNAL_HELPER_FAILED',
|
|
186
|
+
`cannot start session close journal helper: ${result.error.message}`,
|
|
187
|
+
{ cause: result.error },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return decodeHelperResult(result);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function prepare(repoRoot, {
|
|
194
|
+
sid,
|
|
195
|
+
task_id,
|
|
196
|
+
requested_status,
|
|
197
|
+
worktree_path,
|
|
198
|
+
}) {
|
|
199
|
+
assertSid(sid);
|
|
200
|
+
assertTerminalStatus(requested_status);
|
|
201
|
+
assertBoundedString(task_id, 'task_id', { max: 256 });
|
|
202
|
+
assertBoundedString(worktree_path, 'worktree_path', { max: 4096 });
|
|
203
|
+
return {
|
|
204
|
+
intent: runHelper(repoRoot, {
|
|
205
|
+
op: 'prepare',
|
|
206
|
+
sid,
|
|
207
|
+
task_id,
|
|
208
|
+
requested_status,
|
|
209
|
+
worktree_path: path.resolve(worktree_path),
|
|
210
|
+
}),
|
|
211
|
+
path: journalPath(repoRoot, sid),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function read(repoRoot, sid) {
|
|
216
|
+
return runHelper(repoRoot, {
|
|
217
|
+
op: 'read',
|
|
218
|
+
sid: assertSid(sid),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function update(repoRoot, sid, patch, {
|
|
223
|
+
expected_generation = null,
|
|
224
|
+
} = {}) {
|
|
225
|
+
assertSid(sid);
|
|
226
|
+
return runHelper(repoRoot, {
|
|
227
|
+
op: 'update',
|
|
228
|
+
sid,
|
|
229
|
+
patch,
|
|
230
|
+
expected_generation,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function discard(repoRoot, sid, {
|
|
235
|
+
expected_generation = null,
|
|
236
|
+
} = {}) {
|
|
237
|
+
assertSid(sid);
|
|
238
|
+
return runHelper(repoRoot, {
|
|
239
|
+
op: 'discard',
|
|
240
|
+
sid,
|
|
241
|
+
expected_generation,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function list(repoRoot) {
|
|
246
|
+
return runHelper(repoRoot, { op: 'list' });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function findForTask(repoRoot, taskId) {
|
|
250
|
+
return list(repoRoot).find(
|
|
251
|
+
(intent) => intent && intent.task_id === taskId,
|
|
252
|
+
) || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function lstatOrNull(candidate) {
|
|
256
|
+
try {
|
|
257
|
+
return fs.lstatSync(candidate, { bigint: true });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error.code === 'ENOENT') return null;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function assertPinnedRuntimeRoot(expected) {
|
|
265
|
+
const actual = statIdentity(fs.statSync('.', { bigint: true }));
|
|
266
|
+
if (!identitiesEqual(actual, expected)) {
|
|
267
|
+
throw new SessionCloseJournalError(
|
|
268
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
269
|
+
'session close runtime root changed before it could be pinned',
|
|
270
|
+
{ details: { expected, actual } },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function ensureAndEnterDirectory(name) {
|
|
276
|
+
let stat = lstatOrNull(name);
|
|
277
|
+
if (!stat) {
|
|
278
|
+
try {
|
|
279
|
+
fs.mkdirSync(name, { mode: 0o700 });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.code !== 'EEXIST') throw error;
|
|
282
|
+
}
|
|
283
|
+
stat = lstatOrNull(name);
|
|
284
|
+
}
|
|
285
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
286
|
+
throw new SessionCloseJournalError(
|
|
287
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
288
|
+
`session close journal ancestor must be a real directory: ${name}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const expected = statIdentity(stat);
|
|
292
|
+
process.chdir(name);
|
|
293
|
+
const actual = statIdentity(fs.statSync('.', { bigint: true }));
|
|
294
|
+
if (!identitiesEqual(actual, expected)) {
|
|
295
|
+
throw new SessionCloseJournalError(
|
|
296
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
297
|
+
`session close journal ancestor changed while entering it: ${name}`,
|
|
298
|
+
{ details: { expected, actual } },
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function enterPinnedJournalRoot(expectedRuntimeIdentity) {
|
|
304
|
+
assertPinnedRuntimeRoot(expectedRuntimeIdentity);
|
|
305
|
+
ensureAndEnterDirectory('recovery');
|
|
306
|
+
ensureAndEnterDirectory('session-close');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function assertBoundedString(value, label, {
|
|
310
|
+
max,
|
|
311
|
+
allowEmpty = false,
|
|
312
|
+
} = {}) {
|
|
313
|
+
if (typeof value !== 'string'
|
|
314
|
+
|| (!allowEmpty && value.length === 0)
|
|
315
|
+
|| value.length > max
|
|
316
|
+
|| value.includes('\0')) {
|
|
317
|
+
throw new SessionCloseJournalError(
|
|
318
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
319
|
+
`session close journal ${label} is invalid`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function assertTimestamp(value, label) {
|
|
326
|
+
assertBoundedString(value, label, { max: 64 });
|
|
327
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
328
|
+
throw new SessionCloseJournalError(
|
|
329
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
330
|
+
`session close journal ${label} is not a timestamp`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validateIntent(value, file, expectedSid) {
|
|
337
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
338
|
+
throw new SessionCloseJournalError(
|
|
339
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
340
|
+
`session close journal must be an object: ${file}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const version = value.version;
|
|
344
|
+
const expectedKeys = version === JOURNAL_VERSION
|
|
345
|
+
? V2_KEYS
|
|
346
|
+
: version === LEGACY_JOURNAL_VERSION
|
|
347
|
+
? V1_KEYS
|
|
348
|
+
: null;
|
|
349
|
+
if (!expectedKeys
|
|
350
|
+
|| JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(expectedKeys)) {
|
|
351
|
+
throw new SessionCloseJournalError(
|
|
352
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
353
|
+
`session close journal has an unsupported schema: ${file}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
assertSid(value.sid);
|
|
357
|
+
if (expectedSid !== undefined && value.sid !== expectedSid) {
|
|
358
|
+
throw new SessionCloseJournalError(
|
|
359
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
360
|
+
`session close journal filename does not match payload SID: ${file}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
assertTerminalStatus(value.requested_status);
|
|
364
|
+
assertBoundedString(value.task_id, 'task_id', { max: 256 });
|
|
365
|
+
assertBoundedString(value.worktree_path, 'worktree_path', { max: 4096 });
|
|
366
|
+
if (!path.isAbsolute(value.worktree_path)
|
|
367
|
+
|| path.resolve(value.worktree_path) !== value.worktree_path) {
|
|
368
|
+
throw new SessionCloseJournalError(
|
|
369
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
370
|
+
`session close journal worktree_path must be canonical and absolute: ${file}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (!JOURNAL_PHASES.has(value.phase)) {
|
|
374
|
+
throw new SessionCloseJournalError(
|
|
375
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
376
|
+
`session close journal phase is invalid: ${file}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (value.error !== null) {
|
|
380
|
+
assertBoundedString(value.error, 'error', { max: 8192, allowEmpty: true });
|
|
381
|
+
}
|
|
382
|
+
assertTimestamp(value.created_at, 'created_at');
|
|
383
|
+
assertTimestamp(value.updated_at, 'updated_at');
|
|
384
|
+
const generation = version === LEGACY_JOURNAL_VERSION ? 0 : value.generation;
|
|
385
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
386
|
+
throw new SessionCloseJournalError(
|
|
387
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
388
|
+
`session close journal generation is invalid: ${file}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return version === LEGACY_JOURNAL_VERSION
|
|
392
|
+
? { ...value, generation }
|
|
393
|
+
: value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function intentFilename(sid) {
|
|
397
|
+
return `${assertSid(sid)}.json`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function lockFilename(sid) {
|
|
401
|
+
return `${assertSid(sid)}.lock`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function assertIdentity(value, label) {
|
|
405
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
406
|
+
|| JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(['dev', 'ino'])
|
|
407
|
+
|| !/^\d{1,32}$/u.test(String(value.dev))
|
|
408
|
+
|| !/^\d{1,32}$/u.test(String(value.ino))) {
|
|
409
|
+
throw new SessionCloseJournalError(
|
|
410
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
411
|
+
`session close journal ${label} identity is invalid`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
dev: String(value.dev),
|
|
416
|
+
ino: String(value.ino),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function processStartMarker(pid) {
|
|
421
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
422
|
+
if (process.platform === 'linux') {
|
|
423
|
+
try {
|
|
424
|
+
const value = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
425
|
+
const close = value.lastIndexOf(')');
|
|
426
|
+
if (close < 0) return null;
|
|
427
|
+
const fields = value.slice(close + 2).trim().split(/\s+/u);
|
|
428
|
+
const startTime = fields[19];
|
|
429
|
+
return startTime ? `linux:${startTime}` : null;
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (process.platform === 'win32') {
|
|
435
|
+
const result = childProcess.spawnSync(
|
|
436
|
+
'powershell.exe',
|
|
437
|
+
[
|
|
438
|
+
'-NoProfile',
|
|
439
|
+
'-NonInteractive',
|
|
440
|
+
'-Command',
|
|
441
|
+
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
|
|
442
|
+
],
|
|
443
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 2000 },
|
|
444
|
+
);
|
|
445
|
+
const value = String(result.stdout || '').trim();
|
|
446
|
+
return result.status === 0 && /^\d+$/u.test(value) ? `win32:${value}` : null;
|
|
447
|
+
}
|
|
448
|
+
const result = childProcess.spawnSync(
|
|
449
|
+
'ps',
|
|
450
|
+
['-o', 'lstart=', '-p', String(pid)],
|
|
451
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 2000 },
|
|
452
|
+
);
|
|
453
|
+
const value = String(result.stdout || '').trim().replace(/\s+/gu, ' ');
|
|
454
|
+
return result.status === 0 && value ? `${process.platform}:${value}` : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function pidIsAlive(pid) {
|
|
458
|
+
try {
|
|
459
|
+
process.kill(pid, 0);
|
|
460
|
+
return true;
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return error?.code === 'EPERM';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function validateLockMetadata(value, file, actualFileIdentity = null) {
|
|
467
|
+
const expectedKeys = [
|
|
468
|
+
'created_at',
|
|
469
|
+
'directory',
|
|
470
|
+
'file',
|
|
471
|
+
'pid',
|
|
472
|
+
'process_start',
|
|
473
|
+
'token',
|
|
474
|
+
'version',
|
|
475
|
+
];
|
|
476
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
477
|
+
|| JSON.stringify(Object.keys(value).sort()) !== JSON.stringify(expectedKeys)
|
|
478
|
+
|| value.version !== LOCK_VERSION
|
|
479
|
+
|| !Number.isSafeInteger(value.pid)
|
|
480
|
+
|| value.pid <= 0) {
|
|
481
|
+
throw new SessionCloseJournalError(
|
|
482
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
483
|
+
`session close journal lock has an unsupported schema: ${file}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
assertBoundedString(value.process_start, 'lock process_start', { max: 256 });
|
|
487
|
+
assertBoundedString(value.token, 'lock token', { max: 128 });
|
|
488
|
+
assertTimestamp(value.created_at, 'lock created_at');
|
|
489
|
+
const directory = assertIdentity(value.directory, 'lock directory');
|
|
490
|
+
const lockFile = assertIdentity(value.file, 'lock file');
|
|
491
|
+
if (actualFileIdentity && !identitiesEqual(lockFile, actualFileIdentity)) {
|
|
492
|
+
throw new SessionCloseJournalError(
|
|
493
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
494
|
+
`session close journal lock inode does not match its metadata: ${file}`,
|
|
495
|
+
{ details: { expected: lockFile, actual: actualFileIdentity } },
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
...value,
|
|
500
|
+
directory,
|
|
501
|
+
file: lockFile,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function readLockFile(sid) {
|
|
506
|
+
const file = lockFilename(sid);
|
|
507
|
+
let fd;
|
|
508
|
+
try {
|
|
509
|
+
fd = fs.openSync(
|
|
510
|
+
file,
|
|
511
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0),
|
|
512
|
+
);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (error.code === 'ENOENT') return null;
|
|
515
|
+
throw new SessionCloseJournalError(
|
|
516
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
517
|
+
`cannot open session close journal lock ${file}: ${error.message}`,
|
|
518
|
+
{ cause: error },
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
523
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_LOCK_BYTES) {
|
|
524
|
+
throw new SessionCloseJournalError(
|
|
525
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
526
|
+
`session close journal lock is not a bounded regular file: ${file}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
let value;
|
|
530
|
+
try {
|
|
531
|
+
value = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
532
|
+
} catch (error) {
|
|
533
|
+
throw new SessionCloseJournalError(
|
|
534
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
535
|
+
`cannot parse session close journal lock ${file}: ${error.message}`,
|
|
536
|
+
{ cause: error },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
const identity = statIdentity(stat);
|
|
540
|
+
return {
|
|
541
|
+
metadata: validateLockMetadata(value, file, identity),
|
|
542
|
+
identity,
|
|
543
|
+
};
|
|
544
|
+
} finally {
|
|
545
|
+
fs.closeSync(fd);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function reclaimStaleLock(sid) {
|
|
550
|
+
const existing = readLockFile(sid);
|
|
551
|
+
if (!existing) return true;
|
|
552
|
+
const { metadata, identity } = existing;
|
|
553
|
+
const currentDirectory = statIdentity(fs.statSync('.', { bigint: true }));
|
|
554
|
+
if (!identitiesEqual(metadata.directory, currentDirectory)) {
|
|
555
|
+
throw new SessionCloseJournalError(
|
|
556
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
557
|
+
`session ${sid} close journal lock belongs to a different directory`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (pidIsAlive(metadata.pid)) {
|
|
561
|
+
const actualStart = processStartMarker(metadata.pid);
|
|
562
|
+
if (!actualStart || actualStart === metadata.process_start) return false;
|
|
563
|
+
}
|
|
564
|
+
const current = lstatOrNull(lockFilename(sid));
|
|
565
|
+
if (!current || current.isSymbolicLink() || !current.isFile()
|
|
566
|
+
|| !identitiesEqual(statIdentity(current), identity)) {
|
|
567
|
+
throw new SessionCloseJournalError(
|
|
568
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
569
|
+
`session ${sid} close journal lock changed before stale-owner recovery`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
fs.unlinkSync(lockFilename(sid));
|
|
573
|
+
syncCurrentDirectory();
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function syncCurrentDirectory() {
|
|
578
|
+
let fd;
|
|
579
|
+
try {
|
|
580
|
+
fd = fs.openSync('.', fs.constants.O_RDONLY);
|
|
581
|
+
fs.fsyncSync(fd);
|
|
582
|
+
} finally {
|
|
583
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function readIntentFile(sid) {
|
|
588
|
+
const file = intentFilename(sid);
|
|
589
|
+
let fd;
|
|
590
|
+
try {
|
|
591
|
+
fd = fs.openSync(
|
|
592
|
+
file,
|
|
593
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0),
|
|
594
|
+
);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (error.code === 'ENOENT') return null;
|
|
597
|
+
throw new SessionCloseJournalError(
|
|
598
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
599
|
+
`cannot open session close journal ${file}: ${error.message}`,
|
|
600
|
+
{ cause: error },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const stat = fs.fstatSync(fd);
|
|
605
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_JOURNAL_BYTES) {
|
|
606
|
+
throw new SessionCloseJournalError(
|
|
607
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
608
|
+
`session close journal is not a bounded regular file: ${file}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
let value;
|
|
612
|
+
try {
|
|
613
|
+
value = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
614
|
+
} catch (error) {
|
|
615
|
+
throw new SessionCloseJournalError(
|
|
616
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
617
|
+
`cannot parse session close journal ${file}: ${error.message}`,
|
|
618
|
+
{ cause: error },
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return validateIntent(value, file, sid);
|
|
622
|
+
} finally {
|
|
623
|
+
fs.closeSync(fd);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function writeTemp(intent, sid) {
|
|
628
|
+
const temporary = `${assertSid(sid)}.${process.pid}.${randomUUID()}.tmp`;
|
|
629
|
+
let fd;
|
|
630
|
+
try {
|
|
631
|
+
fd = fs.openSync(
|
|
632
|
+
temporary,
|
|
633
|
+
fs.constants.O_WRONLY
|
|
634
|
+
| fs.constants.O_CREAT
|
|
635
|
+
| fs.constants.O_EXCL
|
|
636
|
+
| (fs.constants.O_NOFOLLOW || 0),
|
|
637
|
+
0o600,
|
|
638
|
+
);
|
|
639
|
+
fs.writeFileSync(fd, `${JSON.stringify(intent, null, 2)}\n`, 'utf8');
|
|
640
|
+
fs.fsyncSync(fd);
|
|
641
|
+
fs.closeSync(fd);
|
|
642
|
+
fd = undefined;
|
|
643
|
+
return temporary;
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (fd !== undefined) {
|
|
646
|
+
try { fs.closeSync(fd); } catch { /* best effort */ }
|
|
647
|
+
}
|
|
648
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* best effort */ }
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function writeReplacement(intent, sid) {
|
|
654
|
+
const temporary = writeTemp(intent, sid);
|
|
655
|
+
try {
|
|
656
|
+
fs.renameSync(temporary, intentFilename(sid));
|
|
657
|
+
syncCurrentDirectory();
|
|
658
|
+
} finally {
|
|
659
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* best effort */ }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function sameCloseIdentity(left, right) {
|
|
664
|
+
return left.sid === right.sid
|
|
665
|
+
&& left.task_id === right.task_id
|
|
666
|
+
&& left.requested_status === right.requested_status
|
|
667
|
+
&& left.worktree_path === right.worktree_path;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function helperPrepare(request) {
|
|
671
|
+
assertSid(request.sid);
|
|
672
|
+
assertTerminalStatus(request.requested_status);
|
|
673
|
+
assertBoundedString(request.task_id, 'task_id', { max: 256 });
|
|
674
|
+
assertBoundedString(request.worktree_path, 'worktree_path', { max: 4096 });
|
|
675
|
+
const now = new Date().toISOString();
|
|
676
|
+
const candidate = validateIntent({
|
|
677
|
+
version: JOURNAL_VERSION,
|
|
678
|
+
generation: 0,
|
|
679
|
+
sid: request.sid,
|
|
680
|
+
task_id: request.task_id,
|
|
681
|
+
requested_status: request.requested_status,
|
|
682
|
+
worktree_path: request.worktree_path,
|
|
683
|
+
phase: 'prepared',
|
|
684
|
+
error: null,
|
|
685
|
+
created_at: now,
|
|
686
|
+
updated_at: now,
|
|
687
|
+
}, intentFilename(request.sid), request.sid);
|
|
688
|
+
const temporary = writeTemp(candidate, request.sid);
|
|
689
|
+
try {
|
|
690
|
+
try {
|
|
691
|
+
fs.linkSync(temporary, intentFilename(request.sid));
|
|
692
|
+
syncCurrentDirectory();
|
|
693
|
+
return candidate;
|
|
694
|
+
} catch (error) {
|
|
695
|
+
if (error.code !== 'EEXIST') throw error;
|
|
696
|
+
const existing = readIntentFile(request.sid);
|
|
697
|
+
if (existing && sameCloseIdentity(existing, candidate)) return existing;
|
|
698
|
+
throw new SessionCloseJournalError(
|
|
699
|
+
'SESSION_CLOSE_CONFLICT',
|
|
700
|
+
`session ${request.sid} already has a different close intent`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
} finally {
|
|
704
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* best effort */ }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function acquireUpdateLock(sid) {
|
|
709
|
+
const name = lockFilename(sid);
|
|
710
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
711
|
+
const temporary = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
712
|
+
let fd;
|
|
713
|
+
try {
|
|
714
|
+
fd = fs.openSync(
|
|
715
|
+
temporary,
|
|
716
|
+
fs.constants.O_WRONLY
|
|
717
|
+
| fs.constants.O_CREAT
|
|
718
|
+
| fs.constants.O_EXCL
|
|
719
|
+
| (fs.constants.O_NOFOLLOW || 0),
|
|
720
|
+
0o600,
|
|
721
|
+
);
|
|
722
|
+
const processStart = processStartMarker(process.pid);
|
|
723
|
+
if (!processStart) {
|
|
724
|
+
throw new SessionCloseJournalError(
|
|
725
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
726
|
+
'cannot establish the session close journal worker process-start marker',
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const fileIdentity = statIdentity(fs.fstatSync(fd, { bigint: true }));
|
|
730
|
+
const directoryIdentity = statIdentity(fs.statSync('.', { bigint: true }));
|
|
731
|
+
const metadata = validateLockMetadata({
|
|
732
|
+
version: LOCK_VERSION,
|
|
733
|
+
pid: process.pid,
|
|
734
|
+
process_start: processStart,
|
|
735
|
+
token: randomUUID(),
|
|
736
|
+
created_at: new Date().toISOString(),
|
|
737
|
+
directory: directoryIdentity,
|
|
738
|
+
file: fileIdentity,
|
|
739
|
+
}, temporary, fileIdentity);
|
|
740
|
+
fs.writeFileSync(fd, `${JSON.stringify(metadata)}\n`, 'utf8');
|
|
741
|
+
fs.fsyncSync(fd);
|
|
742
|
+
fs.closeSync(fd);
|
|
743
|
+
fd = undefined;
|
|
744
|
+
fs.linkSync(temporary, name);
|
|
745
|
+
syncCurrentDirectory();
|
|
746
|
+
fs.unlinkSync(temporary);
|
|
747
|
+
return {
|
|
748
|
+
name,
|
|
749
|
+
identity: fileIdentity,
|
|
750
|
+
token: metadata.token,
|
|
751
|
+
};
|
|
752
|
+
} catch (error) {
|
|
753
|
+
if (fd !== undefined) {
|
|
754
|
+
try { fs.closeSync(fd); } catch { /* best effort */ }
|
|
755
|
+
}
|
|
756
|
+
try { fs.rmSync(temporary, { force: true }); } catch { /* best effort */ }
|
|
757
|
+
if (error.code !== 'EEXIST') {
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
if (!reclaimStaleLock(sid)) {
|
|
761
|
+
throw new SessionCloseJournalError(
|
|
762
|
+
'SESSION_CLOSE_CONFLICT',
|
|
763
|
+
`session ${sid} close journal is being updated concurrently`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
throw new SessionCloseJournalError(
|
|
769
|
+
'SESSION_CLOSE_CONFLICT',
|
|
770
|
+
`session ${sid} close journal lock changed repeatedly during recovery`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function releaseUpdateLock(lock) {
|
|
775
|
+
const current = readLockFile(lock.name.slice(0, -'.lock'.length));
|
|
776
|
+
if (!current) return;
|
|
777
|
+
if (!identitiesEqual(current.identity, lock.identity)
|
|
778
|
+
|| current.metadata.token !== lock.token) {
|
|
779
|
+
throw new SessionCloseJournalError(
|
|
780
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
781
|
+
`session close journal lock ownership changed before release: ${lock.name}`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
const stat = lstatOrNull(lock.name);
|
|
785
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile()
|
|
786
|
+
|| !identitiesEqual(statIdentity(stat), lock.identity)) {
|
|
787
|
+
throw new SessionCloseJournalError(
|
|
788
|
+
'SESSION_CLOSE_JOURNAL_UNSAFE',
|
|
789
|
+
`session close journal lock inode changed before release: ${lock.name}`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
fs.unlinkSync(lock.name);
|
|
793
|
+
syncCurrentDirectory();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function withUpdateLock(sid, fn) {
|
|
797
|
+
const lock = acquireUpdateLock(sid);
|
|
798
|
+
let value;
|
|
799
|
+
let operationError = null;
|
|
800
|
+
try {
|
|
801
|
+
value = fn();
|
|
802
|
+
} catch (error) {
|
|
803
|
+
operationError = error;
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
releaseUpdateLock(lock);
|
|
807
|
+
} catch (releaseError) {
|
|
808
|
+
if (!operationError) throw releaseError;
|
|
809
|
+
}
|
|
810
|
+
if (operationError) throw operationError;
|
|
811
|
+
return value;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function validateUpdatePatch(patch) {
|
|
815
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
816
|
+
throw new SessionCloseJournalError(
|
|
817
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
818
|
+
'session close journal update patch must be an object',
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const keys = Object.keys(patch).sort();
|
|
822
|
+
if (keys.length === 0
|
|
823
|
+
|| keys.some((key) => !['error', 'phase'].includes(key))
|
|
824
|
+
|| !Object.hasOwn(patch, 'phase')) {
|
|
825
|
+
throw new SessionCloseJournalError(
|
|
826
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
827
|
+
'session close journal updates may only set phase and error',
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (!JOURNAL_PHASES.has(patch.phase)) {
|
|
831
|
+
throw new SessionCloseJournalError(
|
|
832
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
833
|
+
`unsupported session close journal phase: ${String(patch.phase)}`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
if (Object.hasOwn(patch, 'error') && patch.error !== null) {
|
|
837
|
+
assertBoundedString(patch.error, 'error', { max: 8192, allowEmpty: true });
|
|
838
|
+
}
|
|
839
|
+
return patch;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function helperUpdate(request) {
|
|
843
|
+
assertSid(request.sid);
|
|
844
|
+
validateUpdatePatch(request.patch);
|
|
845
|
+
if (request.expected_generation !== null
|
|
846
|
+
&& (!Number.isSafeInteger(request.expected_generation)
|
|
847
|
+
|| request.expected_generation < 0)) {
|
|
848
|
+
throw new SessionCloseJournalError(
|
|
849
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
850
|
+
'expected_generation must be a non-negative safe integer',
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
return withUpdateLock(request.sid, () => {
|
|
854
|
+
const current = readIntentFile(request.sid);
|
|
855
|
+
if (!current) {
|
|
856
|
+
throw new SessionCloseJournalError(
|
|
857
|
+
'SESSION_CLOSE_JOURNAL_MISSING',
|
|
858
|
+
`session ${request.sid} has no close journal`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
if (request.expected_generation !== null
|
|
862
|
+
&& current.generation !== request.expected_generation) {
|
|
863
|
+
throw new SessionCloseJournalError(
|
|
864
|
+
'SESSION_CLOSE_CONFLICT',
|
|
865
|
+
`session ${request.sid} close journal changed concurrently`,
|
|
866
|
+
{
|
|
867
|
+
details: {
|
|
868
|
+
expected_generation: request.expected_generation,
|
|
869
|
+
actual_generation: current.generation,
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
const nextError = Object.hasOwn(request.patch, 'error')
|
|
875
|
+
? request.patch.error
|
|
876
|
+
: current.error;
|
|
877
|
+
if (request.patch.phase === current.phase) {
|
|
878
|
+
if (nextError === current.error) return current;
|
|
879
|
+
throw new SessionCloseJournalError(
|
|
880
|
+
'SESSION_CLOSE_CONFLICT',
|
|
881
|
+
`session ${request.sid} close journal phase evidence cannot be overwritten`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (!PHASE_TRANSITIONS[current.phase]?.has(request.patch.phase)) {
|
|
885
|
+
throw new SessionCloseJournalError(
|
|
886
|
+
'SESSION_CLOSE_CONFLICT',
|
|
887
|
+
`session ${request.sid} close journal cannot transition from ${current.phase} to ${request.patch.phase}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const next = validateIntent({
|
|
891
|
+
...current,
|
|
892
|
+
version: JOURNAL_VERSION,
|
|
893
|
+
generation: current.generation + 1,
|
|
894
|
+
phase: request.patch.phase,
|
|
895
|
+
error: nextError,
|
|
896
|
+
updated_at: new Date().toISOString(),
|
|
897
|
+
}, intentFilename(request.sid), request.sid);
|
|
898
|
+
writeReplacement(next, request.sid);
|
|
899
|
+
return next;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function helperDiscard(request) {
|
|
904
|
+
assertSid(request.sid);
|
|
905
|
+
if (request.expected_generation !== null
|
|
906
|
+
&& (!Number.isSafeInteger(request.expected_generation)
|
|
907
|
+
|| request.expected_generation < 0)) {
|
|
908
|
+
throw new SessionCloseJournalError(
|
|
909
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
910
|
+
'expected_generation must be a non-negative safe integer',
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
return withUpdateLock(request.sid, () => {
|
|
914
|
+
const current = readIntentFile(request.sid);
|
|
915
|
+
if (!current) return false;
|
|
916
|
+
if (request.expected_generation !== null
|
|
917
|
+
&& current.generation !== request.expected_generation) {
|
|
918
|
+
throw new SessionCloseJournalError(
|
|
919
|
+
'SESSION_CLOSE_CONFLICT',
|
|
920
|
+
`session ${request.sid} close journal changed before discard`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
fs.unlinkSync(intentFilename(request.sid));
|
|
924
|
+
syncCurrentDirectory();
|
|
925
|
+
return true;
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function helperList() {
|
|
930
|
+
const entries = fs.readdirSync('.', { withFileTypes: true })
|
|
931
|
+
.filter((entry) => entry.name.endsWith('.json'))
|
|
932
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
933
|
+
return entries.map((entry) => {
|
|
934
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
935
|
+
throw new SessionCloseJournalError(
|
|
936
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
937
|
+
`session close journal is not a regular file: ${entry.name}`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
const sid = entry.name.slice(0, -'.json'.length);
|
|
941
|
+
assertSid(sid);
|
|
942
|
+
return readIntentFile(sid);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function executeHelper(request) {
|
|
947
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
948
|
+
throw new SessionCloseJournalError(
|
|
949
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
950
|
+
'session close journal helper request must be an object',
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
enterPinnedJournalRoot(request.runtime_identity);
|
|
954
|
+
switch (request.op) {
|
|
955
|
+
case 'prepare':
|
|
956
|
+
return helperPrepare(request);
|
|
957
|
+
case 'read':
|
|
958
|
+
return readIntentFile(assertSid(request.sid));
|
|
959
|
+
case 'update':
|
|
960
|
+
return helperUpdate(request);
|
|
961
|
+
case 'discard':
|
|
962
|
+
return helperDiscard(request);
|
|
963
|
+
case 'list':
|
|
964
|
+
return helperList();
|
|
965
|
+
default:
|
|
966
|
+
throw new SessionCloseJournalError(
|
|
967
|
+
'SESSION_CLOSE_JOURNAL_INVALID',
|
|
968
|
+
`unknown session close journal operation: ${String(request.op)}`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function serializeError(error) {
|
|
974
|
+
return {
|
|
975
|
+
code: error?.code || 'SESSION_CLOSE_JOURNAL_HELPER_FAILED',
|
|
976
|
+
message: error?.message || String(error),
|
|
977
|
+
details: error?.details,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function helperMain() {
|
|
982
|
+
try {
|
|
983
|
+
const input = fs.readFileSync(0, 'utf8');
|
|
984
|
+
const value = executeHelper(JSON.parse(input));
|
|
985
|
+
process.stdout.write(JSON.stringify({ ok: true, value }));
|
|
986
|
+
} catch (error) {
|
|
987
|
+
process.stdout.write(JSON.stringify({ ok: false, error: serializeError(error) }));
|
|
988
|
+
process.exitCode = 1;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
module.exports = {
|
|
993
|
+
JOURNAL_VERSION,
|
|
994
|
+
JOURNAL_PHASES,
|
|
995
|
+
TERMINAL_STATUSES,
|
|
996
|
+
SessionCloseJournalError,
|
|
997
|
+
journalRoot,
|
|
998
|
+
journalPath,
|
|
999
|
+
prepare,
|
|
1000
|
+
read,
|
|
1001
|
+
update,
|
|
1002
|
+
discard,
|
|
1003
|
+
list,
|
|
1004
|
+
findForTask,
|
|
1005
|
+
_internal: {
|
|
1006
|
+
validateIntent,
|
|
1007
|
+
runHelper,
|
|
1008
|
+
workerPath,
|
|
1009
|
+
helperMain,
|
|
1010
|
+
enterPinnedJournalRoot,
|
|
1011
|
+
acquireUpdateLock,
|
|
1012
|
+
processStartMarker,
|
|
1013
|
+
},
|
|
1014
|
+
};
|