@sublang/playbook 5.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -14
- package/docs/cli.md +138 -90
- package/docs/configuration.md +113 -29
- package/docs/embedding.md +24 -16
- package/package.json +42 -21
- package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
- package/reference/sdlc/code.md +55 -97
- package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
- package/reference/sdlc/code.playbook/bin/provision.js +84 -38
- package/reference/sdlc/code.playbook/bin/run.js +1171 -983
- package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
- package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
- package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
- package/reference/sdlc/code.playbook/code.gears.md +51 -263
- package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
- package/reference/sdlc/code.playbook/code.playbook.js +69 -656
- package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
- package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
- package/reference/sdlc/code.playbook/code.registry.js +20 -78
- package/reference/sdlc/code.playbook/code.registry.ts +58 -122
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
- package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
- package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
- package/reference/sdlc/decide.md +54 -0
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
- package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
- package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
- package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
- package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
- package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
- package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
- package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
- package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
- package/reference/sdlc/review.md +81 -0
- package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
- package/reference/sdlc/review.playbook/review.fsm.js +524 -0
- package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
- package/reference/sdlc/review.playbook/review.gears.md +112 -0
- package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
- package/reference/sdlc/review.playbook/review.playbook.js +112 -0
- package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
- package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
- package/reference/sdlc/review.playbook/review.registry.js +73 -0
- package/reference/sdlc/review.playbook/review.registry.ts +138 -0
- package/slc/gears2fsm.md +13 -4
- package/slc/link.md +83 -14
- package/slc/text2gears.md +22 -2
- package/src/runtime.d.ts +21 -2
- package/src/runtime.ts +38 -6
- package/src/xstate-playbook-runtime.d.ts +9 -2
- package/src/xstate-playbook-runtime.js +319 -35
- package/src/xstate-playbook-runtime.ts +412 -41
- package/src/xstate-runtime.d.ts +19 -2
- package/src/xstate-runtime.js +384 -57
- package/src/xstate-runtime.ts +542 -71
- package/reference/sdlc/discuss.md +0 -93
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
- package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
- package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// PBCLI-23: durable headless Captain sessions use one exact settled/uncertain
|
|
5
|
+
// record union and one exclusive, crash-recoverable lease per logical session.
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { constants } from 'node:fs';
|
|
9
|
+
import {
|
|
10
|
+
chmod,
|
|
11
|
+
link,
|
|
12
|
+
lstat,
|
|
13
|
+
mkdir,
|
|
14
|
+
open,
|
|
15
|
+
readdir,
|
|
16
|
+
rename,
|
|
17
|
+
rmdir,
|
|
18
|
+
unlink,
|
|
19
|
+
} from 'node:fs/promises';
|
|
20
|
+
import { homedir, hostname as systemHostname } from 'node:os';
|
|
21
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
22
|
+
import { snapshotJsonValue } from '../../../../src/xstate-runtime.js';
|
|
23
|
+
|
|
24
|
+
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 2;
|
|
25
|
+
export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
|
|
26
|
+
export const SESSION_ID_PATTERN =
|
|
27
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
28
|
+
|
|
29
|
+
const COMMON_RECORD_KEYS = [
|
|
30
|
+
'schemaVersion',
|
|
31
|
+
'kind',
|
|
32
|
+
'state',
|
|
33
|
+
'sessionId',
|
|
34
|
+
'createdAt',
|
|
35
|
+
'updatedAt',
|
|
36
|
+
'cwd',
|
|
37
|
+
'config',
|
|
38
|
+
'snapshot',
|
|
39
|
+
];
|
|
40
|
+
const UNCERTAIN_KEYS = [
|
|
41
|
+
'baseUpdatedAt',
|
|
42
|
+
'input',
|
|
43
|
+
'attemptId',
|
|
44
|
+
'attemptNumber',
|
|
45
|
+
'markedAt',
|
|
46
|
+
];
|
|
47
|
+
const LEASE_SCHEMA_VERSION = 1;
|
|
48
|
+
const LEASE_KIND = 'captain-session-lease';
|
|
49
|
+
const LEASE_OWNER_FILE = 'owner.json';
|
|
50
|
+
const LEASE_OWNER_KEYS = [
|
|
51
|
+
'schemaVersion',
|
|
52
|
+
'kind',
|
|
53
|
+
'sessionId',
|
|
54
|
+
'ownerToken',
|
|
55
|
+
'pid',
|
|
56
|
+
'hostname',
|
|
57
|
+
'acquiredAt',
|
|
58
|
+
];
|
|
59
|
+
const DEFAULT_FS_OPERATIONS = Object.freeze({
|
|
60
|
+
chmod,
|
|
61
|
+
link,
|
|
62
|
+
lstat,
|
|
63
|
+
mkdir,
|
|
64
|
+
open,
|
|
65
|
+
readdir,
|
|
66
|
+
rename,
|
|
67
|
+
rmdir,
|
|
68
|
+
unlink,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export function defaultCaptainSessionsDir(
|
|
72
|
+
env = process.env,
|
|
73
|
+
home = env.HOME ?? homedir(),
|
|
74
|
+
) {
|
|
75
|
+
const stateHome = env.XDG_STATE_HOME || join(home, '.local', 'state');
|
|
76
|
+
return join(stateHome, 'playbook', 'sessions');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createCaptainSessionStore(options = {}) {
|
|
80
|
+
const env = options.env ?? process.env;
|
|
81
|
+
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
82
|
+
const sessionsDir =
|
|
83
|
+
options.sessionsDir ?? defaultCaptainSessionsDir(env, home);
|
|
84
|
+
const now = options.now ?? (() => new Date());
|
|
85
|
+
const createTempId = options.createTempId ?? randomUUID;
|
|
86
|
+
const createLeaseToken = options.createLeaseToken ?? randomUUID;
|
|
87
|
+
const localHostname = options.hostname ?? systemHostname();
|
|
88
|
+
const localPid = options.pid ?? process.pid;
|
|
89
|
+
const probeProcess =
|
|
90
|
+
options.probeProcess ?? ((pid) => process.kill(pid, 0));
|
|
91
|
+
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
92
|
+
|
|
93
|
+
if (!isAbsolute(sessionsDir)) {
|
|
94
|
+
throw new Error('Captain session store path must be absolute');
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
typeof localHostname !== 'string' ||
|
|
98
|
+
localHostname.trim().length === 0
|
|
99
|
+
) {
|
|
100
|
+
throw new Error('Captain session lease hostname must be a non-empty string');
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isSafeInteger(localPid) || localPid <= 0) {
|
|
103
|
+
throw new Error('Captain session lease pid must be a positive integer');
|
|
104
|
+
}
|
|
105
|
+
if (typeof probeProcess !== 'function') {
|
|
106
|
+
throw new Error('Captain session process probe must be a function');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const recordPathFor = (sessionId) => {
|
|
110
|
+
assertSessionId(sessionId);
|
|
111
|
+
return join(sessionsDir, `${sessionId}.json`);
|
|
112
|
+
};
|
|
113
|
+
const leasePathFor = (sessionId) => {
|
|
114
|
+
assertSessionId(sessionId);
|
|
115
|
+
return join(sessionsDir, `.${sessionId}.lock`);
|
|
116
|
+
};
|
|
117
|
+
const retiredPathFor = (sessionId, ownerToken) => {
|
|
118
|
+
assertSessionId(sessionId);
|
|
119
|
+
assertUuid(ownerToken, 'Captain session lease owner token');
|
|
120
|
+
return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const readRecord = async (sessionId, { missing = 'error' } = {}) => {
|
|
124
|
+
const path = recordPathFor(sessionId);
|
|
125
|
+
let text;
|
|
126
|
+
try {
|
|
127
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
128
|
+
text = await readPrivateRegularFile(path, 0o600, fs, 'record');
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
if (cause?.code === 'ENOENT' && missing === 'undefined') return undefined;
|
|
131
|
+
if (cause?.code === 'ENOENT') {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Captain session ${JSON.stringify(sessionId)} does not exist`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
throw new Error(
|
|
137
|
+
`cannot read Captain session ${JSON.stringify(sessionId)}: ${errorMessage(cause)}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
let value;
|
|
141
|
+
try {
|
|
142
|
+
value = JSON.parse(text);
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Captain session ${JSON.stringify(sessionId)} is not valid JSON: ${errorMessage(cause)}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const record = validateCaptainSessionRecord(value);
|
|
149
|
+
if (record.sessionId !== sessionId) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Captain session file ${JSON.stringify(sessionId)} contains record ` +
|
|
152
|
+
JSON.stringify(record.sessionId),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return record;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const read = (sessionId) => readRecord(sessionId);
|
|
159
|
+
|
|
160
|
+
const latest = async () => {
|
|
161
|
+
let names;
|
|
162
|
+
try {
|
|
163
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
164
|
+
names = await fs.readdir(sessionsDir);
|
|
165
|
+
} catch (cause) {
|
|
166
|
+
if (cause?.code === 'ENOENT') {
|
|
167
|
+
throw new Error('no resumable Captain session exists');
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`cannot list Captain sessions: ${errorMessage(cause)}`);
|
|
170
|
+
}
|
|
171
|
+
const candidates = [];
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
if (!name.endsWith('.json')) continue;
|
|
174
|
+
const sessionId = name.slice(0, -'.json'.length);
|
|
175
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) continue;
|
|
176
|
+
// Canonically named records are store-owned. Corruption must not make
|
|
177
|
+
// --continue silently select an older logical session.
|
|
178
|
+
candidates.push(await readRecord(sessionId));
|
|
179
|
+
}
|
|
180
|
+
candidates.sort((left, right) => {
|
|
181
|
+
const byUpdated = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
|
182
|
+
if (byUpdated !== 0) return byUpdated;
|
|
183
|
+
if (right.sessionId === left.sessionId) return 0;
|
|
184
|
+
return right.sessionId < left.sessionId ? -1 : 1;
|
|
185
|
+
});
|
|
186
|
+
if (candidates.length === 0) {
|
|
187
|
+
throw new Error('no resumable Captain session exists');
|
|
188
|
+
}
|
|
189
|
+
return candidates[0];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const writeRecord = async (recordValue, { noReplace }) => {
|
|
193
|
+
const record = validateCaptainSessionRecord(recordValue);
|
|
194
|
+
const destination = recordPathFor(record.sessionId);
|
|
195
|
+
await ensurePrivateDirectory(sessionsDir, fs);
|
|
196
|
+
|
|
197
|
+
if (noReplace) {
|
|
198
|
+
await assertPathMissing(
|
|
199
|
+
destination,
|
|
200
|
+
fs,
|
|
201
|
+
`Captain session ${JSON.stringify(record.sessionId)} already exists`,
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
await assertPrivateRegularPath(destination, 0o600, fs, 'record');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const tempId = createTempId();
|
|
208
|
+
assertUuid(tempId, 'Captain session temporary id');
|
|
209
|
+
const temporary = join(
|
|
210
|
+
sessionsDir,
|
|
211
|
+
`.${record.sessionId}.${localPid}.${tempId}.tmp`,
|
|
212
|
+
);
|
|
213
|
+
let handle;
|
|
214
|
+
let ownsTemporary = false;
|
|
215
|
+
let published = false;
|
|
216
|
+
try {
|
|
217
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
218
|
+
ownsTemporary = true;
|
|
219
|
+
await handle.chmod(0o600);
|
|
220
|
+
const tempStat = await handle.stat();
|
|
221
|
+
if (!tempStat.isFile() || (tempStat.mode & 0o7777) !== 0o600) {
|
|
222
|
+
throw new Error('Captain session temporary path is not a private regular file');
|
|
223
|
+
}
|
|
224
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
|
|
225
|
+
await handle.sync();
|
|
226
|
+
await handle.close();
|
|
227
|
+
handle = undefined;
|
|
228
|
+
|
|
229
|
+
if (noReplace) {
|
|
230
|
+
try {
|
|
231
|
+
await fs.link(temporary, destination);
|
|
232
|
+
published = true;
|
|
233
|
+
} catch (cause) {
|
|
234
|
+
if (cause?.code === 'EEXIST') {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Captain session ${JSON.stringify(record.sessionId)} already exists`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
throw cause;
|
|
240
|
+
}
|
|
241
|
+
// Once the no-replace link exists, failure to remove the ignored temp
|
|
242
|
+
// name cannot make the logical publication fail or become ambiguous.
|
|
243
|
+
try {
|
|
244
|
+
await fs.unlink(temporary);
|
|
245
|
+
ownsTemporary = false;
|
|
246
|
+
} catch {
|
|
247
|
+
// Leave only this unpredictable store-owned temp path behind.
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
await fs.rename(temporary, destination);
|
|
251
|
+
ownsTemporary = false;
|
|
252
|
+
published = true;
|
|
253
|
+
}
|
|
254
|
+
await syncDirectory(sessionsDir, fs);
|
|
255
|
+
return record;
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
try {
|
|
258
|
+
await handle?.close();
|
|
259
|
+
} catch {
|
|
260
|
+
// Preserve the persistence failure.
|
|
261
|
+
}
|
|
262
|
+
if (ownsTemporary && !published) {
|
|
263
|
+
try {
|
|
264
|
+
await fs.unlink(temporary);
|
|
265
|
+
} catch {
|
|
266
|
+
// Preserve the persistence failure and never broaden cleanup.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw cause;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const deleteRecord = async (sessionId) => {
|
|
274
|
+
const path = recordPathFor(sessionId);
|
|
275
|
+
await assertPrivateRegularPath(path, 0o600, fs, 'record');
|
|
276
|
+
await fs.unlink(path);
|
|
277
|
+
await syncDirectory(sessionsDir, fs);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const readLeaseDirectory = async (
|
|
281
|
+
sessionId,
|
|
282
|
+
path,
|
|
283
|
+
expectedNames,
|
|
284
|
+
) => {
|
|
285
|
+
let text;
|
|
286
|
+
try {
|
|
287
|
+
const directoryStat = await fs.lstat(path);
|
|
288
|
+
if (
|
|
289
|
+
directoryStat.isSymbolicLink() ||
|
|
290
|
+
!directoryStat.isDirectory() ||
|
|
291
|
+
(directoryStat.mode & 0o7777) !== 0o700
|
|
292
|
+
) {
|
|
293
|
+
throw new Error('lease path is not a private real directory');
|
|
294
|
+
}
|
|
295
|
+
const names = (await fs.readdir(path)).sort();
|
|
296
|
+
if (
|
|
297
|
+
names.length !== expectedNames.length ||
|
|
298
|
+
expectedNames.some((name, index) => names[index] !== name)
|
|
299
|
+
) {
|
|
300
|
+
throw new Error('lease directory is incomplete or malformed');
|
|
301
|
+
}
|
|
302
|
+
text = await readPrivateRegularFile(
|
|
303
|
+
join(path, LEASE_OWNER_FILE),
|
|
304
|
+
0o600,
|
|
305
|
+
fs,
|
|
306
|
+
'lease owner',
|
|
307
|
+
);
|
|
308
|
+
} catch (cause) {
|
|
309
|
+
if (cause?.code === 'ENOENT') throw cause;
|
|
310
|
+
throw new Error(
|
|
311
|
+
`cannot inspect Captain session lease: ${errorMessage(cause)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
let value;
|
|
315
|
+
try {
|
|
316
|
+
value = JSON.parse(text);
|
|
317
|
+
} catch (cause) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Captain session lease owner is not valid JSON: ${errorMessage(cause)}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const owner = validateLeaseOwner(value);
|
|
323
|
+
if (owner.sessionId !== sessionId) {
|
|
324
|
+
throw new Error('Captain session lease owner id does not match its path');
|
|
325
|
+
}
|
|
326
|
+
return owner;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
|
|
330
|
+
readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
|
|
331
|
+
|
|
332
|
+
const readRetiredLease = async (sessionId, path, expectedToken) => {
|
|
333
|
+
const owner = await readLeaseDirectory(
|
|
334
|
+
sessionId,
|
|
335
|
+
path,
|
|
336
|
+
[LEASE_OWNER_FILE],
|
|
337
|
+
);
|
|
338
|
+
if (owner.ownerToken !== expectedToken) {
|
|
339
|
+
throw new Error('Captain session retired lease owner token is mismatched');
|
|
340
|
+
}
|
|
341
|
+
return owner;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const validateRetiredLeases = async (sessionId) => {
|
|
345
|
+
const prefix = `.${sessionId}.lock.retired`;
|
|
346
|
+
const exactPrefix = `${prefix}.`;
|
|
347
|
+
const names = await fs.readdir(sessionsDir);
|
|
348
|
+
for (const name of names.sort()) {
|
|
349
|
+
if (!name.startsWith(prefix)) continue;
|
|
350
|
+
if (!name.startsWith(exactPrefix)) {
|
|
351
|
+
throw new Error('Captain session retired lease name is malformed');
|
|
352
|
+
}
|
|
353
|
+
const ownerToken = name.slice(exactPrefix.length);
|
|
354
|
+
assertUuid(ownerToken, 'Captain session retired lease token');
|
|
355
|
+
await readRetiredLease(
|
|
356
|
+
sessionId,
|
|
357
|
+
join(sessionsDir, name),
|
|
358
|
+
ownerToken,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const cleanOwnStage = async (stagePath) => {
|
|
364
|
+
try {
|
|
365
|
+
const ownerPath = join(stagePath, LEASE_OWNER_FILE);
|
|
366
|
+
let ownerStat;
|
|
367
|
+
try {
|
|
368
|
+
ownerStat = await fs.lstat(ownerPath);
|
|
369
|
+
} catch (cause) {
|
|
370
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
ownerStat !== undefined &&
|
|
374
|
+
!ownerStat.isSymbolicLink() &&
|
|
375
|
+
ownerStat.isFile()
|
|
376
|
+
) {
|
|
377
|
+
await fs.unlink(ownerPath);
|
|
378
|
+
}
|
|
379
|
+
await fs.rmdir(stagePath);
|
|
380
|
+
} catch {
|
|
381
|
+
// Preserve the acquisition failure. Never scan or broaden cleanup.
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const makeLeaseStage = async (sessionId) => {
|
|
386
|
+
await ensurePrivateDirectory(sessionsDir, fs);
|
|
387
|
+
const ownerToken = createLeaseToken();
|
|
388
|
+
assertUuid(ownerToken, 'Captain session lease owner token');
|
|
389
|
+
const retiredPath = retiredPathFor(sessionId, ownerToken);
|
|
390
|
+
await assertPathMissing(
|
|
391
|
+
retiredPath,
|
|
392
|
+
fs,
|
|
393
|
+
'Captain session lease owner token was already retired',
|
|
394
|
+
);
|
|
395
|
+
const stagePath = join(
|
|
396
|
+
sessionsDir,
|
|
397
|
+
`.${sessionId}.lock.stage.${ownerToken}`,
|
|
398
|
+
);
|
|
399
|
+
await assertPathMissing(
|
|
400
|
+
stagePath,
|
|
401
|
+
fs,
|
|
402
|
+
'Captain session lease owner token is already staged',
|
|
403
|
+
);
|
|
404
|
+
const owner = validateLeaseOwner({
|
|
405
|
+
schemaVersion: LEASE_SCHEMA_VERSION,
|
|
406
|
+
kind: LEASE_KIND,
|
|
407
|
+
sessionId,
|
|
408
|
+
ownerToken,
|
|
409
|
+
pid: localPid,
|
|
410
|
+
hostname: localHostname,
|
|
411
|
+
acquiredAt: timestampFrom(now(), 'lease timestamp'),
|
|
412
|
+
});
|
|
413
|
+
let handle;
|
|
414
|
+
let created = false;
|
|
415
|
+
try {
|
|
416
|
+
await fs.mkdir(stagePath, { mode: 0o700 });
|
|
417
|
+
created = true;
|
|
418
|
+
await fs.chmod(stagePath, 0o700);
|
|
419
|
+
const stageStat = await fs.lstat(stagePath);
|
|
420
|
+
if (
|
|
421
|
+
stageStat.isSymbolicLink() ||
|
|
422
|
+
!stageStat.isDirectory() ||
|
|
423
|
+
(stageStat.mode & 0o7777) !== 0o700
|
|
424
|
+
) {
|
|
425
|
+
throw new Error('Captain session lease stage is not a private directory');
|
|
426
|
+
}
|
|
427
|
+
handle = await fs.open(join(stagePath, LEASE_OWNER_FILE), 'wx', 0o600);
|
|
428
|
+
await handle.chmod(0o600);
|
|
429
|
+
const ownerStat = await handle.stat();
|
|
430
|
+
if (!ownerStat.isFile() || (ownerStat.mode & 0o7777) !== 0o600) {
|
|
431
|
+
throw new Error('Captain session lease owner is not a private regular file');
|
|
432
|
+
}
|
|
433
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
|
|
434
|
+
await handle.sync();
|
|
435
|
+
await handle.close();
|
|
436
|
+
handle = undefined;
|
|
437
|
+
await syncDirectory(stagePath, fs);
|
|
438
|
+
return { owner, stagePath };
|
|
439
|
+
} catch (cause) {
|
|
440
|
+
try {
|
|
441
|
+
await handle?.close();
|
|
442
|
+
} catch {
|
|
443
|
+
// Preserve the stage failure.
|
|
444
|
+
}
|
|
445
|
+
if (created) await cleanOwnStage(stagePath);
|
|
446
|
+
throw cause;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const retireObservedLease = async (sessionId, observedOwner) => {
|
|
451
|
+
const owner = validateLeaseOwner(observedOwner);
|
|
452
|
+
if (owner.sessionId !== sessionId) {
|
|
453
|
+
throw new Error('Captain session lease owner id changed before retirement');
|
|
454
|
+
}
|
|
455
|
+
const canonicalPath = leasePathFor(sessionId);
|
|
456
|
+
const retiredPath = retiredPathFor(sessionId, owner.ownerToken);
|
|
457
|
+
await assertPathMissing(
|
|
458
|
+
retiredPath,
|
|
459
|
+
fs,
|
|
460
|
+
'Captain session lease retired path is already occupied',
|
|
461
|
+
);
|
|
462
|
+
try {
|
|
463
|
+
await fs.rename(canonicalPath, retiredPath);
|
|
464
|
+
} catch (cause) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Captain session lease changed before retirement: ${errorMessage(cause)}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const retiredOwner = await readLeaseDirectory(
|
|
470
|
+
sessionId,
|
|
471
|
+
retiredPath,
|
|
472
|
+
[LEASE_OWNER_FILE],
|
|
473
|
+
);
|
|
474
|
+
if (retiredOwner.ownerToken !== owner.ownerToken) {
|
|
475
|
+
throw new Error('Captain session retired lease owner token changed');
|
|
476
|
+
}
|
|
477
|
+
await syncDirectory(sessionsDir, fs);
|
|
478
|
+
return retiredPath;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const publishLeaseStage = async (sessionId, stage, onRenamed) => {
|
|
482
|
+
const canonicalPath = leasePathFor(sessionId);
|
|
483
|
+
await validateRetiredLeases(sessionId);
|
|
484
|
+
await assertPathMissing(
|
|
485
|
+
canonicalPath,
|
|
486
|
+
fs,
|
|
487
|
+
'Captain session lease became active before publication',
|
|
488
|
+
);
|
|
489
|
+
try {
|
|
490
|
+
// Program-created canonical lease directories are nonempty. Therefore a
|
|
491
|
+
// racing rename cannot replace one; it fails closed instead. Static empty
|
|
492
|
+
// or malformed destinations are rejected by the preflight above.
|
|
493
|
+
await fs.rename(stage.stagePath, canonicalPath);
|
|
494
|
+
onRenamed();
|
|
495
|
+
} catch (cause) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`Captain session lease publication lost its race: ${errorMessage(cause)}`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
await syncDirectory(sessionsDir, fs);
|
|
501
|
+
const publishedOwner = await readLeaseOwner(sessionId);
|
|
502
|
+
if (publishedOwner.ownerToken !== stage.owner.ownerToken) {
|
|
503
|
+
throw new Error('Captain session lease publication owner token changed');
|
|
504
|
+
}
|
|
505
|
+
return publishedOwner;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const acquire = async (sessionId) => {
|
|
509
|
+
assertSessionId(sessionId);
|
|
510
|
+
let stage;
|
|
511
|
+
let stagePublished = false;
|
|
512
|
+
try {
|
|
513
|
+
stage = await makeLeaseStage(sessionId);
|
|
514
|
+
const canonicalPath = leasePathFor(sessionId);
|
|
515
|
+
let existing;
|
|
516
|
+
try {
|
|
517
|
+
existing = await readLeaseOwner(sessionId);
|
|
518
|
+
} catch (cause) {
|
|
519
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (existing !== undefined) {
|
|
523
|
+
if (existing.ownerToken === stage.owner.ownerToken) {
|
|
524
|
+
throw new Error('Captain session lease owner token was reused');
|
|
525
|
+
}
|
|
526
|
+
if (existing.hostname !== localHostname) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`Captain session lease is owned by foreign host ${JSON.stringify(existing.hostname)}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await probeProcess(existing.pid);
|
|
533
|
+
throw new Error(
|
|
534
|
+
`Captain session lease is active in process ${existing.pid}`,
|
|
535
|
+
);
|
|
536
|
+
} catch (cause) {
|
|
537
|
+
if (cause?.code !== 'ESRCH') {
|
|
538
|
+
if (
|
|
539
|
+
cause instanceof Error &&
|
|
540
|
+
cause.message ===
|
|
541
|
+
`Captain session lease is active in process ${existing.pid}`
|
|
542
|
+
) {
|
|
543
|
+
throw cause;
|
|
544
|
+
}
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
await retireObservedLease(sessionId, existing);
|
|
551
|
+
} else {
|
|
552
|
+
// Preserve the explicit local solely for easier audit of the no-owner
|
|
553
|
+
// publication boundary.
|
|
554
|
+
void canonicalPath;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
await publishLeaseStage(sessionId, stage, () => {
|
|
558
|
+
stagePublished = true;
|
|
559
|
+
});
|
|
560
|
+
return createLease({
|
|
561
|
+
sessionId,
|
|
562
|
+
owner: stage.owner,
|
|
563
|
+
readRecord,
|
|
564
|
+
writeRecord,
|
|
565
|
+
deleteRecord,
|
|
566
|
+
readLeaseOwner,
|
|
567
|
+
retireObservedLease,
|
|
568
|
+
validateRetiredLeases,
|
|
569
|
+
now,
|
|
570
|
+
});
|
|
571
|
+
} catch (cause) {
|
|
572
|
+
let cleanupError;
|
|
573
|
+
if (stage !== undefined && stagePublished) {
|
|
574
|
+
try {
|
|
575
|
+
const current = await readLeaseOwner(sessionId);
|
|
576
|
+
if (current.ownerToken === stage.owner.ownerToken) {
|
|
577
|
+
await retireObservedLease(sessionId, current);
|
|
578
|
+
}
|
|
579
|
+
} catch (error) {
|
|
580
|
+
cleanupError = error;
|
|
581
|
+
}
|
|
582
|
+
} else if (stage !== undefined) {
|
|
583
|
+
await cleanOwnStage(stage.stagePath);
|
|
584
|
+
}
|
|
585
|
+
if (cleanupError !== undefined) {
|
|
586
|
+
throw new AggregateError(
|
|
587
|
+
[cause, cleanupError],
|
|
588
|
+
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
throw new Error(
|
|
592
|
+
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
return Object.freeze({ sessionsDir, read, latest, acquire });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function createLease({
|
|
601
|
+
sessionId,
|
|
602
|
+
owner,
|
|
603
|
+
readRecord,
|
|
604
|
+
writeRecord,
|
|
605
|
+
deleteRecord,
|
|
606
|
+
readLeaseOwner,
|
|
607
|
+
retireObservedLease,
|
|
608
|
+
validateRetiredLeases,
|
|
609
|
+
now,
|
|
610
|
+
}) {
|
|
611
|
+
let released = false;
|
|
612
|
+
let operationActive = false;
|
|
613
|
+
|
|
614
|
+
const requireActive = () => {
|
|
615
|
+
if (released) throw new Error('Captain session lease was already released');
|
|
616
|
+
if (operationActive) {
|
|
617
|
+
throw new Error('Captain session lease operation is already in progress');
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const runExclusive = async (operation) => {
|
|
622
|
+
requireActive();
|
|
623
|
+
operationActive = true;
|
|
624
|
+
try {
|
|
625
|
+
return await operation();
|
|
626
|
+
} finally {
|
|
627
|
+
operationActive = false;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const assertOwnerUnchecked = async () => {
|
|
632
|
+
if (released) throw new Error('Captain session lease was already released');
|
|
633
|
+
const current = await readLeaseOwner(sessionId);
|
|
634
|
+
if (current.ownerToken !== owner.ownerToken) {
|
|
635
|
+
throw new Error('Captain session lease is owned by a different token');
|
|
636
|
+
}
|
|
637
|
+
return current;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const assertOwner = () => runExclusive(assertOwnerUnchecked);
|
|
641
|
+
|
|
642
|
+
const read = () =>
|
|
643
|
+
runExclusive(async () => {
|
|
644
|
+
await assertOwnerUnchecked();
|
|
645
|
+
const record = await readRecord(sessionId, { missing: 'undefined' });
|
|
646
|
+
await assertOwnerUnchecked();
|
|
647
|
+
return record;
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const beginTurn = ({ input, attemptId, fresh } = {}) =>
|
|
651
|
+
runExclusive(async () => {
|
|
652
|
+
assertAcceptedInput(input);
|
|
653
|
+
assertUuid(attemptId, 'Captain session attempt id');
|
|
654
|
+
await assertOwnerUnchecked();
|
|
655
|
+
const prior = await readRecord(sessionId, { missing: 'undefined' });
|
|
656
|
+
let record;
|
|
657
|
+
if (fresh !== undefined) {
|
|
658
|
+
if (prior !== undefined) {
|
|
659
|
+
throw new Error('fresh Captain session record already exists');
|
|
660
|
+
}
|
|
661
|
+
const initial = validateFreshBoundary(fresh);
|
|
662
|
+
const timestamp = timestampFrom(now(), 'session timestamp');
|
|
663
|
+
record = validateCaptainSessionRecord({
|
|
664
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
665
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
666
|
+
state: 'uncertain',
|
|
667
|
+
sessionId,
|
|
668
|
+
createdAt: timestamp,
|
|
669
|
+
updatedAt: timestamp,
|
|
670
|
+
cwd: initial.cwd,
|
|
671
|
+
config: initial.config,
|
|
672
|
+
snapshot: initial.snapshot,
|
|
673
|
+
uncertain: {
|
|
674
|
+
baseUpdatedAt: null,
|
|
675
|
+
input,
|
|
676
|
+
attemptId,
|
|
677
|
+
attemptNumber: 1,
|
|
678
|
+
markedAt: timestamp,
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
await assertOwnerUnchecked();
|
|
682
|
+
await writeRecord(record, { noReplace: true });
|
|
683
|
+
} else {
|
|
684
|
+
if (prior === undefined) {
|
|
685
|
+
throw new Error('Captain session does not exist for continuation');
|
|
686
|
+
}
|
|
687
|
+
if (prior.state !== 'settled') {
|
|
688
|
+
throw new Error('Captain session already has an uncertain turn');
|
|
689
|
+
}
|
|
690
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
691
|
+
record = validateCaptainSessionRecord({
|
|
692
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
693
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
694
|
+
state: 'uncertain',
|
|
695
|
+
sessionId,
|
|
696
|
+
createdAt: prior.createdAt,
|
|
697
|
+
updatedAt: timestamp,
|
|
698
|
+
cwd: prior.cwd,
|
|
699
|
+
config: prior.config,
|
|
700
|
+
snapshot: prior.snapshot,
|
|
701
|
+
uncertain: {
|
|
702
|
+
baseUpdatedAt: prior.updatedAt,
|
|
703
|
+
input,
|
|
704
|
+
attemptId,
|
|
705
|
+
attemptNumber: 1,
|
|
706
|
+
markedAt: timestamp,
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
await assertOwnerUnchecked();
|
|
710
|
+
await writeRecord(record, { noReplace: false });
|
|
711
|
+
}
|
|
712
|
+
await assertOwnerUnchecked();
|
|
713
|
+
return record;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const beginRetry = ({ expectedAttemptId, nextAttemptId } = {}) =>
|
|
717
|
+
runExclusive(async () => {
|
|
718
|
+
assertUuid(expectedAttemptId, 'Captain session expected attempt id');
|
|
719
|
+
assertUuid(nextAttemptId, 'Captain session next attempt id');
|
|
720
|
+
if (expectedAttemptId === nextAttemptId) {
|
|
721
|
+
throw new Error('Captain session retry requires a fresh attempt id');
|
|
722
|
+
}
|
|
723
|
+
await assertOwnerUnchecked();
|
|
724
|
+
const prior = await requireUncertainRecord(
|
|
725
|
+
await readRecord(sessionId, { missing: 'undefined' }),
|
|
726
|
+
expectedAttemptId,
|
|
727
|
+
);
|
|
728
|
+
if (!Number.isSafeInteger(prior.uncertain.attemptNumber + 1)) {
|
|
729
|
+
throw new Error('Captain session attempt number cannot be incremented');
|
|
730
|
+
}
|
|
731
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
732
|
+
const record = validateCaptainSessionRecord({
|
|
733
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
734
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
735
|
+
state: 'uncertain',
|
|
736
|
+
sessionId,
|
|
737
|
+
createdAt: prior.createdAt,
|
|
738
|
+
updatedAt: timestamp,
|
|
739
|
+
cwd: prior.cwd,
|
|
740
|
+
config: prior.config,
|
|
741
|
+
snapshot: prior.snapshot,
|
|
742
|
+
uncertain: {
|
|
743
|
+
baseUpdatedAt: prior.uncertain.baseUpdatedAt,
|
|
744
|
+
input: prior.uncertain.input,
|
|
745
|
+
attemptId: nextAttemptId,
|
|
746
|
+
attemptNumber: prior.uncertain.attemptNumber + 1,
|
|
747
|
+
markedAt: timestamp,
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
await assertOwnerUnchecked();
|
|
751
|
+
await writeRecord(record, { noReplace: false });
|
|
752
|
+
await assertOwnerUnchecked();
|
|
753
|
+
return record;
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const settle = ({ attemptId, snapshot } = {}) =>
|
|
757
|
+
runExclusive(async () => {
|
|
758
|
+
assertUuid(attemptId, 'Captain session attempt id');
|
|
759
|
+
await assertOwnerUnchecked();
|
|
760
|
+
const prior = await requireUncertainRecord(
|
|
761
|
+
await readRecord(sessionId, { missing: 'undefined' }),
|
|
762
|
+
attemptId,
|
|
763
|
+
);
|
|
764
|
+
const timestamp = nextTimestamp(now(), prior.updatedAt);
|
|
765
|
+
const record = validateCaptainSessionRecord({
|
|
766
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
767
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
768
|
+
state: 'settled',
|
|
769
|
+
sessionId,
|
|
770
|
+
createdAt: prior.createdAt,
|
|
771
|
+
updatedAt: timestamp,
|
|
772
|
+
cwd: prior.cwd,
|
|
773
|
+
config: prior.config,
|
|
774
|
+
snapshot,
|
|
775
|
+
});
|
|
776
|
+
await assertOwnerUnchecked();
|
|
777
|
+
await writeRecord(record, { noReplace: false });
|
|
778
|
+
await assertOwnerUnchecked();
|
|
779
|
+
return record;
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const discard = ({ attemptId } = {}) =>
|
|
783
|
+
runExclusive(async () => {
|
|
784
|
+
assertUuid(attemptId, 'Captain session attempt id');
|
|
785
|
+
await assertOwnerUnchecked();
|
|
786
|
+
const prior = await requireUncertainRecord(
|
|
787
|
+
await readRecord(sessionId, { missing: 'undefined' }),
|
|
788
|
+
attemptId,
|
|
789
|
+
);
|
|
790
|
+
await assertOwnerUnchecked();
|
|
791
|
+
if (prior.uncertain.baseUpdatedAt === null) {
|
|
792
|
+
await deleteRecord(sessionId);
|
|
793
|
+
await assertOwnerUnchecked();
|
|
794
|
+
return undefined;
|
|
795
|
+
}
|
|
796
|
+
// writeRecord's stable key order reconstructs the exact prior settled
|
|
797
|
+
// bytes from the baseline carried by the uncertain record.
|
|
798
|
+
const record = validateCaptainSessionRecord({
|
|
799
|
+
schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
|
|
800
|
+
kind: CAPTAIN_SESSION_RECORD_KIND,
|
|
801
|
+
state: 'settled',
|
|
802
|
+
sessionId,
|
|
803
|
+
createdAt: prior.createdAt,
|
|
804
|
+
updatedAt: prior.uncertain.baseUpdatedAt,
|
|
805
|
+
cwd: prior.cwd,
|
|
806
|
+
config: prior.config,
|
|
807
|
+
snapshot: prior.snapshot,
|
|
808
|
+
});
|
|
809
|
+
await writeRecord(record, { noReplace: false });
|
|
810
|
+
await assertOwnerUnchecked();
|
|
811
|
+
return record;
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const release = () =>
|
|
815
|
+
runExclusive(async () => {
|
|
816
|
+
const current = await assertOwnerUnchecked();
|
|
817
|
+
await retireObservedLease(sessionId, current);
|
|
818
|
+
released = true;
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return Object.freeze({
|
|
822
|
+
sessionId,
|
|
823
|
+
ownerToken: owner.ownerToken,
|
|
824
|
+
read,
|
|
825
|
+
beginTurn,
|
|
826
|
+
beginRetry,
|
|
827
|
+
settle,
|
|
828
|
+
discard,
|
|
829
|
+
assertOwner,
|
|
830
|
+
release,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function validateCaptainSessionRecord(value) {
|
|
835
|
+
const record = requireRecord(
|
|
836
|
+
snapshotJsonValue(value, 'Captain session record'),
|
|
837
|
+
'Captain session record',
|
|
838
|
+
);
|
|
839
|
+
if (record.state !== 'settled' && record.state !== 'uncertain') {
|
|
840
|
+
throw new Error('Captain session record state is not supported');
|
|
841
|
+
}
|
|
842
|
+
rejectUnknownOrMissingKeys(
|
|
843
|
+
record,
|
|
844
|
+
record.state === 'uncertain'
|
|
845
|
+
? [...COMMON_RECORD_KEYS, 'uncertain']
|
|
846
|
+
: COMMON_RECORD_KEYS,
|
|
847
|
+
'Captain session record',
|
|
848
|
+
);
|
|
849
|
+
if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`Captain session record schema ${JSON.stringify(record.schemaVersion)} is not supported`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (record.kind !== CAPTAIN_SESSION_RECORD_KIND) {
|
|
855
|
+
throw new Error('Captain session record kind is not supported');
|
|
856
|
+
}
|
|
857
|
+
assertSessionId(record.sessionId);
|
|
858
|
+
const createdAt = canonicalTimestamp(record.createdAt, 'createdAt');
|
|
859
|
+
const updatedAt = canonicalTimestamp(record.updatedAt, 'updatedAt');
|
|
860
|
+
if (Date.parse(updatedAt) < Date.parse(createdAt)) {
|
|
861
|
+
throw new Error('Captain session record updatedAt precedes createdAt');
|
|
862
|
+
}
|
|
863
|
+
if (record.state === 'settled' && updatedAt === createdAt) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
'settled Captain session updatedAt must follow its creation marker',
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
|
|
869
|
+
throw new Error('Captain session record cwd must be an absolute path');
|
|
870
|
+
}
|
|
871
|
+
if (resolve(record.cwd) !== record.cwd) {
|
|
872
|
+
throw new Error('Captain session record cwd must be normalized');
|
|
873
|
+
}
|
|
874
|
+
requireRecord(record.config, 'Captain session record config');
|
|
875
|
+
requireRecord(record.snapshot, 'Captain session record snapshot');
|
|
876
|
+
|
|
877
|
+
if (record.state === 'uncertain') {
|
|
878
|
+
const uncertain = requireRecord(
|
|
879
|
+
record.uncertain,
|
|
880
|
+
'Captain session record uncertain',
|
|
881
|
+
);
|
|
882
|
+
rejectUnknownOrMissingKeys(
|
|
883
|
+
uncertain,
|
|
884
|
+
UNCERTAIN_KEYS,
|
|
885
|
+
'Captain session record uncertain',
|
|
886
|
+
);
|
|
887
|
+
if (uncertain.baseUpdatedAt !== null) {
|
|
888
|
+
canonicalTimestamp(uncertain.baseUpdatedAt, 'uncertain.baseUpdatedAt');
|
|
889
|
+
if (
|
|
890
|
+
Date.parse(uncertain.baseUpdatedAt) < Date.parse(createdAt) ||
|
|
891
|
+
Date.parse(uncertain.baseUpdatedAt) >= Date.parse(updatedAt)
|
|
892
|
+
) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
'Captain session uncertain baseUpdatedAt must identify an earlier settled boundary',
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
const isFirstAttempt = uncertain.attemptNumber === 1;
|
|
899
|
+
if (isFirstAttempt !== (updatedAt === createdAt)) {
|
|
900
|
+
throw new Error(
|
|
901
|
+
'fresh Captain session retry timestamps must match the attempt boundary',
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
assertAcceptedInput(uncertain.input);
|
|
906
|
+
assertUuid(uncertain.attemptId, 'Captain session attempt id');
|
|
907
|
+
if (
|
|
908
|
+
!Number.isSafeInteger(uncertain.attemptNumber) ||
|
|
909
|
+
uncertain.attemptNumber <= 0
|
|
910
|
+
) {
|
|
911
|
+
throw new Error('Captain session attempt number must be a positive integer');
|
|
912
|
+
}
|
|
913
|
+
const markedAt = canonicalTimestamp(
|
|
914
|
+
uncertain.markedAt,
|
|
915
|
+
'uncertain.markedAt',
|
|
916
|
+
);
|
|
917
|
+
if (markedAt !== updatedAt) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
'Captain session uncertain markedAt must equal updatedAt',
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return record;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function validateFreshBoundary(value) {
|
|
928
|
+
const boundary = requireRecord(
|
|
929
|
+
snapshotJsonValue(value, 'fresh Captain session boundary'),
|
|
930
|
+
'fresh Captain session boundary',
|
|
931
|
+
);
|
|
932
|
+
rejectUnknownOrMissingKeys(
|
|
933
|
+
boundary,
|
|
934
|
+
['cwd', 'config', 'snapshot'],
|
|
935
|
+
'fresh Captain session boundary',
|
|
936
|
+
);
|
|
937
|
+
if (typeof boundary.cwd !== 'string' || !isAbsolute(boundary.cwd)) {
|
|
938
|
+
throw new Error('fresh Captain session cwd must be an absolute path');
|
|
939
|
+
}
|
|
940
|
+
if (resolve(boundary.cwd) !== boundary.cwd) {
|
|
941
|
+
throw new Error('fresh Captain session cwd must be normalized');
|
|
942
|
+
}
|
|
943
|
+
requireRecord(boundary.config, 'fresh Captain session config');
|
|
944
|
+
requireRecord(boundary.snapshot, 'fresh Captain session snapshot');
|
|
945
|
+
return boundary;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function validateLeaseOwner(value) {
|
|
949
|
+
const owner = requireRecord(
|
|
950
|
+
snapshotJsonValue(value, 'Captain session lease owner'),
|
|
951
|
+
'Captain session lease owner',
|
|
952
|
+
);
|
|
953
|
+
rejectUnknownOrMissingKeys(
|
|
954
|
+
owner,
|
|
955
|
+
LEASE_OWNER_KEYS,
|
|
956
|
+
'Captain session lease owner',
|
|
957
|
+
);
|
|
958
|
+
if (owner.schemaVersion !== LEASE_SCHEMA_VERSION) {
|
|
959
|
+
throw new Error('Captain session lease schema is not supported');
|
|
960
|
+
}
|
|
961
|
+
if (owner.kind !== LEASE_KIND) {
|
|
962
|
+
throw new Error('Captain session lease kind is not supported');
|
|
963
|
+
}
|
|
964
|
+
assertSessionId(owner.sessionId);
|
|
965
|
+
assertUuid(owner.ownerToken, 'Captain session lease owner token');
|
|
966
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
|
|
967
|
+
throw new Error('Captain session lease pid must be a positive integer');
|
|
968
|
+
}
|
|
969
|
+
if (typeof owner.hostname !== 'string' || owner.hostname.trim().length === 0) {
|
|
970
|
+
throw new Error('Captain session lease hostname must be a non-empty string');
|
|
971
|
+
}
|
|
972
|
+
canonicalTimestamp(owner.acquiredAt, 'lease acquiredAt');
|
|
973
|
+
return owner;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function requireUncertainRecord(record, attemptId) {
|
|
977
|
+
if (record === undefined) throw new Error('Captain session does not exist');
|
|
978
|
+
if (record.state !== 'uncertain') {
|
|
979
|
+
throw new Error('Captain session has no uncertain turn');
|
|
980
|
+
}
|
|
981
|
+
if (record.uncertain.attemptId !== attemptId) {
|
|
982
|
+
throw new Error('Captain session uncertain attempt id changed');
|
|
983
|
+
}
|
|
984
|
+
return record;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function assertAcceptedInput(value) {
|
|
988
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
989
|
+
throw new Error('Captain session input must be a non-empty string');
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function assertSessionId(value) {
|
|
994
|
+
assertUuid(value, 'Captain session id');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function assertUuid(value, path) {
|
|
998
|
+
if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
|
|
999
|
+
throw new Error(`${path} must be a UUID`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function requireRecord(value, path) {
|
|
1004
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1005
|
+
throw new Error(`${path} must be an object`);
|
|
1006
|
+
}
|
|
1007
|
+
return value;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function rejectUnknownOrMissingKeys(value, expected, path) {
|
|
1011
|
+
const actual = Object.keys(value);
|
|
1012
|
+
const expectedSet = new Set(expected);
|
|
1013
|
+
const unknown = actual.find((key) => !expectedSet.has(key));
|
|
1014
|
+
if (unknown !== undefined) {
|
|
1015
|
+
throw new Error(`${path} has unknown field ${JSON.stringify(unknown)}`);
|
|
1016
|
+
}
|
|
1017
|
+
const missing = expected.find((key) => !Object.hasOwn(value, key));
|
|
1018
|
+
if (missing !== undefined) {
|
|
1019
|
+
throw new Error(`${path} is missing field ${JSON.stringify(missing)}`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function canonicalTimestamp(value, field) {
|
|
1024
|
+
if (
|
|
1025
|
+
typeof value !== 'string' ||
|
|
1026
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
1027
|
+
) {
|
|
1028
|
+
throw new Error(`Captain session record ${field} must be an ISO timestamp`);
|
|
1029
|
+
}
|
|
1030
|
+
const millis = Date.parse(value);
|
|
1031
|
+
if (!Number.isFinite(millis) || new Date(millis).toISOString() !== value) {
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
`Captain session record ${field} must be a canonical ISO timestamp`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
return value;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function timestampFrom(value, field) {
|
|
1040
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
1041
|
+
if (!Number.isFinite(date.getTime())) {
|
|
1042
|
+
throw new Error(`${field} generator returned an invalid date`);
|
|
1043
|
+
}
|
|
1044
|
+
return date.toISOString();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function nextTimestamp(value, previous) {
|
|
1048
|
+
const candidate = timestampFrom(value, 'session timestamp');
|
|
1049
|
+
if (Date.parse(candidate) > Date.parse(previous)) return candidate;
|
|
1050
|
+
return new Date(Date.parse(previous) + 1).toISOString();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function readPrivateRegularFile(path, mode, fs, label) {
|
|
1054
|
+
await assertPrivateRegularPath(path, mode, fs, label);
|
|
1055
|
+
const handle = await fs.open(
|
|
1056
|
+
path,
|
|
1057
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
1058
|
+
);
|
|
1059
|
+
try {
|
|
1060
|
+
const stat = await handle.stat();
|
|
1061
|
+
if (!stat.isFile()) {
|
|
1062
|
+
throw new Error(`${label} path is not a regular file`);
|
|
1063
|
+
}
|
|
1064
|
+
if ((stat.mode & 0o7777) !== mode) {
|
|
1065
|
+
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
1066
|
+
}
|
|
1067
|
+
return await handle.readFile('utf8');
|
|
1068
|
+
} finally {
|
|
1069
|
+
await handle.close();
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function assertPrivateRegularPath(path, mode, fs, label) {
|
|
1074
|
+
const stat = await fs.lstat(path);
|
|
1075
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1076
|
+
throw new Error(`${label} path is not a real regular file`);
|
|
1077
|
+
}
|
|
1078
|
+
if ((stat.mode & 0o7777) !== mode) {
|
|
1079
|
+
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
1080
|
+
}
|
|
1081
|
+
return stat;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function octal(mode) {
|
|
1085
|
+
return `0${mode.toString(8)}`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function assertPathMissing(path, fs, message) {
|
|
1089
|
+
try {
|
|
1090
|
+
await fs.lstat(path);
|
|
1091
|
+
} catch (cause) {
|
|
1092
|
+
if (cause?.code === 'ENOENT') return;
|
|
1093
|
+
throw cause;
|
|
1094
|
+
}
|
|
1095
|
+
throw new Error(message);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function syncDirectory(path, fs) {
|
|
1099
|
+
let directory;
|
|
1100
|
+
try {
|
|
1101
|
+
directory = await fs.open(path, 'r');
|
|
1102
|
+
await directory.sync();
|
|
1103
|
+
} finally {
|
|
1104
|
+
await directory?.close();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function ensurePrivateDirectory(path, fs) {
|
|
1109
|
+
try {
|
|
1110
|
+
await assertPrivateDirectory(path, fs);
|
|
1111
|
+
return;
|
|
1112
|
+
} catch (cause) {
|
|
1113
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const missing = [];
|
|
1117
|
+
let cursor = path;
|
|
1118
|
+
for (;;) {
|
|
1119
|
+
try {
|
|
1120
|
+
await assertDirectoryNotLink(cursor, fs);
|
|
1121
|
+
break;
|
|
1122
|
+
} catch (cause) {
|
|
1123
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
1124
|
+
missing.push(cursor);
|
|
1125
|
+
const parent = dirname(cursor);
|
|
1126
|
+
if (parent === cursor) throw cause;
|
|
1127
|
+
cursor = parent;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
for (const directory of missing.reverse()) {
|
|
1131
|
+
let created = false;
|
|
1132
|
+
try {
|
|
1133
|
+
await fs.mkdir(directory, { mode: 0o700 });
|
|
1134
|
+
created = true;
|
|
1135
|
+
} catch (cause) {
|
|
1136
|
+
if (cause?.code !== 'EEXIST') throw cause;
|
|
1137
|
+
}
|
|
1138
|
+
const stat = await assertDirectoryNotLink(directory, fs);
|
|
1139
|
+
if (created) {
|
|
1140
|
+
await fs.chmod(directory, 0o700);
|
|
1141
|
+
await syncDirectory(dirname(directory), fs);
|
|
1142
|
+
} else if (
|
|
1143
|
+
directory === path &&
|
|
1144
|
+
(stat.mode & 0o7777) !== 0o700
|
|
1145
|
+
) {
|
|
1146
|
+
throw new Error('Captain session store directory permissions must be 0700');
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
await assertPrivateDirectory(path, fs);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function assertPrivateDirectory(path, fs) {
|
|
1153
|
+
const stat = await assertDirectoryNotLink(path, fs);
|
|
1154
|
+
if ((stat.mode & 0o7777) !== 0o700) {
|
|
1155
|
+
throw new Error('Captain session store directory permissions must be 0700');
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function assertDirectoryNotLink(path, fs) {
|
|
1160
|
+
const stat = await fs.lstat(path);
|
|
1161
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1162
|
+
throw new Error('Captain session store path is not a real directory');
|
|
1163
|
+
}
|
|
1164
|
+
return stat;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function errorMessage(error) {
|
|
1168
|
+
return error instanceof Error ? error.message : String(error);
|
|
1169
|
+
}
|