@sublang/playbook 6.0.0 → 8.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.
Files changed (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -0,0 +1,1961 @@
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 { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { isDeepStrictEqual } from 'node:util';
24
+ import { assertSupportedEffort } from '@sublang/cligent';
25
+ import { KNOWN_PLAYER_ADAPTERS } from '@sublang/cligent/tmux-play';
26
+ import { snapshotJsonValue } from '../../../../src/xstate-runtime.js';
27
+ import { assertPlaybookCaptainShellSnapshot } from '../playbook-captain.js';
28
+
29
+ export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
30
+ export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
31
+ export const SESSION_ID_PATTERN =
32
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
33
+ export const CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION = 1;
34
+ export const CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION = 2;
35
+
36
+ const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
37
+ const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
38
+ const RESERVED_ID = 'captain';
39
+ const KNOWN_ADAPTERS = new Set(KNOWN_PLAYER_ADAPTERS);
40
+
41
+ const COMMON_RECORD_KEYS = [
42
+ 'schemaVersion',
43
+ 'kind',
44
+ 'state',
45
+ 'sessionId',
46
+ 'createdAt',
47
+ 'updatedAt',
48
+ 'cwd',
49
+ 'structuralProjection',
50
+ 'lastAppliedExecutionProjection',
51
+ 'snapshot',
52
+ ];
53
+ const UNCERTAIN_KEYS = [
54
+ 'baseUpdatedAt',
55
+ 'input',
56
+ 'attemptId',
57
+ 'attemptNumber',
58
+ 'markedAt',
59
+ 'attemptedExecutionProjection',
60
+ ];
61
+ const RELEASED_SCHEMA_2_COMMON_RECORD_KEYS = [
62
+ 'schemaVersion',
63
+ 'kind',
64
+ 'state',
65
+ 'sessionId',
66
+ 'createdAt',
67
+ 'updatedAt',
68
+ 'cwd',
69
+ 'config',
70
+ 'snapshot',
71
+ ];
72
+ const RELEASED_SCHEMA_2_UNCERTAIN_KEYS = [
73
+ 'baseUpdatedAt',
74
+ 'input',
75
+ 'attemptId',
76
+ 'attemptNumber',
77
+ 'markedAt',
78
+ ];
79
+ const LEASE_SCHEMA_VERSION = 1;
80
+ const LEASE_KIND = 'captain-session-lease';
81
+ const LEASE_OWNER_FILE = 'owner.json';
82
+ const LEASE_OWNER_KEYS = [
83
+ 'schemaVersion',
84
+ 'kind',
85
+ 'sessionId',
86
+ 'ownerToken',
87
+ 'pid',
88
+ 'hostname',
89
+ 'acquiredAt',
90
+ ];
91
+ const DEFAULT_FS_OPERATIONS = Object.freeze({
92
+ chmod,
93
+ link,
94
+ lstat,
95
+ mkdir,
96
+ open,
97
+ readdir,
98
+ rename,
99
+ rmdir,
100
+ unlink,
101
+ });
102
+
103
+ class CaptainSessionRecordSchemaError extends Error {
104
+ constructor(schemaVersion, message, cause) {
105
+ super(message);
106
+ this.name = 'CaptainSessionRecordSchemaError';
107
+ this.schemaVersion = schemaVersion;
108
+ this.cause = cause;
109
+ }
110
+ }
111
+
112
+ export function defaultCaptainSessionsDir(
113
+ env = process.env,
114
+ home = env.HOME ?? homedir(),
115
+ ) {
116
+ const stateHome = env.XDG_STATE_HOME || join(home, '.local', 'state');
117
+ return join(stateHome, 'playbook', 'sessions');
118
+ }
119
+
120
+ export function createCaptainSessionStore(options = {}) {
121
+ const env = options.env ?? process.env;
122
+ const home = options.homeDir ?? env.HOME ?? homedir();
123
+ const sessionsDir =
124
+ options.sessionsDir ?? defaultCaptainSessionsDir(env, home);
125
+ const now = options.now ?? (() => new Date());
126
+ const createTempId = options.createTempId ?? randomUUID;
127
+ const createLeaseToken = options.createLeaseToken ?? randomUUID;
128
+ const localHostname = options.hostname ?? systemHostname();
129
+ const localPid = options.pid ?? process.pid;
130
+ const probeProcess =
131
+ options.probeProcess ?? ((pid) => process.kill(pid, 0));
132
+ const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
133
+
134
+ if (!isAbsolute(sessionsDir)) {
135
+ throw new Error('Captain session store path must be absolute');
136
+ }
137
+ if (
138
+ typeof localHostname !== 'string' ||
139
+ localHostname.trim().length === 0
140
+ ) {
141
+ throw new Error('Captain session lease hostname must be a non-empty string');
142
+ }
143
+ if (!Number.isSafeInteger(localPid) || localPid <= 0) {
144
+ throw new Error('Captain session lease pid must be a positive integer');
145
+ }
146
+ if (typeof probeProcess !== 'function') {
147
+ throw new Error('Captain session process probe must be a function');
148
+ }
149
+
150
+ const recordPathFor = (sessionId) => {
151
+ assertSessionId(sessionId);
152
+ return join(sessionsDir, `${sessionId}.json`);
153
+ };
154
+ const leasePathFor = (sessionId) => {
155
+ assertSessionId(sessionId);
156
+ return join(sessionsDir, `.${sessionId}.lock`);
157
+ };
158
+ const retiredPathFor = (sessionId, ownerToken) => {
159
+ assertSessionId(sessionId);
160
+ assertUuid(ownerToken, 'Captain session lease owner token');
161
+ return join(sessionsDir, `.${sessionId}.lock.retired.${ownerToken}`);
162
+ };
163
+
164
+ const readRecord = async (sessionId, { missing = 'error' } = {}) => {
165
+ const path = recordPathFor(sessionId);
166
+ let text;
167
+ try {
168
+ await assertPrivateDirectory(sessionsDir, fs);
169
+ text = await readPrivateRegularFile(path, 0o600, fs, 'record');
170
+ } catch (cause) {
171
+ if (cause?.code === 'ENOENT' && missing === 'undefined') return undefined;
172
+ if (cause?.code === 'ENOENT') {
173
+ throw new Error(
174
+ `Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} does not exist`,
175
+ );
176
+ }
177
+ throw new Error(
178
+ `cannot read Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)}: ${errorMessage(cause)}`,
179
+ );
180
+ }
181
+ let value;
182
+ try {
183
+ value = JSON.parse(text);
184
+ } catch (cause) {
185
+ throw new Error(
186
+ `Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} is not valid JSON: ${errorMessage(cause)}`,
187
+ );
188
+ }
189
+ let record;
190
+ try {
191
+ record = validateCaptainSessionRecord(value);
192
+ } catch (cause) {
193
+ const context =
194
+ `Captain session ${JSON.stringify(sessionId)} at ` +
195
+ `${JSON.stringify(path)}`;
196
+ if (cause instanceof CaptainSessionRecordSchemaError) {
197
+ if (cause.schemaVersion === 2 && value.sessionId !== sessionId) {
198
+ throw new Error(
199
+ `Captain session file ${JSON.stringify(path)} contains record ` +
200
+ JSON.stringify(value.sessionId),
201
+ );
202
+ }
203
+ throw new CaptainSessionRecordSchemaError(
204
+ cause.schemaVersion,
205
+ `${context}: ${cause.message}`,
206
+ cause,
207
+ );
208
+ }
209
+ throw new Error(`${context} is invalid: ${errorMessage(cause)}`, {
210
+ cause,
211
+ });
212
+ }
213
+ if (record.sessionId !== sessionId) {
214
+ throw new Error(
215
+ `Captain session file ${JSON.stringify(path)} contains record ` +
216
+ JSON.stringify(record.sessionId),
217
+ );
218
+ }
219
+ return record;
220
+ };
221
+
222
+ const read = (sessionId) => readRecord(sessionId);
223
+
224
+ const latest = async ({ onLegacyRecord } = {}) => {
225
+ if (
226
+ onLegacyRecord !== undefined &&
227
+ typeof onLegacyRecord !== 'function'
228
+ ) {
229
+ throw new Error(
230
+ 'Captain session legacy-record observer must be a function',
231
+ );
232
+ }
233
+ let names;
234
+ try {
235
+ await assertPrivateDirectory(sessionsDir, fs);
236
+ names = await fs.readdir(sessionsDir);
237
+ } catch (cause) {
238
+ if (cause?.code === 'ENOENT') {
239
+ throw new Error('no resumable Captain session exists');
240
+ }
241
+ throw new Error(`cannot list Captain sessions: ${errorMessage(cause)}`);
242
+ }
243
+ const candidates = [];
244
+ for (const name of names) {
245
+ if (!name.endsWith('.json')) continue;
246
+ const sessionId = name.slice(0, -'.json'.length);
247
+ if (!SESSION_ID_PATTERN.test(sessionId)) continue;
248
+ // Canonically named records are store-owned. Corruption must not make
249
+ // --continue silently select an older logical session.
250
+ try {
251
+ candidates.push(await readRecord(sessionId));
252
+ } catch (error) {
253
+ if (
254
+ error instanceof CaptainSessionRecordSchemaError &&
255
+ error.schemaVersion === 2
256
+ ) {
257
+ await onLegacyRecord?.(
258
+ Object.freeze({
259
+ sessionId,
260
+ path: recordPathFor(sessionId),
261
+ schemaVersion: 2,
262
+ }),
263
+ );
264
+ continue;
265
+ }
266
+ throw error;
267
+ }
268
+ }
269
+ candidates.sort((left, right) => {
270
+ const byUpdated = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
271
+ if (byUpdated !== 0) return byUpdated;
272
+ if (right.sessionId === left.sessionId) return 0;
273
+ return right.sessionId < left.sessionId ? -1 : 1;
274
+ });
275
+ if (candidates.length === 0) {
276
+ throw new Error('no resumable Captain session exists');
277
+ }
278
+ return candidates[0];
279
+ };
280
+
281
+ const writeRecord = async (recordValue, { noReplace }) => {
282
+ const record = validateCaptainSessionRecord(recordValue);
283
+ const destination = recordPathFor(record.sessionId);
284
+ await ensurePrivateDirectory(sessionsDir, fs);
285
+
286
+ if (noReplace) {
287
+ await assertPathMissing(
288
+ destination,
289
+ fs,
290
+ `Captain session ${JSON.stringify(record.sessionId)} already exists`,
291
+ );
292
+ } else {
293
+ await assertPrivateRegularPath(destination, 0o600, fs, 'record');
294
+ }
295
+
296
+ const tempId = createTempId();
297
+ assertUuid(tempId, 'Captain session temporary id');
298
+ const temporary = join(
299
+ sessionsDir,
300
+ `.${record.sessionId}.${localPid}.${tempId}.tmp`,
301
+ );
302
+ let handle;
303
+ let ownsTemporary = false;
304
+ let published = false;
305
+ try {
306
+ handle = await fs.open(temporary, 'wx', 0o600);
307
+ ownsTemporary = true;
308
+ await handle.chmod(0o600);
309
+ const tempStat = await handle.stat();
310
+ if (!tempStat.isFile() || (tempStat.mode & 0o7777) !== 0o600) {
311
+ throw new Error('Captain session temporary path is not a private regular file');
312
+ }
313
+ await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8');
314
+ await handle.sync();
315
+ await handle.close();
316
+ handle = undefined;
317
+
318
+ if (noReplace) {
319
+ try {
320
+ await fs.link(temporary, destination);
321
+ published = true;
322
+ } catch (cause) {
323
+ if (cause?.code === 'EEXIST') {
324
+ throw new Error(
325
+ `Captain session ${JSON.stringify(record.sessionId)} already exists`,
326
+ );
327
+ }
328
+ throw cause;
329
+ }
330
+ // Once the no-replace link exists, failure to remove the ignored temp
331
+ // name cannot make the logical publication fail or become ambiguous.
332
+ try {
333
+ await fs.unlink(temporary);
334
+ ownsTemporary = false;
335
+ } catch {
336
+ // Leave only this unpredictable store-owned temp path behind.
337
+ }
338
+ } else {
339
+ await fs.rename(temporary, destination);
340
+ ownsTemporary = false;
341
+ published = true;
342
+ }
343
+ await syncDirectory(sessionsDir, fs);
344
+ return record;
345
+ } catch (cause) {
346
+ try {
347
+ await handle?.close();
348
+ } catch {
349
+ // Preserve the persistence failure.
350
+ }
351
+ if (ownsTemporary && !published) {
352
+ try {
353
+ await fs.unlink(temporary);
354
+ } catch {
355
+ // Preserve the persistence failure and never broaden cleanup.
356
+ }
357
+ }
358
+ throw cause;
359
+ }
360
+ };
361
+
362
+ const deleteRecord = async (sessionId) => {
363
+ const path = recordPathFor(sessionId);
364
+ await assertPrivateRegularPath(path, 0o600, fs, 'record');
365
+ await fs.unlink(path);
366
+ await syncDirectory(sessionsDir, fs);
367
+ };
368
+
369
+ const readLeaseDirectory = async (
370
+ sessionId,
371
+ path,
372
+ expectedNames,
373
+ ) => {
374
+ let text;
375
+ try {
376
+ const directoryStat = await fs.lstat(path);
377
+ if (
378
+ directoryStat.isSymbolicLink() ||
379
+ !directoryStat.isDirectory() ||
380
+ (directoryStat.mode & 0o7777) !== 0o700
381
+ ) {
382
+ throw new Error('lease path is not a private real directory');
383
+ }
384
+ const names = (await fs.readdir(path)).sort();
385
+ if (
386
+ names.length !== expectedNames.length ||
387
+ expectedNames.some((name, index) => names[index] !== name)
388
+ ) {
389
+ throw new Error('lease directory is incomplete or malformed');
390
+ }
391
+ text = await readPrivateRegularFile(
392
+ join(path, LEASE_OWNER_FILE),
393
+ 0o600,
394
+ fs,
395
+ 'lease owner',
396
+ );
397
+ } catch (cause) {
398
+ if (cause?.code === 'ENOENT') throw cause;
399
+ throw new Error(
400
+ `cannot inspect Captain session lease: ${errorMessage(cause)}`,
401
+ );
402
+ }
403
+ let value;
404
+ try {
405
+ value = JSON.parse(text);
406
+ } catch (cause) {
407
+ throw new Error(
408
+ `Captain session lease owner is not valid JSON: ${errorMessage(cause)}`,
409
+ );
410
+ }
411
+ const owner = validateLeaseOwner(value);
412
+ if (owner.sessionId !== sessionId) {
413
+ throw new Error('Captain session lease owner id does not match its path');
414
+ }
415
+ return owner;
416
+ };
417
+
418
+ const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
419
+ readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
420
+
421
+ const readRetiredLease = async (sessionId, path, expectedToken) => {
422
+ const owner = await readLeaseDirectory(
423
+ sessionId,
424
+ path,
425
+ [LEASE_OWNER_FILE],
426
+ );
427
+ if (owner.ownerToken !== expectedToken) {
428
+ throw new Error('Captain session retired lease owner token is mismatched');
429
+ }
430
+ return owner;
431
+ };
432
+
433
+ const validateRetiredLeases = async (sessionId) => {
434
+ const prefix = `.${sessionId}.lock.retired`;
435
+ const exactPrefix = `${prefix}.`;
436
+ const names = await fs.readdir(sessionsDir);
437
+ for (const name of names.sort()) {
438
+ if (!name.startsWith(prefix)) continue;
439
+ if (!name.startsWith(exactPrefix)) {
440
+ throw new Error('Captain session retired lease name is malformed');
441
+ }
442
+ const ownerToken = name.slice(exactPrefix.length);
443
+ assertUuid(ownerToken, 'Captain session retired lease token');
444
+ await readRetiredLease(
445
+ sessionId,
446
+ join(sessionsDir, name),
447
+ ownerToken,
448
+ );
449
+ }
450
+ };
451
+
452
+ const cleanOwnStage = async (stagePath) => {
453
+ try {
454
+ const ownerPath = join(stagePath, LEASE_OWNER_FILE);
455
+ let ownerStat;
456
+ try {
457
+ ownerStat = await fs.lstat(ownerPath);
458
+ } catch (cause) {
459
+ if (cause?.code !== 'ENOENT') throw cause;
460
+ }
461
+ if (
462
+ ownerStat !== undefined &&
463
+ !ownerStat.isSymbolicLink() &&
464
+ ownerStat.isFile()
465
+ ) {
466
+ await fs.unlink(ownerPath);
467
+ }
468
+ await fs.rmdir(stagePath);
469
+ } catch {
470
+ // Preserve the acquisition failure. Never scan or broaden cleanup.
471
+ }
472
+ };
473
+
474
+ const makeLeaseStage = async (sessionId) => {
475
+ await ensurePrivateDirectory(sessionsDir, fs);
476
+ const ownerToken = createLeaseToken();
477
+ assertUuid(ownerToken, 'Captain session lease owner token');
478
+ const retiredPath = retiredPathFor(sessionId, ownerToken);
479
+ await assertPathMissing(
480
+ retiredPath,
481
+ fs,
482
+ 'Captain session lease owner token was already retired',
483
+ );
484
+ const stagePath = join(
485
+ sessionsDir,
486
+ `.${sessionId}.lock.stage.${ownerToken}`,
487
+ );
488
+ await assertPathMissing(
489
+ stagePath,
490
+ fs,
491
+ 'Captain session lease owner token is already staged',
492
+ );
493
+ const owner = validateLeaseOwner({
494
+ schemaVersion: LEASE_SCHEMA_VERSION,
495
+ kind: LEASE_KIND,
496
+ sessionId,
497
+ ownerToken,
498
+ pid: localPid,
499
+ hostname: localHostname,
500
+ acquiredAt: timestampFrom(now(), 'lease timestamp'),
501
+ });
502
+ let handle;
503
+ let created = false;
504
+ try {
505
+ await fs.mkdir(stagePath, { mode: 0o700 });
506
+ created = true;
507
+ await fs.chmod(stagePath, 0o700);
508
+ const stageStat = await fs.lstat(stagePath);
509
+ if (
510
+ stageStat.isSymbolicLink() ||
511
+ !stageStat.isDirectory() ||
512
+ (stageStat.mode & 0o7777) !== 0o700
513
+ ) {
514
+ throw new Error('Captain session lease stage is not a private directory');
515
+ }
516
+ handle = await fs.open(join(stagePath, LEASE_OWNER_FILE), 'wx', 0o600);
517
+ await handle.chmod(0o600);
518
+ const ownerStat = await handle.stat();
519
+ if (!ownerStat.isFile() || (ownerStat.mode & 0o7777) !== 0o600) {
520
+ throw new Error('Captain session lease owner is not a private regular file');
521
+ }
522
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
523
+ await handle.sync();
524
+ await handle.close();
525
+ handle = undefined;
526
+ await syncDirectory(stagePath, fs);
527
+ return { owner, stagePath };
528
+ } catch (cause) {
529
+ try {
530
+ await handle?.close();
531
+ } catch {
532
+ // Preserve the stage failure.
533
+ }
534
+ if (created) await cleanOwnStage(stagePath);
535
+ throw cause;
536
+ }
537
+ };
538
+
539
+ const retireObservedLease = async (sessionId, observedOwner) => {
540
+ const owner = validateLeaseOwner(observedOwner);
541
+ if (owner.sessionId !== sessionId) {
542
+ throw new Error('Captain session lease owner id changed before retirement');
543
+ }
544
+ const canonicalPath = leasePathFor(sessionId);
545
+ const retiredPath = retiredPathFor(sessionId, owner.ownerToken);
546
+ await assertPathMissing(
547
+ retiredPath,
548
+ fs,
549
+ 'Captain session lease retired path is already occupied',
550
+ );
551
+ try {
552
+ await fs.rename(canonicalPath, retiredPath);
553
+ } catch (cause) {
554
+ throw new Error(
555
+ `Captain session lease changed before retirement: ${errorMessage(cause)}`,
556
+ );
557
+ }
558
+ const retiredOwner = await readLeaseDirectory(
559
+ sessionId,
560
+ retiredPath,
561
+ [LEASE_OWNER_FILE],
562
+ );
563
+ if (retiredOwner.ownerToken !== owner.ownerToken) {
564
+ throw new Error('Captain session retired lease owner token changed');
565
+ }
566
+ await syncDirectory(sessionsDir, fs);
567
+ return retiredPath;
568
+ };
569
+
570
+ const publishLeaseStage = async (sessionId, stage, onRenamed) => {
571
+ const canonicalPath = leasePathFor(sessionId);
572
+ await validateRetiredLeases(sessionId);
573
+ await assertPathMissing(
574
+ canonicalPath,
575
+ fs,
576
+ 'Captain session lease became active before publication',
577
+ );
578
+ try {
579
+ // Program-created canonical lease directories are nonempty. Therefore a
580
+ // racing rename cannot replace one; it fails closed instead. Static empty
581
+ // or malformed destinations are rejected by the preflight above.
582
+ await fs.rename(stage.stagePath, canonicalPath);
583
+ onRenamed();
584
+ } catch (cause) {
585
+ throw new Error(
586
+ `Captain session lease publication lost its race: ${errorMessage(cause)}`,
587
+ );
588
+ }
589
+ await syncDirectory(sessionsDir, fs);
590
+ const publishedOwner = await readLeaseOwner(sessionId);
591
+ if (publishedOwner.ownerToken !== stage.owner.ownerToken) {
592
+ throw new Error('Captain session lease publication owner token changed');
593
+ }
594
+ return publishedOwner;
595
+ };
596
+
597
+ const acquire = async (sessionId) => {
598
+ assertSessionId(sessionId);
599
+ let stage;
600
+ let stagePublished = false;
601
+ try {
602
+ stage = await makeLeaseStage(sessionId);
603
+ const canonicalPath = leasePathFor(sessionId);
604
+ let existing;
605
+ try {
606
+ existing = await readLeaseOwner(sessionId);
607
+ } catch (cause) {
608
+ if (cause?.code !== 'ENOENT') throw cause;
609
+ }
610
+
611
+ if (existing !== undefined) {
612
+ if (existing.ownerToken === stage.owner.ownerToken) {
613
+ throw new Error('Captain session lease owner token was reused');
614
+ }
615
+ if (existing.hostname !== localHostname) {
616
+ throw new Error(
617
+ `Captain session lease is owned by foreign host ${JSON.stringify(existing.hostname)}`,
618
+ );
619
+ }
620
+ try {
621
+ await probeProcess(existing.pid);
622
+ throw new Error(
623
+ `Captain session lease is active in process ${existing.pid}`,
624
+ );
625
+ } catch (cause) {
626
+ if (cause?.code !== 'ESRCH') {
627
+ if (
628
+ cause instanceof Error &&
629
+ cause.message ===
630
+ `Captain session lease is active in process ${existing.pid}`
631
+ ) {
632
+ throw cause;
633
+ }
634
+ throw new Error(
635
+ `Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
636
+ );
637
+ }
638
+ }
639
+ await retireObservedLease(sessionId, existing);
640
+ } else {
641
+ // Preserve the explicit local solely for easier audit of the no-owner
642
+ // publication boundary.
643
+ void canonicalPath;
644
+ }
645
+
646
+ await publishLeaseStage(sessionId, stage, () => {
647
+ stagePublished = true;
648
+ });
649
+ return createLease({
650
+ sessionId,
651
+ owner: stage.owner,
652
+ readRecord,
653
+ writeRecord,
654
+ deleteRecord,
655
+ readLeaseOwner,
656
+ retireObservedLease,
657
+ validateRetiredLeases,
658
+ now,
659
+ });
660
+ } catch (cause) {
661
+ let cleanupError;
662
+ if (stage !== undefined && stagePublished) {
663
+ try {
664
+ const current = await readLeaseOwner(sessionId);
665
+ if (current.ownerToken === stage.owner.ownerToken) {
666
+ await retireObservedLease(sessionId, current);
667
+ }
668
+ } catch (error) {
669
+ cleanupError = error;
670
+ }
671
+ } else if (stage !== undefined) {
672
+ await cleanOwnStage(stage.stagePath);
673
+ }
674
+ if (cleanupError !== undefined) {
675
+ throw new AggregateError(
676
+ [cause, cleanupError],
677
+ `cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
678
+ );
679
+ }
680
+ throw new Error(
681
+ `cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
682
+ );
683
+ }
684
+ };
685
+
686
+ return Object.freeze({ sessionsDir, read, latest, acquire });
687
+ }
688
+
689
+ function createLease({
690
+ sessionId,
691
+ owner,
692
+ readRecord,
693
+ writeRecord,
694
+ deleteRecord,
695
+ readLeaseOwner,
696
+ retireObservedLease,
697
+ validateRetiredLeases,
698
+ now,
699
+ }) {
700
+ let released = false;
701
+ let operationActive = false;
702
+
703
+ const requireActive = () => {
704
+ if (released) throw new Error('Captain session lease was already released');
705
+ if (operationActive) {
706
+ throw new Error('Captain session lease operation is already in progress');
707
+ }
708
+ };
709
+
710
+ const runExclusive = async (operation) => {
711
+ requireActive();
712
+ operationActive = true;
713
+ try {
714
+ return await operation();
715
+ } finally {
716
+ operationActive = false;
717
+ }
718
+ };
719
+
720
+ const assertOwnerUnchecked = async () => {
721
+ if (released) throw new Error('Captain session lease was already released');
722
+ const current = await readLeaseOwner(sessionId);
723
+ if (current.ownerToken !== owner.ownerToken) {
724
+ throw new Error('Captain session lease is owned by a different token');
725
+ }
726
+ return current;
727
+ };
728
+
729
+ const assertOwner = () => runExclusive(assertOwnerUnchecked);
730
+
731
+ const read = () =>
732
+ runExclusive(async () => {
733
+ await assertOwnerUnchecked();
734
+ const record = await readRecord(sessionId, { missing: 'undefined' });
735
+ await assertOwnerUnchecked();
736
+ return record;
737
+ });
738
+
739
+ const initializeSettled = ({
740
+ cwd,
741
+ structuralProjection,
742
+ executionProjection,
743
+ snapshot,
744
+ } = {}) =>
745
+ runExclusive(async () => {
746
+ const initial = validateFreshBoundary({
747
+ cwd,
748
+ structuralProjection,
749
+ snapshot,
750
+ });
751
+ const applied = assertCaptainSessionExecutionCompatible(
752
+ initial.structuralProjection,
753
+ executionProjection,
754
+ );
755
+ const createdAt = timestampFrom(now(), 'session timestamp');
756
+ const updatedAt = nextTimestamp(now(), createdAt);
757
+ const record = validateCaptainSessionRecord({
758
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
759
+ kind: CAPTAIN_SESSION_RECORD_KIND,
760
+ state: 'settled',
761
+ sessionId,
762
+ createdAt,
763
+ updatedAt,
764
+ cwd: initial.cwd,
765
+ structuralProjection: initial.structuralProjection,
766
+ lastAppliedExecutionProjection: applied,
767
+ snapshot: initial.snapshot,
768
+ });
769
+ await assertOwnerUnchecked();
770
+ await writeRecord(record, { noReplace: true });
771
+ await assertOwnerUnchecked();
772
+ return record;
773
+ });
774
+
775
+ const beginTurn = ({
776
+ input,
777
+ attemptId,
778
+ attemptedExecutionProjection,
779
+ fresh,
780
+ } = {}) =>
781
+ runExclusive(async () => {
782
+ assertAcceptedInput(input);
783
+ assertUuid(attemptId, 'Captain session attempt id');
784
+ const attempted = validateCaptainSessionExecutionProjection(
785
+ attemptedExecutionProjection,
786
+ 'Captain session attempted execution projection',
787
+ );
788
+ await assertOwnerUnchecked();
789
+ const prior = await readRecord(sessionId, { missing: 'undefined' });
790
+ let record;
791
+ if (fresh !== undefined) {
792
+ if (prior !== undefined) {
793
+ throw new Error('fresh Captain session record already exists');
794
+ }
795
+ const initial = validateFreshBoundary(fresh);
796
+ const timestamp = timestampFrom(now(), 'session timestamp');
797
+ record = validateCaptainSessionRecord({
798
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
799
+ kind: CAPTAIN_SESSION_RECORD_KIND,
800
+ state: 'uncertain',
801
+ sessionId,
802
+ createdAt: timestamp,
803
+ updatedAt: timestamp,
804
+ cwd: initial.cwd,
805
+ structuralProjection: initial.structuralProjection,
806
+ lastAppliedExecutionProjection: attempted,
807
+ snapshot: initial.snapshot,
808
+ uncertain: {
809
+ baseUpdatedAt: null,
810
+ input,
811
+ attemptId,
812
+ attemptNumber: 1,
813
+ markedAt: timestamp,
814
+ attemptedExecutionProjection: attempted,
815
+ },
816
+ });
817
+ await assertOwnerUnchecked();
818
+ await writeRecord(record, { noReplace: true });
819
+ } else {
820
+ if (prior === undefined) {
821
+ throw new Error('Captain session does not exist for continuation');
822
+ }
823
+ if (prior.state !== 'settled') {
824
+ throw new Error('Captain session already has an uncertain turn');
825
+ }
826
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
827
+ record = validateCaptainSessionRecord({
828
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
829
+ kind: CAPTAIN_SESSION_RECORD_KIND,
830
+ state: 'uncertain',
831
+ sessionId,
832
+ createdAt: prior.createdAt,
833
+ updatedAt: timestamp,
834
+ cwd: prior.cwd,
835
+ structuralProjection: prior.structuralProjection,
836
+ lastAppliedExecutionProjection:
837
+ prior.lastAppliedExecutionProjection,
838
+ snapshot: prior.snapshot,
839
+ uncertain: {
840
+ baseUpdatedAt: prior.updatedAt,
841
+ input,
842
+ attemptId,
843
+ attemptNumber: 1,
844
+ markedAt: timestamp,
845
+ attemptedExecutionProjection: attempted,
846
+ },
847
+ });
848
+ await assertOwnerUnchecked();
849
+ await writeRecord(record, { noReplace: false });
850
+ }
851
+ await assertOwnerUnchecked();
852
+ return record;
853
+ });
854
+
855
+ const beginRetry = ({ expectedAttemptId, nextAttemptId } = {}) =>
856
+ runExclusive(async () => {
857
+ assertUuid(expectedAttemptId, 'Captain session expected attempt id');
858
+ assertUuid(nextAttemptId, 'Captain session next attempt id');
859
+ if (expectedAttemptId === nextAttemptId) {
860
+ throw new Error('Captain session retry requires a fresh attempt id');
861
+ }
862
+ await assertOwnerUnchecked();
863
+ const prior = await requireUncertainRecord(
864
+ await readRecord(sessionId, { missing: 'undefined' }),
865
+ expectedAttemptId,
866
+ );
867
+ if (!Number.isSafeInteger(prior.uncertain.attemptNumber + 1)) {
868
+ throw new Error('Captain session attempt number cannot be incremented');
869
+ }
870
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
871
+ const record = validateCaptainSessionRecord({
872
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
873
+ kind: CAPTAIN_SESSION_RECORD_KIND,
874
+ state: 'uncertain',
875
+ sessionId,
876
+ createdAt: prior.createdAt,
877
+ updatedAt: timestamp,
878
+ cwd: prior.cwd,
879
+ structuralProjection: prior.structuralProjection,
880
+ lastAppliedExecutionProjection:
881
+ prior.lastAppliedExecutionProjection,
882
+ snapshot: prior.snapshot,
883
+ uncertain: {
884
+ baseUpdatedAt: prior.uncertain.baseUpdatedAt,
885
+ input: prior.uncertain.input,
886
+ attemptId: nextAttemptId,
887
+ attemptNumber: prior.uncertain.attemptNumber + 1,
888
+ markedAt: timestamp,
889
+ attemptedExecutionProjection:
890
+ prior.uncertain.attemptedExecutionProjection,
891
+ },
892
+ });
893
+ await assertOwnerUnchecked();
894
+ await writeRecord(record, { noReplace: false });
895
+ await assertOwnerUnchecked();
896
+ return record;
897
+ });
898
+
899
+ const settle = ({ attemptId, snapshot } = {}) =>
900
+ runExclusive(async () => {
901
+ assertUuid(attemptId, 'Captain session attempt id');
902
+ await assertOwnerUnchecked();
903
+ const prior = await requireUncertainRecord(
904
+ await readRecord(sessionId, { missing: 'undefined' }),
905
+ attemptId,
906
+ );
907
+ const timestamp = nextTimestamp(now(), prior.updatedAt);
908
+ const record = validateCaptainSessionRecord({
909
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
910
+ kind: CAPTAIN_SESSION_RECORD_KIND,
911
+ state: 'settled',
912
+ sessionId,
913
+ createdAt: prior.createdAt,
914
+ updatedAt: timestamp,
915
+ cwd: prior.cwd,
916
+ structuralProjection: prior.structuralProjection,
917
+ lastAppliedExecutionProjection:
918
+ prior.uncertain.attemptedExecutionProjection,
919
+ snapshot,
920
+ });
921
+ await assertOwnerUnchecked();
922
+ await writeRecord(record, { noReplace: false });
923
+ await assertOwnerUnchecked();
924
+ return record;
925
+ });
926
+
927
+ const discard = ({ attemptId } = {}) =>
928
+ runExclusive(async () => {
929
+ assertUuid(attemptId, 'Captain session attempt id');
930
+ await assertOwnerUnchecked();
931
+ const prior = await requireUncertainRecord(
932
+ await readRecord(sessionId, { missing: 'undefined' }),
933
+ attemptId,
934
+ );
935
+ await assertOwnerUnchecked();
936
+ if (prior.uncertain.baseUpdatedAt === null) {
937
+ await deleteRecord(sessionId);
938
+ await assertOwnerUnchecked();
939
+ return undefined;
940
+ }
941
+ // writeRecord's stable key order reconstructs the exact prior settled
942
+ // bytes from the baseline carried by the uncertain record.
943
+ const record = validateCaptainSessionRecord({
944
+ schemaVersion: CAPTAIN_SESSION_RECORD_SCHEMA_VERSION,
945
+ kind: CAPTAIN_SESSION_RECORD_KIND,
946
+ state: 'settled',
947
+ sessionId,
948
+ createdAt: prior.createdAt,
949
+ updatedAt: prior.uncertain.baseUpdatedAt,
950
+ cwd: prior.cwd,
951
+ structuralProjection: prior.structuralProjection,
952
+ lastAppliedExecutionProjection:
953
+ prior.lastAppliedExecutionProjection,
954
+ snapshot: prior.snapshot,
955
+ });
956
+ await writeRecord(record, { noReplace: false });
957
+ await assertOwnerUnchecked();
958
+ return record;
959
+ });
960
+
961
+ const release = () =>
962
+ runExclusive(async () => {
963
+ const current = await assertOwnerUnchecked();
964
+ await retireObservedLease(sessionId, current);
965
+ released = true;
966
+ });
967
+
968
+ return Object.freeze({
969
+ sessionId,
970
+ ownerToken: owner.ownerToken,
971
+ read,
972
+ initializeSettled,
973
+ beginTurn,
974
+ beginRetry,
975
+ settle,
976
+ discard,
977
+ assertOwner,
978
+ release,
979
+ });
980
+ }
981
+
982
+ export function validateCaptainSessionExecutionProjection(
983
+ value,
984
+ path = 'Captain session execution projection',
985
+ ) {
986
+ const projection = requireRecord(snapshotJsonValue(value, path), path);
987
+ validateCaptainSessionProjection(projection, { path, structural: false });
988
+ return projection;
989
+ }
990
+
991
+ export function validateCaptainSessionStructuralProjection(
992
+ value,
993
+ path = 'Captain session structural projection',
994
+ ) {
995
+ const projection = requireRecord(snapshotJsonValue(value, path), path);
996
+ validateCaptainSessionProjection(projection, { path, structural: true });
997
+ return projection;
998
+ }
999
+
1000
+ export function projectCaptainSessionStructure(value) {
1001
+ const execution = validateCaptainSessionExecutionProjection(value);
1002
+ const fixedAgent = (agent) => ({
1003
+ adapter: agent.adapter,
1004
+ ...(agent.instruction === undefined
1005
+ ? {}
1006
+ : { instruction: agent.instruction }),
1007
+ ...(agent.permissions === undefined
1008
+ ? {}
1009
+ : { permissions: agent.permissions }),
1010
+ });
1011
+ return validateCaptainSessionStructuralProjection({
1012
+ schemaVersion: CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION,
1013
+ captain: fixedAgent(execution.captain),
1014
+ players: execution.players.map(({ id, ...agent }) => ({
1015
+ id,
1016
+ ...fixedAgent(agent),
1017
+ })),
1018
+ catalog: Object.fromEntries(
1019
+ Object.entries(execution.catalog).map(([id, item]) => [
1020
+ id,
1021
+ {
1022
+ id: item.id,
1023
+ from: item.from,
1024
+ manifestCommand: item.manifestCommand,
1025
+ command: item.command,
1026
+ intent: item.intent,
1027
+ artifactSchema: item.artifactSchema,
1028
+ requiredRoleIds: item.requiredRoleIds,
1029
+ concurrentRoleSets: item.concurrentRoleSets,
1030
+ roles: Object.fromEntries(
1031
+ Object.entries(item.roles).map(([roleId, binding]) => [
1032
+ roleId,
1033
+ { playerId: binding.playerId },
1034
+ ]),
1035
+ ),
1036
+ options: item.options,
1037
+ },
1038
+ ]),
1039
+ ),
1040
+ });
1041
+ }
1042
+
1043
+ export function assertCaptainSessionExecutionCompatible(
1044
+ structuralProjection,
1045
+ executionProjection,
1046
+ ) {
1047
+ const structural = validateCaptainSessionStructuralProjection(
1048
+ structuralProjection,
1049
+ );
1050
+ const execution = validateCaptainSessionExecutionProjection(
1051
+ executionProjection,
1052
+ );
1053
+ const projected = projectCaptainSessionStructure(execution);
1054
+ if (!isDeepStrictEqual(projected, structural)) {
1055
+ throw new Error(
1056
+ 'Captain session execution projection does not reproduce the stored structural projection',
1057
+ );
1058
+ }
1059
+ return execution;
1060
+ }
1061
+
1062
+ export function captainSessionSelectedMembers(value) {
1063
+ const structural = validateCaptainSessionStructuralProjection(value);
1064
+ return snapshotJsonValue(
1065
+ {
1066
+ playbookIds: Object.keys(structural.catalog),
1067
+ playerIds: referencedPlayerIds(structural.catalog),
1068
+ },
1069
+ 'Captain session selected members',
1070
+ );
1071
+ }
1072
+
1073
+ export function validateCaptainSessionRecord(value) {
1074
+ const record = requireRecord(
1075
+ snapshotJsonValue(value, 'Captain session record'),
1076
+ 'Captain session record',
1077
+ );
1078
+ if (record.schemaVersion !== CAPTAIN_SESSION_RECORD_SCHEMA_VERSION) {
1079
+ if (record.schemaVersion === 2) {
1080
+ assertReleasedSchema2CaptainSessionRecord(record);
1081
+ throw new CaptainSessionRecordSchemaError(
1082
+ record.schemaVersion,
1083
+ 'Captain session record schema 2 has incompatible root-owned player identity; schema 3 is required',
1084
+ );
1085
+ }
1086
+ throw new CaptainSessionRecordSchemaError(
1087
+ record.schemaVersion,
1088
+ `Captain session record schema ${JSON.stringify(record.schemaVersion)} is not supported`,
1089
+ );
1090
+ }
1091
+ if (record.state !== 'settled' && record.state !== 'uncertain') {
1092
+ throw new Error('Captain session record state is not supported');
1093
+ }
1094
+ rejectUnknownOrMissingKeys(
1095
+ record,
1096
+ record.state === 'uncertain'
1097
+ ? [...COMMON_RECORD_KEYS, 'uncertain']
1098
+ : COMMON_RECORD_KEYS,
1099
+ 'Captain session record',
1100
+ );
1101
+ if (record.kind !== CAPTAIN_SESSION_RECORD_KIND) {
1102
+ throw new Error('Captain session record kind is not supported');
1103
+ }
1104
+ assertSessionId(record.sessionId);
1105
+ const createdAt = canonicalTimestamp(record.createdAt, 'createdAt');
1106
+ const updatedAt = canonicalTimestamp(record.updatedAt, 'updatedAt');
1107
+ if (Date.parse(updatedAt) < Date.parse(createdAt)) {
1108
+ throw new Error('Captain session record updatedAt precedes createdAt');
1109
+ }
1110
+ if (record.state === 'settled' && updatedAt === createdAt) {
1111
+ throw new Error(
1112
+ 'settled Captain session updatedAt must follow its creation marker',
1113
+ );
1114
+ }
1115
+ if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
1116
+ throw new Error('Captain session record cwd must be an absolute path');
1117
+ }
1118
+ if (resolve(record.cwd) !== record.cwd) {
1119
+ throw new Error('Captain session record cwd must be normalized');
1120
+ }
1121
+ const structural = validateCaptainSessionStructuralProjection(
1122
+ record.structuralProjection,
1123
+ 'Captain session record structuralProjection',
1124
+ );
1125
+ const lastApplied = assertCaptainSessionExecutionCompatible(
1126
+ structural,
1127
+ record.lastAppliedExecutionProjection,
1128
+ );
1129
+ void lastApplied;
1130
+ const snapshot = assertPlaybookCaptainShellSnapshot(record.snapshot);
1131
+ assertSnapshotMatchesStructure(snapshot, structural);
1132
+ if (
1133
+ snapshot.captain.sessionId === record.sessionId ||
1134
+ snapshot.issuedSessionIds.includes(record.sessionId)
1135
+ ) {
1136
+ throw new Error(
1137
+ 'Captain session public id collides with an internal Captain session id',
1138
+ );
1139
+ }
1140
+
1141
+ if (record.state === 'uncertain') {
1142
+ const uncertain = requireRecord(
1143
+ record.uncertain,
1144
+ 'Captain session record uncertain',
1145
+ );
1146
+ rejectUnknownOrMissingKeys(
1147
+ uncertain,
1148
+ UNCERTAIN_KEYS,
1149
+ 'Captain session record uncertain',
1150
+ );
1151
+ if (uncertain.baseUpdatedAt !== null) {
1152
+ canonicalTimestamp(uncertain.baseUpdatedAt, 'uncertain.baseUpdatedAt');
1153
+ if (
1154
+ Date.parse(uncertain.baseUpdatedAt) <= Date.parse(createdAt) ||
1155
+ Date.parse(uncertain.baseUpdatedAt) >= Date.parse(updatedAt)
1156
+ ) {
1157
+ throw new Error(
1158
+ 'Captain session uncertain baseUpdatedAt must identify an earlier settled boundary',
1159
+ );
1160
+ }
1161
+ } else {
1162
+ const isFirstAttempt = uncertain.attemptNumber === 1;
1163
+ if (isFirstAttempt !== (updatedAt === createdAt)) {
1164
+ throw new Error(
1165
+ 'fresh Captain session retry timestamps must match the attempt boundary',
1166
+ );
1167
+ }
1168
+ }
1169
+ assertAcceptedInput(uncertain.input);
1170
+ assertUuid(uncertain.attemptId, 'Captain session attempt id');
1171
+ if (
1172
+ !Number.isSafeInteger(uncertain.attemptNumber) ||
1173
+ uncertain.attemptNumber <= 0
1174
+ ) {
1175
+ throw new Error('Captain session attempt number must be a positive integer');
1176
+ }
1177
+ const markedAt = canonicalTimestamp(
1178
+ uncertain.markedAt,
1179
+ 'uncertain.markedAt',
1180
+ );
1181
+ if (markedAt !== updatedAt) {
1182
+ throw new Error(
1183
+ 'Captain session uncertain markedAt must equal updatedAt',
1184
+ );
1185
+ }
1186
+ const attempted = assertCaptainSessionExecutionCompatible(
1187
+ structural,
1188
+ uncertain.attemptedExecutionProjection,
1189
+ );
1190
+ if (uncertain.baseUpdatedAt === null) {
1191
+ if (!isDeepStrictEqual(lastApplied, attempted)) {
1192
+ throw new Error(
1193
+ 'fresh Captain session baseline and attempted execution projections must match',
1194
+ );
1195
+ }
1196
+ assertTurnZeroSnapshot(
1197
+ snapshot,
1198
+ 'fresh Captain session record snapshot',
1199
+ );
1200
+ }
1201
+ }
1202
+
1203
+ return record;
1204
+ }
1205
+
1206
+ function assertReleasedSchema2CaptainSessionRecord(record) {
1207
+ if (record.state !== 'settled' && record.state !== 'uncertain') {
1208
+ throw new Error('Captain session record state is not supported');
1209
+ }
1210
+ rejectUnknownOrMissingKeys(
1211
+ record,
1212
+ record.state === 'uncertain'
1213
+ ? [...RELEASED_SCHEMA_2_COMMON_RECORD_KEYS, 'uncertain']
1214
+ : RELEASED_SCHEMA_2_COMMON_RECORD_KEYS,
1215
+ 'Captain session record',
1216
+ );
1217
+ if (record.kind !== CAPTAIN_SESSION_RECORD_KIND) {
1218
+ throw new Error('Captain session record kind is not supported');
1219
+ }
1220
+ assertSessionId(record.sessionId);
1221
+ const createdAt = canonicalTimestamp(record.createdAt, 'createdAt');
1222
+ const updatedAt = canonicalTimestamp(record.updatedAt, 'updatedAt');
1223
+ if (Date.parse(updatedAt) < Date.parse(createdAt)) {
1224
+ throw new Error('Captain session record updatedAt precedes createdAt');
1225
+ }
1226
+ if (record.state === 'settled' && updatedAt === createdAt) {
1227
+ throw new Error(
1228
+ 'settled Captain session updatedAt must follow its creation marker',
1229
+ );
1230
+ }
1231
+ if (typeof record.cwd !== 'string' || !isAbsolute(record.cwd)) {
1232
+ throw new Error('Captain session record cwd must be an absolute path');
1233
+ }
1234
+ if (resolve(record.cwd) !== record.cwd) {
1235
+ throw new Error('Captain session record cwd must be normalized');
1236
+ }
1237
+ requireRecord(record.config, 'Captain session record config');
1238
+ requireRecord(record.snapshot, 'Captain session record snapshot');
1239
+
1240
+ if (record.state !== 'uncertain') return;
1241
+ const uncertain = requireRecord(
1242
+ record.uncertain,
1243
+ 'Captain session record uncertain',
1244
+ );
1245
+ rejectUnknownOrMissingKeys(
1246
+ uncertain,
1247
+ RELEASED_SCHEMA_2_UNCERTAIN_KEYS,
1248
+ 'Captain session record uncertain',
1249
+ );
1250
+ if (uncertain.baseUpdatedAt !== null) {
1251
+ canonicalTimestamp(uncertain.baseUpdatedAt, 'uncertain.baseUpdatedAt');
1252
+ if (
1253
+ Date.parse(uncertain.baseUpdatedAt) < Date.parse(createdAt) ||
1254
+ Date.parse(uncertain.baseUpdatedAt) >= Date.parse(updatedAt)
1255
+ ) {
1256
+ throw new Error(
1257
+ 'Captain session uncertain baseUpdatedAt must identify an earlier settled boundary',
1258
+ );
1259
+ }
1260
+ } else {
1261
+ const isFirstAttempt = uncertain.attemptNumber === 1;
1262
+ if (isFirstAttempt !== (updatedAt === createdAt)) {
1263
+ throw new Error(
1264
+ 'fresh Captain session retry timestamps must match the attempt boundary',
1265
+ );
1266
+ }
1267
+ }
1268
+ assertAcceptedInput(uncertain.input);
1269
+ assertUuid(uncertain.attemptId, 'Captain session attempt id');
1270
+ if (
1271
+ !Number.isSafeInteger(uncertain.attemptNumber) ||
1272
+ uncertain.attemptNumber <= 0
1273
+ ) {
1274
+ throw new Error('Captain session attempt number must be a positive integer');
1275
+ }
1276
+ const markedAt = canonicalTimestamp(
1277
+ uncertain.markedAt,
1278
+ 'uncertain.markedAt',
1279
+ );
1280
+ if (markedAt !== updatedAt) {
1281
+ throw new Error(
1282
+ 'Captain session uncertain markedAt must equal updatedAt',
1283
+ );
1284
+ }
1285
+ }
1286
+
1287
+ function validateFreshBoundary(value) {
1288
+ const boundary = requireRecord(
1289
+ snapshotJsonValue(value, 'fresh Captain session boundary'),
1290
+ 'fresh Captain session boundary',
1291
+ );
1292
+ rejectUnknownOrMissingKeys(
1293
+ boundary,
1294
+ ['cwd', 'structuralProjection', 'snapshot'],
1295
+ 'fresh Captain session boundary',
1296
+ );
1297
+ if (typeof boundary.cwd !== 'string' || !isAbsolute(boundary.cwd)) {
1298
+ throw new Error('fresh Captain session cwd must be an absolute path');
1299
+ }
1300
+ if (resolve(boundary.cwd) !== boundary.cwd) {
1301
+ throw new Error('fresh Captain session cwd must be normalized');
1302
+ }
1303
+ const structural = validateCaptainSessionStructuralProjection(
1304
+ boundary.structuralProjection,
1305
+ 'fresh Captain session structuralProjection',
1306
+ );
1307
+ const snapshot = assertPlaybookCaptainShellSnapshot(boundary.snapshot);
1308
+ assertSnapshotMatchesStructure(snapshot, structural);
1309
+ assertTurnZeroSnapshot(snapshot, 'fresh Captain session boundary snapshot');
1310
+ return boundary;
1311
+ }
1312
+
1313
+ function assertTurnZeroSnapshot(snapshot, path) {
1314
+ if (
1315
+ snapshot.sequences.turn !== 0 ||
1316
+ snapshot.sequences.journal !== 0 ||
1317
+ snapshot.journal.length !== 0 ||
1318
+ snapshot.captain.conversation.kind !== 'unopened'
1319
+ ) {
1320
+ throw new Error(`${path} must be an initialized turn-zero shell snapshot`);
1321
+ }
1322
+ }
1323
+
1324
+ function validateCaptainSessionProjection(
1325
+ projection,
1326
+ { path, structural },
1327
+ ) {
1328
+ rejectUnknownOrMissingKeys(
1329
+ projection,
1330
+ ['schemaVersion', 'captain', 'players', 'catalog'],
1331
+ path,
1332
+ );
1333
+ const expectedSchema = structural
1334
+ ? CAPTAIN_SESSION_STRUCTURAL_PROJECTION_SCHEMA_VERSION
1335
+ : CAPTAIN_SESSION_EXECUTION_PROJECTION_SCHEMA_VERSION;
1336
+ if (projection.schemaVersion !== expectedSchema) {
1337
+ throw new Error(
1338
+ `${path}.schemaVersion ${JSON.stringify(projection.schemaVersion)} is not supported (expected ${expectedSchema})`,
1339
+ );
1340
+ }
1341
+ validateProjectedAgent(projection.captain, `${path}.captain`, {
1342
+ structural,
1343
+ });
1344
+ if (!Array.isArray(projection.players)) {
1345
+ throw new Error(`${path}.players must be an array`);
1346
+ }
1347
+ const playerIds = [];
1348
+ for (let index = 0; index < projection.players.length; index += 1) {
1349
+ const playerPath = `${path}.players[${index}]`;
1350
+ const player = requireRecord(projection.players[index], playerPath);
1351
+ exactOptionalKeys(
1352
+ player,
1353
+ structural
1354
+ ? ['id', 'adapter']
1355
+ : ['id', 'adapter', 'model', 'effort'],
1356
+ ['instruction', 'permissions'],
1357
+ playerPath,
1358
+ );
1359
+ assertPlayerId(player.id, `${playerPath}.id`);
1360
+ validateProjectedAgent(player, playerPath, { structural, hasId: true });
1361
+ playerIds.push(player.id);
1362
+ }
1363
+ if (new Set(playerIds).size !== playerIds.length) {
1364
+ throw new Error(`${path}.players contains a duplicate player id`);
1365
+ }
1366
+
1367
+ const catalog = requireRecord(projection.catalog, `${path}.catalog`);
1368
+ const catalogEntries = Object.entries(catalog);
1369
+ if (catalogEntries.length === 0) {
1370
+ throw new Error(`${path}.catalog must not be empty`);
1371
+ }
1372
+ const commands = new Set();
1373
+ for (const [id, rawItem] of catalogEntries) {
1374
+ const itemPath = `${path}.catalog.${id}`;
1375
+ const item = requireRecord(rawItem, itemPath);
1376
+ rejectUnknownOrMissingKeys(
1377
+ item,
1378
+ [
1379
+ 'id',
1380
+ 'from',
1381
+ 'manifestCommand',
1382
+ 'command',
1383
+ 'intent',
1384
+ 'artifactSchema',
1385
+ 'requiredRoleIds',
1386
+ 'concurrentRoleSets',
1387
+ 'roles',
1388
+ 'options',
1389
+ ],
1390
+ itemPath,
1391
+ );
1392
+ if (requireCanonicalNonblank(item.id, `${itemPath}.id`) !== id) {
1393
+ throw new Error(`${itemPath}.id must equal its catalog key`);
1394
+ }
1395
+ if (id === RESERVED_ID) {
1396
+ throw new Error(`${itemPath}.id uses reserved id "captain"`);
1397
+ }
1398
+ validateCanonicalModuleSpecifier(item.from, `${itemPath}.from`);
1399
+ requireCanonicalNonblank(
1400
+ item.manifestCommand,
1401
+ `${itemPath}.manifestCommand`,
1402
+ );
1403
+ const command = requireCanonicalNonblank(
1404
+ item.command,
1405
+ `${itemPath}.command`,
1406
+ );
1407
+ if (command === RESERVED_ID) {
1408
+ throw new Error(`${itemPath}.command uses reserved command "captain"`);
1409
+ }
1410
+ if (commands.has(command)) {
1411
+ throw new Error(`${path}.catalog contains duplicate command ${JSON.stringify(command)}`);
1412
+ }
1413
+ commands.add(command);
1414
+ if (typeof item.intent !== 'string') {
1415
+ throw new Error(`${itemPath}.intent must be a string`);
1416
+ }
1417
+ if (item.artifactSchema !== 2) {
1418
+ throw new Error(`${itemPath}.artifactSchema must be exactly 2`);
1419
+ }
1420
+ const requiredRoleIds = validateRoleIds(
1421
+ item.requiredRoleIds,
1422
+ `${itemPath}.requiredRoleIds`,
1423
+ );
1424
+ validateConcurrentRoleSets(
1425
+ item.concurrentRoleSets,
1426
+ requiredRoleIds,
1427
+ `${itemPath}.concurrentRoleSets`,
1428
+ );
1429
+ const roles = requireRecord(item.roles, `${itemPath}.roles`);
1430
+ if (!isDeepStrictEqual(Object.keys(roles), requiredRoleIds)) {
1431
+ throw new Error(`${itemPath}.roles must exactly follow requiredRoleIds`);
1432
+ }
1433
+ for (const roleId of requiredRoleIds) {
1434
+ const bindingPath = `${itemPath}.roles.${roleId}`;
1435
+ const binding = requireRecord(roles[roleId], bindingPath);
1436
+ rejectUnknownOrMissingKeys(
1437
+ binding,
1438
+ structural
1439
+ ? ['playerId']
1440
+ : ['playerId', 'model', 'effort'],
1441
+ bindingPath,
1442
+ );
1443
+ assertPlayerId(binding.playerId, `${bindingPath}.playerId`);
1444
+ if (!structural) {
1445
+ validateTuningSelection(binding.model, `${bindingPath}.model`);
1446
+ const player = projection.players.find(
1447
+ (candidate) => candidate.id === binding.playerId,
1448
+ );
1449
+ if (player === undefined) {
1450
+ throw new Error(
1451
+ `${bindingPath}.playerId names a player absent from ${path}.players`,
1452
+ );
1453
+ }
1454
+ validateEffortSelection(
1455
+ binding.effort,
1456
+ player.adapter,
1457
+ `${bindingPath}.effort`,
1458
+ );
1459
+ }
1460
+ }
1461
+ for (const [setIndex, set] of item.concurrentRoleSets.entries()) {
1462
+ const concurrentPlayers = set.map(
1463
+ (roleId) => roles[roleId].playerId,
1464
+ );
1465
+ if (new Set(concurrentPlayers).size !== concurrentPlayers.length) {
1466
+ throw new Error(
1467
+ `${itemPath}.concurrentRoleSets[${setIndex}] binds one player more than once`,
1468
+ );
1469
+ }
1470
+ }
1471
+ requireRecord(item.options, `${itemPath}.options`);
1472
+ }
1473
+ const referenced = referencedPlayerIds(catalog);
1474
+ if (!isDeepStrictEqual(playerIds, referenced)) {
1475
+ throw new Error(
1476
+ `${path}.players must equal the ordered player ids referenced by catalog roles`,
1477
+ );
1478
+ }
1479
+ }
1480
+
1481
+ function validateProjectedAgent(value, path, { structural, hasId = false }) {
1482
+ const agent = requireRecord(value, path);
1483
+ if (!hasId) {
1484
+ exactOptionalKeys(
1485
+ agent,
1486
+ structural ? ['adapter'] : ['adapter', 'model', 'effort'],
1487
+ ['instruction', 'permissions'],
1488
+ path,
1489
+ );
1490
+ }
1491
+ const adapter = requireCanonicalNonblank(agent.adapter, `${path}.adapter`);
1492
+ if (!KNOWN_ADAPTERS.has(adapter)) {
1493
+ throw new Error(`${path}.adapter ${JSON.stringify(adapter)} is not supported`);
1494
+ }
1495
+ if (!structural) {
1496
+ validateTuningSelection(agent.model, `${path}.model`);
1497
+ validateEffortSelection(agent.effort, adapter, `${path}.effort`);
1498
+ }
1499
+ if (agent.instruction !== undefined && typeof agent.instruction !== 'string') {
1500
+ throw new Error(`${path}.instruction must be a string`);
1501
+ }
1502
+ if (agent.permissions !== undefined) {
1503
+ validatePermissionPolicy(agent.permissions, `${path}.permissions`);
1504
+ }
1505
+ }
1506
+
1507
+ function validateTuningSelection(value, path) {
1508
+ const selection = requireRecord(value, path);
1509
+ if (selection.kind === 'provider-default') {
1510
+ rejectUnknownOrMissingKeys(selection, ['kind'], path);
1511
+ return;
1512
+ }
1513
+ if (selection.kind === 'value') {
1514
+ rejectUnknownOrMissingKeys(selection, ['kind', 'value'], path);
1515
+ requireNonblank(selection.value, `${path}.value`);
1516
+ return;
1517
+ }
1518
+ throw new Error(`${path}.kind is not supported`);
1519
+ }
1520
+
1521
+ function validateEffortSelection(value, adapter, path) {
1522
+ validateTuningSelection(value, path);
1523
+ if (value.kind !== 'value') return;
1524
+ try {
1525
+ assertSupportedEffort(adapter, value.value, `${path}.value`);
1526
+ } catch (cause) {
1527
+ throw new Error(errorMessage(cause));
1528
+ }
1529
+ }
1530
+
1531
+ function validatePermissionPolicy(value, path) {
1532
+ const permissions = requireRecord(value, path);
1533
+ exactOptionalKeys(
1534
+ permissions,
1535
+ [],
1536
+ [
1537
+ 'mode',
1538
+ 'fileWrite',
1539
+ 'shellExecute',
1540
+ 'networkAccess',
1541
+ 'writablePaths',
1542
+ ],
1543
+ path,
1544
+ );
1545
+ if (
1546
+ permissions.mode !== undefined &&
1547
+ permissions.mode !== 'auto' &&
1548
+ permissions.mode !== 'bypass'
1549
+ ) {
1550
+ throw new Error(`${path}.mode must be "auto" or "bypass"`);
1551
+ }
1552
+ for (const key of ['fileWrite', 'shellExecute', 'networkAccess']) {
1553
+ if (
1554
+ permissions[key] !== undefined &&
1555
+ !['allow', 'ask', 'deny'].includes(permissions[key])
1556
+ ) {
1557
+ throw new Error(`${path}.${key} must be "allow", "ask", or "deny"`);
1558
+ }
1559
+ }
1560
+ if (permissions.writablePaths !== undefined) {
1561
+ if (
1562
+ !Array.isArray(permissions.writablePaths) ||
1563
+ permissions.writablePaths.some(
1564
+ (entry) => typeof entry !== 'string' || entry.length === 0,
1565
+ )
1566
+ ) {
1567
+ throw new Error(
1568
+ `${path}.writablePaths must be an array of non-empty strings`,
1569
+ );
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ function validateRoleIds(value, path) {
1575
+ if (
1576
+ !Array.isArray(value) ||
1577
+ value.some(
1578
+ (roleId) =>
1579
+ typeof roleId !== 'string' ||
1580
+ !ROLE_ID_PATTERN.test(roleId) ||
1581
+ roleId === RESERVED_ID,
1582
+ ) ||
1583
+ new Set(value).size !== value.length
1584
+ ) {
1585
+ throw new Error(`${path} must contain distinct canonical local role ids`);
1586
+ }
1587
+ return value;
1588
+ }
1589
+
1590
+ function validateConcurrentRoleSets(value, requiredRoleIds, path) {
1591
+ if (!Array.isArray(value)) {
1592
+ throw new Error(`${path} must be an array`);
1593
+ }
1594
+ const required = new Set(requiredRoleIds);
1595
+ const signatures = new Set();
1596
+ for (let index = 0; index < value.length; index += 1) {
1597
+ const set = value[index];
1598
+ if (
1599
+ !Array.isArray(set) ||
1600
+ set.length < 2 ||
1601
+ set.some((roleId) => !required.has(roleId)) ||
1602
+ new Set(set).size !== set.length
1603
+ ) {
1604
+ throw new Error(
1605
+ `${path}[${index}] must contain at least two distinct required roles`,
1606
+ );
1607
+ }
1608
+ const signature = JSON.stringify(set);
1609
+ if (signatures.has(signature)) {
1610
+ throw new Error(`${path} contains duplicate set ${signature}`);
1611
+ }
1612
+ signatures.add(signature);
1613
+ }
1614
+ }
1615
+
1616
+ function referencedPlayerIds(catalog) {
1617
+ const seen = new Set();
1618
+ const ids = [];
1619
+ for (const item of Object.values(catalog)) {
1620
+ for (const roleId of item.requiredRoleIds) {
1621
+ const playerId = item.roles[roleId].playerId;
1622
+ if (!seen.has(playerId)) {
1623
+ seen.add(playerId);
1624
+ ids.push(playerId);
1625
+ }
1626
+ }
1627
+ }
1628
+ return ids;
1629
+ }
1630
+
1631
+ function assertSnapshotMatchesStructure(snapshot, structural) {
1632
+ if (!isDeepStrictEqual(snapshot.captain.agent, structural.captain)) {
1633
+ throw new Error(
1634
+ 'Captain session snapshot Captain envelope differs from structuralProjection',
1635
+ );
1636
+ }
1637
+ const structuralPlayers = new Map(
1638
+ structural.players.map(({ id, ...agent }) => [id, agent]),
1639
+ );
1640
+ if (
1641
+ !isDeepStrictEqual(
1642
+ Object.keys(snapshot.playerSessions),
1643
+ [...structuralPlayers.keys()],
1644
+ )
1645
+ ) {
1646
+ throw new Error(
1647
+ 'Captain session snapshot player ledger differs from structuralProjection roster',
1648
+ );
1649
+ }
1650
+ for (const [playerId, agent] of structuralPlayers) {
1651
+ const { resumeToken: _resumeToken, ...savedAgent } =
1652
+ snapshot.playerSessions[playerId];
1653
+ if (!isDeepStrictEqual(savedAgent, agent)) {
1654
+ throw new Error(
1655
+ `Captain session snapshot player ${JSON.stringify(playerId)} envelope differs from structuralProjection`,
1656
+ );
1657
+ }
1658
+ }
1659
+ if (snapshot.mode !== 'engaged.parked') return;
1660
+ for (const frame of snapshot.frames) {
1661
+ const item = structural.catalog[frame.playbookId];
1662
+ if (item === undefined) {
1663
+ throw new Error(
1664
+ `Captain session snapshot frame names unknown stored playbook ${JSON.stringify(frame.playbookId)}`,
1665
+ );
1666
+ }
1667
+ const roleBindings = Object.fromEntries(
1668
+ item.requiredRoleIds.map((roleId) => [
1669
+ roleId,
1670
+ item.roles[roleId].playerId,
1671
+ ]),
1672
+ );
1673
+ if (!isDeepStrictEqual(frame.roleBindings, roleBindings)) {
1674
+ throw new Error(
1675
+ `Captain session snapshot frame ${JSON.stringify(frame.playbookId)} role bindings differ from structuralProjection`,
1676
+ );
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ function validateLeaseOwner(value) {
1682
+ const owner = requireRecord(
1683
+ snapshotJsonValue(value, 'Captain session lease owner'),
1684
+ 'Captain session lease owner',
1685
+ );
1686
+ rejectUnknownOrMissingKeys(
1687
+ owner,
1688
+ LEASE_OWNER_KEYS,
1689
+ 'Captain session lease owner',
1690
+ );
1691
+ if (owner.schemaVersion !== LEASE_SCHEMA_VERSION) {
1692
+ throw new Error('Captain session lease schema is not supported');
1693
+ }
1694
+ if (owner.kind !== LEASE_KIND) {
1695
+ throw new Error('Captain session lease kind is not supported');
1696
+ }
1697
+ assertSessionId(owner.sessionId);
1698
+ assertUuid(owner.ownerToken, 'Captain session lease owner token');
1699
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
1700
+ throw new Error('Captain session lease pid must be a positive integer');
1701
+ }
1702
+ if (typeof owner.hostname !== 'string' || owner.hostname.trim().length === 0) {
1703
+ throw new Error('Captain session lease hostname must be a non-empty string');
1704
+ }
1705
+ canonicalTimestamp(owner.acquiredAt, 'lease acquiredAt');
1706
+ return owner;
1707
+ }
1708
+
1709
+ async function requireUncertainRecord(record, attemptId) {
1710
+ if (record === undefined) throw new Error('Captain session does not exist');
1711
+ if (record.state !== 'uncertain') {
1712
+ throw new Error('Captain session has no uncertain turn');
1713
+ }
1714
+ if (record.uncertain.attemptId !== attemptId) {
1715
+ throw new Error('Captain session uncertain attempt id changed');
1716
+ }
1717
+ return record;
1718
+ }
1719
+
1720
+ function assertAcceptedInput(value) {
1721
+ if (typeof value !== 'string' || value.trim().length === 0) {
1722
+ throw new Error('Captain session input must be a non-empty string');
1723
+ }
1724
+ }
1725
+
1726
+ function assertSessionId(value) {
1727
+ assertUuid(value, 'Captain session id');
1728
+ }
1729
+
1730
+ function assertPlayerId(value, path) {
1731
+ if (
1732
+ typeof value !== 'string' ||
1733
+ !PLAYER_ID_PATTERN.test(value) ||
1734
+ value === RESERVED_ID
1735
+ ) {
1736
+ throw new Error(
1737
+ `${path} must be a non-reserved player id matching ${PLAYER_ID_PATTERN.source}`,
1738
+ );
1739
+ }
1740
+ }
1741
+
1742
+ function assertUuid(value, path) {
1743
+ if (typeof value !== 'string' || !SESSION_ID_PATTERN.test(value)) {
1744
+ throw new Error(`${path} must be a UUID`);
1745
+ }
1746
+ }
1747
+
1748
+ function requireRecord(value, path) {
1749
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
1750
+ throw new Error(`${path} must be an object`);
1751
+ }
1752
+ return value;
1753
+ }
1754
+
1755
+ function rejectUnknownOrMissingKeys(value, expected, path) {
1756
+ const actual = Object.keys(value);
1757
+ const expectedSet = new Set(expected);
1758
+ const unknown = actual.find((key) => !expectedSet.has(key));
1759
+ if (unknown !== undefined) {
1760
+ throw new Error(`${path} has unknown field ${JSON.stringify(unknown)}`);
1761
+ }
1762
+ const missing = expected.find((key) => !Object.hasOwn(value, key));
1763
+ if (missing !== undefined) {
1764
+ throw new Error(`${path} is missing field ${JSON.stringify(missing)}`);
1765
+ }
1766
+ }
1767
+
1768
+ function exactOptionalKeys(value, required, optional, path) {
1769
+ rejectUnknownOrMissingKeys(
1770
+ value,
1771
+ [
1772
+ ...required,
1773
+ ...optional.filter((key) => Object.hasOwn(value, key)),
1774
+ ],
1775
+ path,
1776
+ );
1777
+ }
1778
+
1779
+ function requireNonblank(value, path) {
1780
+ if (typeof value !== 'string' || value.trim().length === 0) {
1781
+ throw new Error(`${path} must be a nonblank string`);
1782
+ }
1783
+ return value;
1784
+ }
1785
+
1786
+ function requireCanonicalNonblank(value, path) {
1787
+ const text = requireNonblank(value, path);
1788
+ if (text !== text.trim()) {
1789
+ throw new Error(`${path} must be in canonical trimmed form`);
1790
+ }
1791
+ return text;
1792
+ }
1793
+
1794
+ function validateCanonicalModuleSpecifier(value, path) {
1795
+ const specifier = requireCanonicalNonblank(value, path);
1796
+ if (
1797
+ isAbsolute(specifier) ||
1798
+ /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[A-Za-z]:[\\/])/.test(specifier)
1799
+ ) {
1800
+ throw new Error(`${path} must be a canonical module specifier`);
1801
+ }
1802
+ if (!specifier.startsWith('file:')) return specifier;
1803
+ let canonical;
1804
+ try {
1805
+ canonical = pathToFileURL(fileURLToPath(specifier)).href;
1806
+ } catch {
1807
+ throw new Error(`${path} must be a canonical file URL`);
1808
+ }
1809
+ if (canonical !== specifier) {
1810
+ throw new Error(`${path} must be a canonical file URL`);
1811
+ }
1812
+ return specifier;
1813
+ }
1814
+
1815
+ function canonicalTimestamp(value, field) {
1816
+ if (
1817
+ typeof value !== 'string' ||
1818
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
1819
+ ) {
1820
+ throw new Error(`Captain session record ${field} must be an ISO timestamp`);
1821
+ }
1822
+ const millis = Date.parse(value);
1823
+ if (!Number.isFinite(millis) || new Date(millis).toISOString() !== value) {
1824
+ throw new Error(
1825
+ `Captain session record ${field} must be a canonical ISO timestamp`,
1826
+ );
1827
+ }
1828
+ return value;
1829
+ }
1830
+
1831
+ function timestampFrom(value, field) {
1832
+ const date = value instanceof Date ? value : new Date(value);
1833
+ if (!Number.isFinite(date.getTime())) {
1834
+ throw new Error(`${field} generator returned an invalid date`);
1835
+ }
1836
+ return date.toISOString();
1837
+ }
1838
+
1839
+ function nextTimestamp(value, previous) {
1840
+ const candidate = timestampFrom(value, 'session timestamp');
1841
+ if (Date.parse(candidate) > Date.parse(previous)) return candidate;
1842
+ return new Date(Date.parse(previous) + 1).toISOString();
1843
+ }
1844
+
1845
+ async function readPrivateRegularFile(path, mode, fs, label) {
1846
+ await assertPrivateRegularPath(path, mode, fs, label);
1847
+ const handle = await fs.open(
1848
+ path,
1849
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
1850
+ );
1851
+ try {
1852
+ const stat = await handle.stat();
1853
+ if (!stat.isFile()) {
1854
+ throw new Error(`${label} path is not a regular file`);
1855
+ }
1856
+ if ((stat.mode & 0o7777) !== mode) {
1857
+ throw new Error(`${label} permissions must be ${octal(mode)}`);
1858
+ }
1859
+ return await handle.readFile('utf8');
1860
+ } finally {
1861
+ await handle.close();
1862
+ }
1863
+ }
1864
+
1865
+ async function assertPrivateRegularPath(path, mode, fs, label) {
1866
+ const stat = await fs.lstat(path);
1867
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1868
+ throw new Error(`${label} path is not a real regular file`);
1869
+ }
1870
+ if ((stat.mode & 0o7777) !== mode) {
1871
+ throw new Error(`${label} permissions must be ${octal(mode)}`);
1872
+ }
1873
+ return stat;
1874
+ }
1875
+
1876
+ function octal(mode) {
1877
+ return `0${mode.toString(8)}`;
1878
+ }
1879
+
1880
+ async function assertPathMissing(path, fs, message) {
1881
+ try {
1882
+ await fs.lstat(path);
1883
+ } catch (cause) {
1884
+ if (cause?.code === 'ENOENT') return;
1885
+ throw cause;
1886
+ }
1887
+ throw new Error(message);
1888
+ }
1889
+
1890
+ async function syncDirectory(path, fs) {
1891
+ let directory;
1892
+ try {
1893
+ directory = await fs.open(path, 'r');
1894
+ await directory.sync();
1895
+ } finally {
1896
+ await directory?.close();
1897
+ }
1898
+ }
1899
+
1900
+ async function ensurePrivateDirectory(path, fs) {
1901
+ try {
1902
+ await assertPrivateDirectory(path, fs);
1903
+ return;
1904
+ } catch (cause) {
1905
+ if (cause?.code !== 'ENOENT') throw cause;
1906
+ }
1907
+
1908
+ const missing = [];
1909
+ let cursor = path;
1910
+ for (;;) {
1911
+ try {
1912
+ await assertDirectoryNotLink(cursor, fs);
1913
+ break;
1914
+ } catch (cause) {
1915
+ if (cause?.code !== 'ENOENT') throw cause;
1916
+ missing.push(cursor);
1917
+ const parent = dirname(cursor);
1918
+ if (parent === cursor) throw cause;
1919
+ cursor = parent;
1920
+ }
1921
+ }
1922
+ for (const directory of missing.reverse()) {
1923
+ let created = false;
1924
+ try {
1925
+ await fs.mkdir(directory, { mode: 0o700 });
1926
+ created = true;
1927
+ } catch (cause) {
1928
+ if (cause?.code !== 'EEXIST') throw cause;
1929
+ }
1930
+ const stat = await assertDirectoryNotLink(directory, fs);
1931
+ if (created) {
1932
+ await fs.chmod(directory, 0o700);
1933
+ await syncDirectory(dirname(directory), fs);
1934
+ } else if (
1935
+ directory === path &&
1936
+ (stat.mode & 0o7777) !== 0o700
1937
+ ) {
1938
+ throw new Error('Captain session store directory permissions must be 0700');
1939
+ }
1940
+ }
1941
+ await assertPrivateDirectory(path, fs);
1942
+ }
1943
+
1944
+ async function assertPrivateDirectory(path, fs) {
1945
+ const stat = await assertDirectoryNotLink(path, fs);
1946
+ if ((stat.mode & 0o7777) !== 0o700) {
1947
+ throw new Error('Captain session store directory permissions must be 0700');
1948
+ }
1949
+ }
1950
+
1951
+ async function assertDirectoryNotLink(path, fs) {
1952
+ const stat = await fs.lstat(path);
1953
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
1954
+ throw new Error('Captain session store path is not a real directory');
1955
+ }
1956
+ return stat;
1957
+ }
1958
+
1959
+ function errorMessage(error) {
1960
+ return error instanceof Error ? error.message : String(error);
1961
+ }