@sublang/playbook 10.0.0 → 12.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 +1 -1
- package/docs/cli.md +67 -19
- package/docs/configuration.md +103 -40
- package/docs/embedding.md +88 -0
- package/package.json +30 -4
- package/reference/sdlc/code.md +35 -15
- package/reference/sdlc/code.playbook/bin/interactive-session.js +58 -6
- package/reference/sdlc/code.playbook/bin/launch-config.js +499 -241
- package/reference/sdlc/code.playbook/bin/playbook.js +236 -187
- package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
- package/reference/sdlc/code.playbook/bin/run.js +355 -203
- package/reference/sdlc/code.playbook/bin/session-store.js +1512 -136
- package/reference/sdlc/code.playbook/code.fsm.d.ts +22 -19
- package/reference/sdlc/code.playbook/code.fsm.js +116 -52
- package/reference/sdlc/code.playbook/code.fsm.ts +149 -64
- package/reference/sdlc/code.playbook/code.gears.md +40 -20
- package/reference/sdlc/code.playbook/code.playbook.js +23 -2
- package/reference/sdlc/code.playbook/code.playbook.ts +23 -2
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +4 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +21 -3
- package/reference/sdlc/code.playbook/playbook-captain.ts +42 -6
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +31 -11
- package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
- package/reference/sdlc/code.playbook/session-store.js +113 -0
- package/reference/sdlc/decide.md +24 -15
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +13 -6
- package/reference/sdlc/decide.playbook/decide.fsm.js +54 -27
- package/reference/sdlc/decide.playbook/decide.fsm.ts +68 -29
- package/reference/sdlc/decide.playbook/decide.gears.md +25 -19
- package/reference/sdlc/decide.playbook/decide.playbook.js +11 -3
- package/reference/sdlc/decide.playbook/decide.playbook.ts +11 -3
- package/reference/sdlc/decide.playbook/decide.registry.js +1 -1
- package/reference/sdlc/decide.playbook/decide.registry.ts +1 -1
- package/reference/sdlc/dev.md +52 -0
- package/reference/sdlc/dev.playbook/dev.fsm.d.ts +261 -0
- package/reference/sdlc/dev.playbook/dev.fsm.js +723 -0
- package/reference/sdlc/dev.playbook/dev.fsm.ts +988 -0
- package/reference/sdlc/dev.playbook/dev.gears.md +91 -0
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +21 -0
- package/reference/sdlc/dev.playbook/dev.playbook.js +143 -0
- package/reference/sdlc/dev.playbook/dev.playbook.ts +246 -0
- package/reference/sdlc/dev.playbook/dev.registry.d.ts +40 -0
- package/reference/sdlc/dev.playbook/dev.registry.js +64 -0
- package/reference/sdlc/dev.playbook/dev.registry.ts +120 -0
- package/reference/sdlc/review.md +36 -18
- package/reference/sdlc/review.playbook/review.fsm.d.ts +15 -2
- package/reference/sdlc/review.playbook/review.fsm.js +77 -27
- package/reference/sdlc/review.playbook/review.fsm.ts +96 -30
- package/reference/sdlc/review.playbook/review.gears.md +52 -26
- package/reference/sdlc/review.playbook/review.playbook.js +17 -7
- package/reference/sdlc/review.playbook/review.playbook.ts +17 -7
- package/reference/sdlc/review.playbook/review.registry.js +1 -1
- package/reference/sdlc/review.playbook/review.registry.ts +1 -1
- package/slc/link.md +12 -5
- package/slc/text2gears.md +3 -0
- package/src/xstate-playbook-runtime.js +5 -2
- package/src/xstate-playbook-runtime.ts +5 -2
- package/src/xstate-runtime.js +13 -1
- package/src/xstate-runtime.ts +13 -1
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// PBCLI-53: a fresh lease may move its newest same-directory predecessor's
|
|
7
7
|
// complete retained-generation map under guarded source-first publication.
|
|
8
8
|
|
|
9
|
-
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
10
|
import { constants } from 'node:fs';
|
|
11
11
|
import {
|
|
12
|
+
access,
|
|
12
13
|
chmod,
|
|
13
14
|
link,
|
|
14
15
|
lstat,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
|
|
40
41
|
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
|
|
41
42
|
export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
|
|
43
|
+
export const RECORDS_STREAM_VERSION = 1;
|
|
42
44
|
const LEGACY_CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 3;
|
|
43
45
|
// The interrupted retention change emitted this required-member shape before
|
|
44
46
|
// canonical writes returned to additive schema 3. Both pre-effect shapes and
|
|
@@ -120,7 +122,11 @@ const LEASE_OWNER_KEYS = [
|
|
|
120
122
|
'hostname',
|
|
121
123
|
'acquiredAt',
|
|
122
124
|
];
|
|
125
|
+
const PLAYBOOK_SESSION_NOT_FOUND = 'PLAYBOOK_SESSION_NOT_FOUND';
|
|
126
|
+
const PLAYBOOK_SESSION_LEASE_ACTIVE = 'PLAYBOOK_SESSION_LEASE_ACTIVE';
|
|
127
|
+
const ACTIVE_LEASE_ERROR = Symbol('active Playbook session lease');
|
|
123
128
|
const DEFAULT_FS_OPERATIONS = Object.freeze({
|
|
129
|
+
access,
|
|
124
130
|
chmod,
|
|
125
131
|
link,
|
|
126
132
|
lstat,
|
|
@@ -131,6 +137,15 @@ const DEFAULT_FS_OPERATIONS = Object.freeze({
|
|
|
131
137
|
rmdir,
|
|
132
138
|
unlink,
|
|
133
139
|
});
|
|
140
|
+
const EMPTY_REPLAY_PREFIX_DIGEST = createHash('sha256').digest('hex');
|
|
141
|
+
const REPLAY_READ_CHUNK_SIZE = 64 * 1024;
|
|
142
|
+
|
|
143
|
+
class ReplaySnapshotChangedError extends Error {
|
|
144
|
+
constructor(message) {
|
|
145
|
+
super(message);
|
|
146
|
+
this.name = 'ReplaySnapshotChangedError';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
134
149
|
|
|
135
150
|
class CaptainSessionRecordSchemaError extends Error {
|
|
136
151
|
constructor(schemaVersion, message, cause) {
|
|
@@ -150,17 +165,42 @@ class CaptainSessionRecordNonresumableError extends
|
|
|
150
165
|
}
|
|
151
166
|
}
|
|
152
167
|
|
|
168
|
+
class CaptainSessionNotFoundError extends Error {
|
|
169
|
+
constructor(sessionId, path) {
|
|
170
|
+
super(
|
|
171
|
+
`Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} does not exist`,
|
|
172
|
+
);
|
|
173
|
+
this.name = 'CaptainSessionNotFoundError';
|
|
174
|
+
this.code = PLAYBOOK_SESSION_NOT_FOUND;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
153
178
|
class CaptainSessionLeaseActiveError extends Error {
|
|
154
179
|
constructor(sessionId, pid) {
|
|
155
180
|
super(
|
|
156
181
|
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease: Captain session lease is active in process ${pid}`,
|
|
157
182
|
);
|
|
158
183
|
this.name = 'CaptainSessionLeaseActiveError';
|
|
184
|
+
this.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
|
|
185
|
+
this[ACTIVE_LEASE_ERROR] = true;
|
|
159
186
|
this.sessionId = sessionId;
|
|
160
187
|
this.pid = pid;
|
|
161
188
|
}
|
|
162
189
|
}
|
|
163
190
|
|
|
191
|
+
function foreignCaptainSessionLeaseActiveError(sessionId, hostname) {
|
|
192
|
+
const error = new Error(
|
|
193
|
+
`Captain session ${JSON.stringify(sessionId)} lease is owned by foreign host ${JSON.stringify(hostname)}`,
|
|
194
|
+
);
|
|
195
|
+
error.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
|
|
196
|
+
error[ACTIVE_LEASE_ERROR] = true;
|
|
197
|
+
return error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isActiveLeaseError(value) {
|
|
201
|
+
return value?.[ACTIVE_LEASE_ERROR] === true;
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
export function defaultCaptainSessionsDir(
|
|
165
205
|
env = process.env,
|
|
166
206
|
home = env.HOME ?? homedir(),
|
|
@@ -169,6 +209,398 @@ export function defaultCaptainSessionsDir(
|
|
|
169
209
|
return join(stateHome, 'playbook', 'sessions');
|
|
170
210
|
}
|
|
171
211
|
|
|
212
|
+
// PBCLI-78: front-end bootstrap checks the same private filesystem boundary
|
|
213
|
+
// as later store operations without creating an otherwise-unused directory.
|
|
214
|
+
// A missing leaf is usable because the first lease publication creates it.
|
|
215
|
+
export async function assertCaptainSessionsDirectoryUsable(
|
|
216
|
+
sessionsDir,
|
|
217
|
+
options = {},
|
|
218
|
+
) {
|
|
219
|
+
if (typeof sessionsDir !== 'string' || !isAbsolute(sessionsDir)) {
|
|
220
|
+
throw new Error('Captain session store path must be absolute');
|
|
221
|
+
}
|
|
222
|
+
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
223
|
+
try {
|
|
224
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
225
|
+
await fs.access(
|
|
226
|
+
sessionsDir,
|
|
227
|
+
constants.R_OK | constants.W_OK | constants.X_OK,
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Reject a symlink or non-directory anywhere in an otherwise missing path;
|
|
235
|
+
// mode 0700 becomes mandatory once the sessions directory itself exists.
|
|
236
|
+
let cursor = sessionsDir;
|
|
237
|
+
for (;;) {
|
|
238
|
+
try {
|
|
239
|
+
await assertDirectoryNotLink(cursor, fs);
|
|
240
|
+
await fs.access(cursor, constants.W_OK | constants.X_OK);
|
|
241
|
+
return;
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
244
|
+
const parent = dirname(cursor);
|
|
245
|
+
if (parent === cursor) throw cause;
|
|
246
|
+
cursor = parent;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// PBCLI-73: caller-shape rejection precedes the PBCLI-75 sanitizer so Task 3
|
|
252
|
+
// can keep non-latching argument errors distinct from latching data failures.
|
|
253
|
+
export function assertReplayAppendArguments(record, role) {
|
|
254
|
+
if (
|
|
255
|
+
typeof record !== 'object' ||
|
|
256
|
+
record === null ||
|
|
257
|
+
Array.isArray(record)
|
|
258
|
+
) {
|
|
259
|
+
throw new TypeError('replay append record must be an object');
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
role !== undefined &&
|
|
263
|
+
(typeof role !== 'string' || role.length === 0)
|
|
264
|
+
) {
|
|
265
|
+
throw new TypeError('replay append role must be a nonempty string');
|
|
266
|
+
}
|
|
267
|
+
return record;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function sanitizeReplayRecord(value) {
|
|
271
|
+
const sanitized = sanitizeReplayValue(value, 'replay record', new Set());
|
|
272
|
+
return requireRecord(sanitized, 'replay record');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// PBCLI-75/76/83: one lease-owned writer repairs the retained prefix, queues
|
|
276
|
+
// appends, and keeps replay durability fail-soft beside canonical settlement.
|
|
277
|
+
async function createLeaseReplayWriter({
|
|
278
|
+
sessionsDir,
|
|
279
|
+
path,
|
|
280
|
+
fs,
|
|
281
|
+
assertOwner,
|
|
282
|
+
readStream,
|
|
283
|
+
}) {
|
|
284
|
+
let lastReadableSeq = null;
|
|
285
|
+
let lastDurableSeq = null;
|
|
286
|
+
let incomplete = true;
|
|
287
|
+
let identity;
|
|
288
|
+
let completeOffset = 0;
|
|
289
|
+
let writerHandle;
|
|
290
|
+
let operationTail = Promise.resolve();
|
|
291
|
+
let appendAdmissionClosed = false;
|
|
292
|
+
|
|
293
|
+
const status = () =>
|
|
294
|
+
Object.freeze({ lastReadableSeq, lastDurableSeq, incomplete });
|
|
295
|
+
|
|
296
|
+
const enqueue = (operation) => {
|
|
297
|
+
const result = operationTail.then(operation);
|
|
298
|
+
operationTail = result.then(
|
|
299
|
+
() => undefined,
|
|
300
|
+
() => undefined,
|
|
301
|
+
);
|
|
302
|
+
return result;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const latch = () => {
|
|
306
|
+
if (lastReadableSeq !== null) incomplete = true;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const updateReadableFromDisk = async () => {
|
|
310
|
+
if (lastReadableSeq === null) return;
|
|
311
|
+
try {
|
|
312
|
+
const snapshot = await readReplayWriterSnapshot({
|
|
313
|
+
sessionsDir,
|
|
314
|
+
path,
|
|
315
|
+
identity,
|
|
316
|
+
fs,
|
|
317
|
+
});
|
|
318
|
+
if (snapshot.absent) return;
|
|
319
|
+
const parsed = parseReplayLines(
|
|
320
|
+
snapshot.bytes,
|
|
321
|
+
0,
|
|
322
|
+
EMPTY_REPLAY_PREFIX_DIGEST,
|
|
323
|
+
);
|
|
324
|
+
lastReadableSeq = parsed.sequence;
|
|
325
|
+
completeOffset = parsed.completeBytes;
|
|
326
|
+
identity = snapshot.identity;
|
|
327
|
+
} catch {
|
|
328
|
+
// The already-established boundary remains the only trustworthy one.
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const initialize = async () => {
|
|
333
|
+
let snapshot;
|
|
334
|
+
await assertOwner();
|
|
335
|
+
try {
|
|
336
|
+
snapshot = await readReplayWriterSnapshot({
|
|
337
|
+
sessionsDir,
|
|
338
|
+
path,
|
|
339
|
+
fs,
|
|
340
|
+
});
|
|
341
|
+
if (snapshot.absent) {
|
|
342
|
+
lastReadableSeq = 0;
|
|
343
|
+
lastDurableSeq = 0;
|
|
344
|
+
incomplete = false;
|
|
345
|
+
completeOffset = 0;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const parsed = parseReplayLines(
|
|
350
|
+
snapshot.bytes,
|
|
351
|
+
0,
|
|
352
|
+
EMPTY_REPLAY_PREFIX_DIGEST,
|
|
353
|
+
);
|
|
354
|
+
const prefixSequence = parsed.sequence;
|
|
355
|
+
lastReadableSeq = prefixSequence;
|
|
356
|
+
lastDurableSeq = prefixSequence;
|
|
357
|
+
incomplete = false;
|
|
358
|
+
identity = snapshot.identity;
|
|
359
|
+
completeOffset = parsed.completeBytes;
|
|
360
|
+
const tail = snapshot.bytes.subarray(parsed.completeBytes);
|
|
361
|
+
if (tail.length === 0) return;
|
|
362
|
+
|
|
363
|
+
let retainTail = false;
|
|
364
|
+
try {
|
|
365
|
+
parseReplayEnvelope(tail, prefixSequence + 1);
|
|
366
|
+
retainTail = true;
|
|
367
|
+
} catch {
|
|
368
|
+
// Every other torn tail is discarded at the complete-prefix boundary.
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (retainTail) {
|
|
372
|
+
try {
|
|
373
|
+
writerHandle = await mutateReplayWriterFile({
|
|
374
|
+
sessionsDir,
|
|
375
|
+
path,
|
|
376
|
+
identity,
|
|
377
|
+
expectedSize: snapshot.bytes.length,
|
|
378
|
+
expectedFinalSize: snapshot.bytes.length + 1,
|
|
379
|
+
fs,
|
|
380
|
+
retainHandle: true,
|
|
381
|
+
operation: async (handle) => {
|
|
382
|
+
await writeReplayRange(
|
|
383
|
+
handle,
|
|
384
|
+
Buffer.from('\n'),
|
|
385
|
+
snapshot.bytes.length,
|
|
386
|
+
);
|
|
387
|
+
completeOffset = snapshot.bytes.length + 1;
|
|
388
|
+
lastReadableSeq = prefixSequence + 1;
|
|
389
|
+
await handle.sync();
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
lastDurableSeq = prefixSequence + 1;
|
|
393
|
+
} catch {
|
|
394
|
+
await updateReadableFromDisk();
|
|
395
|
+
latch();
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
writerHandle = await mutateReplayWriterFile({
|
|
402
|
+
sessionsDir,
|
|
403
|
+
path,
|
|
404
|
+
identity,
|
|
405
|
+
expectedSize: snapshot.bytes.length,
|
|
406
|
+
expectedFinalSize: parsed.completeBytes,
|
|
407
|
+
fs,
|
|
408
|
+
retainHandle: true,
|
|
409
|
+
operation: async (handle) => {
|
|
410
|
+
await handle.truncate(parsed.completeBytes);
|
|
411
|
+
completeOffset = parsed.completeBytes;
|
|
412
|
+
await handle.sync();
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
} catch {
|
|
416
|
+
await updateReadableFromDisk();
|
|
417
|
+
latch();
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
lastReadableSeq = null;
|
|
421
|
+
lastDurableSeq = null;
|
|
422
|
+
incomplete = true;
|
|
423
|
+
identity = undefined;
|
|
424
|
+
completeOffset = 0;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const appendAtQueueHead = async (record, role) => {
|
|
429
|
+
if (lastReadableSeq === null || incomplete) return undefined;
|
|
430
|
+
|
|
431
|
+
let sanitized;
|
|
432
|
+
let sanitizationFailed = false;
|
|
433
|
+
let sanitizationFailure;
|
|
434
|
+
try {
|
|
435
|
+
sanitized = sanitizeReplayRecord(record);
|
|
436
|
+
} catch (cause) {
|
|
437
|
+
sanitizationFailed = true;
|
|
438
|
+
sanitizationFailure = cause;
|
|
439
|
+
}
|
|
440
|
+
// Sanitization may invoke caller-controlled Proxy traps. This one owner
|
|
441
|
+
// check is the append's cooperative-lease linearization point.
|
|
442
|
+
await assertOwner();
|
|
443
|
+
if (sanitizationFailed) {
|
|
444
|
+
latch();
|
|
445
|
+
throw sanitizationFailure;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (lastReadableSeq >= Number.MAX_SAFE_INTEGER) {
|
|
449
|
+
latch();
|
|
450
|
+
throw new Error('replay stream sequence exhausted its safe integer range');
|
|
451
|
+
}
|
|
452
|
+
const sequence = lastReadableSeq + 1;
|
|
453
|
+
const line = Buffer.from(
|
|
454
|
+
`${JSON.stringify({
|
|
455
|
+
v: RECORDS_STREAM_VERSION,
|
|
456
|
+
seq: sequence,
|
|
457
|
+
...(role === undefined ? {} : { role }),
|
|
458
|
+
record: sanitized,
|
|
459
|
+
})}\n`,
|
|
460
|
+
);
|
|
461
|
+
const publishing = identity === undefined;
|
|
462
|
+
try {
|
|
463
|
+
if (publishing) {
|
|
464
|
+
const published = await publishReplayWriterFile({
|
|
465
|
+
sessionsDir,
|
|
466
|
+
path,
|
|
467
|
+
bytes: line,
|
|
468
|
+
fs,
|
|
469
|
+
});
|
|
470
|
+
identity = published.identity;
|
|
471
|
+
writerHandle = published.handle;
|
|
472
|
+
completeOffset = line.length;
|
|
473
|
+
lastReadableSeq = sequence;
|
|
474
|
+
await syncDirectory(sessionsDir, fs);
|
|
475
|
+
} else if (writerHandle === undefined) {
|
|
476
|
+
writerHandle = await mutateReplayWriterFile({
|
|
477
|
+
sessionsDir,
|
|
478
|
+
path,
|
|
479
|
+
identity,
|
|
480
|
+
expectedSize: completeOffset,
|
|
481
|
+
expectedFinalSize: completeOffset + line.length,
|
|
482
|
+
fs,
|
|
483
|
+
retainHandle: true,
|
|
484
|
+
operation: (handle) =>
|
|
485
|
+
writeReplayRange(handle, line, completeOffset),
|
|
486
|
+
});
|
|
487
|
+
completeOffset += line.length;
|
|
488
|
+
lastReadableSeq = sequence;
|
|
489
|
+
} else {
|
|
490
|
+
await appendReplayWriterFile({
|
|
491
|
+
handle: writerHandle,
|
|
492
|
+
identity,
|
|
493
|
+
expectedSize: completeOffset,
|
|
494
|
+
bytes: line,
|
|
495
|
+
});
|
|
496
|
+
completeOffset += line.length;
|
|
497
|
+
lastReadableSeq = sequence;
|
|
498
|
+
}
|
|
499
|
+
return undefined;
|
|
500
|
+
} catch (cause) {
|
|
501
|
+
await updateReadableFromDisk();
|
|
502
|
+
latch();
|
|
503
|
+
throw cause;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const append = (record, role) => {
|
|
508
|
+
if (appendAdmissionClosed) {
|
|
509
|
+
return Promise.reject(
|
|
510
|
+
new Error('replay append admission is closed for release'),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (lastReadableSeq === null || incomplete) return Promise.resolve();
|
|
514
|
+
try {
|
|
515
|
+
assertReplayAppendArguments(record, role);
|
|
516
|
+
} catch (cause) {
|
|
517
|
+
return Promise.reject(cause);
|
|
518
|
+
}
|
|
519
|
+
return enqueue(() => appendAtQueueHead(record, role));
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const read = (options) => {
|
|
523
|
+
if (appendAdmissionClosed) {
|
|
524
|
+
return Promise.reject(new Error('replay writer is closing'));
|
|
525
|
+
}
|
|
526
|
+
return enqueue(async () => {
|
|
527
|
+
await assertOwner();
|
|
528
|
+
if (lastReadableSeq === null) {
|
|
529
|
+
throw new Error('replay stream is unavailable for this lease');
|
|
530
|
+
}
|
|
531
|
+
const result = await readStream(options);
|
|
532
|
+
lastReadableSeq = result.lastReadableSeq;
|
|
533
|
+
return freezeReplayLeaseReadResult(
|
|
534
|
+
result.entries,
|
|
535
|
+
lastReadableSeq,
|
|
536
|
+
lastDurableSeq,
|
|
537
|
+
incomplete,
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const checkpointAtQueueHead = async () => {
|
|
543
|
+
if (
|
|
544
|
+
lastReadableSeq === null ||
|
|
545
|
+
incomplete ||
|
|
546
|
+
lastReadableSeq === lastDurableSeq
|
|
547
|
+
) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
await assertOwner();
|
|
552
|
+
if (writerHandle === undefined) {
|
|
553
|
+
throw new Error('replay stream checkpoint has no writer handle');
|
|
554
|
+
}
|
|
555
|
+
await checkpointReplayWriterFile({
|
|
556
|
+
sessionsDir,
|
|
557
|
+
path,
|
|
558
|
+
handle: writerHandle,
|
|
559
|
+
identity,
|
|
560
|
+
expectedSize: completeOffset,
|
|
561
|
+
fs,
|
|
562
|
+
});
|
|
563
|
+
await assertOwner();
|
|
564
|
+
lastDurableSeq = lastReadableSeq;
|
|
565
|
+
} catch {
|
|
566
|
+
await updateReadableFromDisk();
|
|
567
|
+
latch();
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const checkpoint = () => enqueue(checkpointAtQueueHead);
|
|
572
|
+
|
|
573
|
+
const closeAppendAdmission = () => {
|
|
574
|
+
appendAdmissionClosed = true;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const prepareRelease = () =>
|
|
578
|
+
enqueue(async () => {
|
|
579
|
+
try {
|
|
580
|
+
await checkpointAtQueueHead();
|
|
581
|
+
} finally {
|
|
582
|
+
const handle = writerHandle;
|
|
583
|
+
writerHandle = undefined;
|
|
584
|
+
try {
|
|
585
|
+
await handle?.close();
|
|
586
|
+
} catch {
|
|
587
|
+
// A completed checkpoint owns durability; descriptor cleanup cannot
|
|
588
|
+
// broaden the latch trigger set or block canonical lease retirement.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
await initialize();
|
|
594
|
+
return Object.freeze({
|
|
595
|
+
append,
|
|
596
|
+
read,
|
|
597
|
+
status,
|
|
598
|
+
checkpoint,
|
|
599
|
+
closeAppendAdmission,
|
|
600
|
+
prepareRelease,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
172
604
|
export function createCaptainSessionStore(options = {}) {
|
|
173
605
|
const env = options.env ?? process.env;
|
|
174
606
|
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
@@ -182,6 +614,8 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
182
614
|
const probeProcess =
|
|
183
615
|
options.probeProcess ?? ((pid) => process.kill(pid, 0));
|
|
184
616
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
617
|
+
const replayReadCursors = new Map();
|
|
618
|
+
const replayReadQueues = new Map();
|
|
185
619
|
|
|
186
620
|
if (!isAbsolute(sessionsDir)) {
|
|
187
621
|
throw new Error('Captain session store path must be absolute');
|
|
@@ -203,6 +637,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
203
637
|
assertSessionId(sessionId);
|
|
204
638
|
return join(sessionsDir, `${sessionId}.json`);
|
|
205
639
|
};
|
|
640
|
+
const recordsPathFor = (sessionId) => {
|
|
641
|
+
assertSessionId(sessionId);
|
|
642
|
+
return join(sessionsDir, `${sessionId}.records.jsonl`);
|
|
643
|
+
};
|
|
206
644
|
const leasePathFor = (sessionId) => {
|
|
207
645
|
assertSessionId(sessionId);
|
|
208
646
|
return join(sessionsDir, `.${sessionId}.lock`);
|
|
@@ -222,9 +660,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
222
660
|
} catch (cause) {
|
|
223
661
|
if (cause?.code === 'ENOENT' && missing === 'undefined') return undefined;
|
|
224
662
|
if (cause?.code === 'ENOENT') {
|
|
225
|
-
throw new
|
|
226
|
-
`Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)} does not exist`,
|
|
227
|
-
);
|
|
663
|
+
throw new CaptainSessionNotFoundError(sessionId, path);
|
|
228
664
|
}
|
|
229
665
|
throw new Error(
|
|
230
666
|
`cannot read Captain session ${JSON.stringify(sessionId)} at ${JSON.stringify(path)}: ${errorMessage(cause)}`,
|
|
@@ -286,6 +722,40 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
286
722
|
|
|
287
723
|
const read = (sessionId) => readRecord(sessionId);
|
|
288
724
|
|
|
725
|
+
const readStream = async (sessionId, options) => {
|
|
726
|
+
assertSessionId(sessionId);
|
|
727
|
+
const afterSeq = validateReplayReadOptions(options);
|
|
728
|
+
const previous = replayReadQueues.get(sessionId) ?? Promise.resolve();
|
|
729
|
+
const operation = previous.then(() =>
|
|
730
|
+
readReplayStream({
|
|
731
|
+
sessionsDir,
|
|
732
|
+
path: recordsPathFor(sessionId),
|
|
733
|
+
afterSeq,
|
|
734
|
+
cursor: replayReadCursors.get(sessionId),
|
|
735
|
+
fs,
|
|
736
|
+
}),
|
|
737
|
+
);
|
|
738
|
+
const drained = operation.then(
|
|
739
|
+
(result) => {
|
|
740
|
+
if (result.cursor === undefined) {
|
|
741
|
+
replayReadCursors.delete(sessionId);
|
|
742
|
+
} else {
|
|
743
|
+
replayReadCursors.set(sessionId, result.cursor);
|
|
744
|
+
}
|
|
745
|
+
return undefined;
|
|
746
|
+
},
|
|
747
|
+
() => undefined,
|
|
748
|
+
);
|
|
749
|
+
replayReadQueues.set(sessionId, drained);
|
|
750
|
+
void drained.then(() => {
|
|
751
|
+
if (replayReadQueues.get(sessionId) === drained) {
|
|
752
|
+
replayReadQueues.delete(sessionId);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
const result = await operation;
|
|
756
|
+
return result.value;
|
|
757
|
+
};
|
|
758
|
+
|
|
289
759
|
const listRecords = async ({
|
|
290
760
|
onLegacyRecord,
|
|
291
761
|
onLegacyOrderingBoundary,
|
|
@@ -381,6 +851,39 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
381
851
|
);
|
|
382
852
|
};
|
|
383
853
|
|
|
854
|
+
const readSummary = async (sessionId) =>
|
|
855
|
+
projectPlaybookSessionSummary(await readRecord(sessionId));
|
|
856
|
+
|
|
857
|
+
const listSummaries = async () => {
|
|
858
|
+
const skipped = [];
|
|
859
|
+
const records = await listRecords({
|
|
860
|
+
onLegacyRecord: ({ sessionId, schemaVersion }) => {
|
|
861
|
+
skipped.push(
|
|
862
|
+
Object.freeze({
|
|
863
|
+
sessionId,
|
|
864
|
+
reason:
|
|
865
|
+
`Captain session schema ${schemaVersion} is below current ` +
|
|
866
|
+
`schema ${CAPTAIN_SESSION_RECORD_SCHEMA_VERSION}`,
|
|
867
|
+
}),
|
|
868
|
+
);
|
|
869
|
+
},
|
|
870
|
+
onInvalidRecord: ({ sessionId, reason }) => {
|
|
871
|
+
skipped.push(Object.freeze({ sessionId, reason }));
|
|
872
|
+
},
|
|
873
|
+
skipInvalidRecords: true,
|
|
874
|
+
});
|
|
875
|
+
return Object.freeze({
|
|
876
|
+
sessions: Object.freeze(
|
|
877
|
+
sortCaptainSessionRecords(records).map(projectPlaybookSessionSummary),
|
|
878
|
+
),
|
|
879
|
+
skipped: Object.freeze(
|
|
880
|
+
skipped.sort((left, right) =>
|
|
881
|
+
left.sessionId.localeCompare(right.sessionId),
|
|
882
|
+
),
|
|
883
|
+
),
|
|
884
|
+
});
|
|
885
|
+
};
|
|
886
|
+
|
|
384
887
|
const scanAdoptionPredecessor = async (
|
|
385
888
|
target,
|
|
386
889
|
{ onLegacyRecord, onInvalidRecord } = {},
|
|
@@ -580,6 +1083,24 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
580
1083
|
const readLeaseOwner = (sessionId, path = leasePathFor(sessionId)) =>
|
|
581
1084
|
readLeaseDirectory(sessionId, path, [LEASE_OWNER_FILE]);
|
|
582
1085
|
|
|
1086
|
+
const activeLeaseErrorFor = async (sessionId, owner) => {
|
|
1087
|
+
if (owner.hostname !== localHostname) {
|
|
1088
|
+
return foreignCaptainSessionLeaseActiveError(
|
|
1089
|
+
sessionId,
|
|
1090
|
+
owner.hostname,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
await probeProcess(owner.pid);
|
|
1095
|
+
return new CaptainSessionLeaseActiveError(sessionId, owner.pid);
|
|
1096
|
+
} catch (cause) {
|
|
1097
|
+
if (cause?.code === 'ESRCH') return undefined;
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`Captain session lease owner process cannot be ruled dead: ${errorMessage(cause)}`,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
|
|
583
1104
|
const readRetiredLease = async (sessionId, path, expectedToken) => {
|
|
584
1105
|
const owner = await readLeaseDirectory(
|
|
585
1106
|
sessionId,
|
|
@@ -710,146 +1231,963 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
710
1231
|
fs,
|
|
711
1232
|
'Captain session lease retired path is already occupied',
|
|
712
1233
|
);
|
|
713
|
-
try {
|
|
714
|
-
await fs.rename(canonicalPath, retiredPath);
|
|
715
|
-
} catch (cause) {
|
|
716
|
-
throw new Error(
|
|
717
|
-
`Captain session lease changed before retirement: ${errorMessage(cause)}`,
|
|
718
|
-
);
|
|
1234
|
+
try {
|
|
1235
|
+
await fs.rename(canonicalPath, retiredPath);
|
|
1236
|
+
} catch (cause) {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
`Captain session lease changed before retirement: ${errorMessage(cause)}`,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
const retiredOwner = await readLeaseDirectory(
|
|
1242
|
+
sessionId,
|
|
1243
|
+
retiredPath,
|
|
1244
|
+
[LEASE_OWNER_FILE],
|
|
1245
|
+
);
|
|
1246
|
+
if (retiredOwner.ownerToken !== owner.ownerToken) {
|
|
1247
|
+
throw new Error('Captain session retired lease owner token changed');
|
|
1248
|
+
}
|
|
1249
|
+
await syncDirectory(sessionsDir, fs);
|
|
1250
|
+
return retiredPath;
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
const publishLeaseStage = async (sessionId, stage, onRenamed) => {
|
|
1254
|
+
const canonicalPath = leasePathFor(sessionId);
|
|
1255
|
+
await validateRetiredLeases(sessionId);
|
|
1256
|
+
try {
|
|
1257
|
+
await assertPathMissing(
|
|
1258
|
+
canonicalPath,
|
|
1259
|
+
fs,
|
|
1260
|
+
'Captain session lease became active before publication',
|
|
1261
|
+
);
|
|
1262
|
+
// Program-created canonical lease directories are nonempty. Therefore a
|
|
1263
|
+
// racing rename cannot replace one; it fails closed instead. Static empty
|
|
1264
|
+
// or malformed destinations are rejected by the preflight above.
|
|
1265
|
+
await fs.rename(stage.stagePath, canonicalPath);
|
|
1266
|
+
onRenamed();
|
|
1267
|
+
} catch (cause) {
|
|
1268
|
+
try {
|
|
1269
|
+
const winner = await readLeaseOwner(sessionId);
|
|
1270
|
+
const active = await activeLeaseErrorFor(sessionId, winner);
|
|
1271
|
+
if (active !== undefined) throw active;
|
|
1272
|
+
} catch (winnerCause) {
|
|
1273
|
+
if (isActiveLeaseError(winnerCause)) {
|
|
1274
|
+
throw winnerCause;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
`Captain session lease publication lost its race: ${errorMessage(cause)}`,
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
await syncDirectory(sessionsDir, fs);
|
|
1282
|
+
const publishedOwner = await readLeaseOwner(sessionId);
|
|
1283
|
+
if (publishedOwner.ownerToken !== stage.owner.ownerToken) {
|
|
1284
|
+
throw new Error('Captain session lease publication owner token changed');
|
|
1285
|
+
}
|
|
1286
|
+
return publishedOwner;
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
const acquire = async (sessionId) => {
|
|
1290
|
+
assertSessionId(sessionId);
|
|
1291
|
+
let stage;
|
|
1292
|
+
let stagePublished = false;
|
|
1293
|
+
try {
|
|
1294
|
+
stage = await makeLeaseStage(sessionId);
|
|
1295
|
+
const canonicalPath = leasePathFor(sessionId);
|
|
1296
|
+
let existing;
|
|
1297
|
+
try {
|
|
1298
|
+
existing = await readLeaseOwner(sessionId);
|
|
1299
|
+
} catch (cause) {
|
|
1300
|
+
if (cause?.code !== 'ENOENT') throw cause;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (existing !== undefined) {
|
|
1304
|
+
if (existing.ownerToken === stage.owner.ownerToken) {
|
|
1305
|
+
throw new Error('Captain session lease owner token was reused');
|
|
1306
|
+
}
|
|
1307
|
+
const active = await activeLeaseErrorFor(sessionId, existing);
|
|
1308
|
+
if (active !== undefined) throw active;
|
|
1309
|
+
await retireObservedLease(sessionId, existing);
|
|
1310
|
+
} else {
|
|
1311
|
+
// Preserve the explicit local solely for easier audit of the no-owner
|
|
1312
|
+
// publication boundary.
|
|
1313
|
+
void canonicalPath;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
await publishLeaseStage(sessionId, stage, () => {
|
|
1317
|
+
stagePublished = true;
|
|
1318
|
+
});
|
|
1319
|
+
return await createLease({
|
|
1320
|
+
sessionId,
|
|
1321
|
+
owner: stage.owner,
|
|
1322
|
+
sessionsDir,
|
|
1323
|
+
replayPath: recordsPathFor(sessionId),
|
|
1324
|
+
replayFs: fs,
|
|
1325
|
+
readReplayStream: (options) => readStream(sessionId, options),
|
|
1326
|
+
readRecord,
|
|
1327
|
+
writeRecord,
|
|
1328
|
+
syncRecordDirectory: () => syncDirectory(sessionsDir, fs),
|
|
1329
|
+
deleteRecord,
|
|
1330
|
+
scanAdoptionPredecessor,
|
|
1331
|
+
acquireSession: acquire,
|
|
1332
|
+
readLeaseOwner,
|
|
1333
|
+
retireObservedLease,
|
|
1334
|
+
validateRetiredLeases,
|
|
1335
|
+
now,
|
|
1336
|
+
});
|
|
1337
|
+
} catch (cause) {
|
|
1338
|
+
let cleanupError;
|
|
1339
|
+
if (stage !== undefined && stagePublished) {
|
|
1340
|
+
try {
|
|
1341
|
+
const current = await readLeaseOwner(sessionId);
|
|
1342
|
+
if (current.ownerToken === stage.owner.ownerToken) {
|
|
1343
|
+
await retireObservedLease(sessionId, current);
|
|
1344
|
+
}
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
cleanupError = error;
|
|
1347
|
+
}
|
|
1348
|
+
} else if (stage !== undefined) {
|
|
1349
|
+
await cleanOwnStage(stage.stagePath);
|
|
1350
|
+
}
|
|
1351
|
+
if (cleanupError !== undefined) {
|
|
1352
|
+
throw new AggregateError(
|
|
1353
|
+
[cause, cleanupError],
|
|
1354
|
+
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease without leaving ownership uncertain`,
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
if (isActiveLeaseError(cause)) throw cause;
|
|
1358
|
+
throw new Error(
|
|
1359
|
+
`cannot acquire Captain session ${JSON.stringify(sessionId)} lease: ${errorMessage(cause)}`,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
return Object.freeze({
|
|
1365
|
+
sessionsDir,
|
|
1366
|
+
listSummaries,
|
|
1367
|
+
readSummary,
|
|
1368
|
+
read,
|
|
1369
|
+
readStream,
|
|
1370
|
+
latest,
|
|
1371
|
+
acquire,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function readReplayStream({
|
|
1376
|
+
sessionsDir,
|
|
1377
|
+
path,
|
|
1378
|
+
afterSeq,
|
|
1379
|
+
cursor,
|
|
1380
|
+
fs,
|
|
1381
|
+
}) {
|
|
1382
|
+
let forceFullRead = false;
|
|
1383
|
+
let snapshot;
|
|
1384
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1385
|
+
try {
|
|
1386
|
+
snapshot = await readReplaySnapshot({
|
|
1387
|
+
sessionsDir,
|
|
1388
|
+
path,
|
|
1389
|
+
afterSeq,
|
|
1390
|
+
cursor,
|
|
1391
|
+
forceFullRead,
|
|
1392
|
+
fs,
|
|
1393
|
+
});
|
|
1394
|
+
break;
|
|
1395
|
+
} catch (cause) {
|
|
1396
|
+
if (!(cause instanceof ReplaySnapshotChangedError) || attempt === 1) {
|
|
1397
|
+
throw cause;
|
|
1398
|
+
}
|
|
1399
|
+
if (cursor !== undefined) cursor.reusable = false;
|
|
1400
|
+
forceFullRead = true;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
if (snapshot === undefined) {
|
|
1405
|
+
throw new Error('replay stream changed during both read snapshots');
|
|
1406
|
+
}
|
|
1407
|
+
if (snapshot.absent) {
|
|
1408
|
+
if (cursor !== undefined) cursor.reusable = false;
|
|
1409
|
+
if (cursor !== undefined && cursor.sequence > 0) {
|
|
1410
|
+
throw new Error('replay stream rolled back below its observed prefix');
|
|
1411
|
+
}
|
|
1412
|
+
if (afterSeq !== 0) {
|
|
1413
|
+
throw new Error(
|
|
1414
|
+
`replay stream afterSeq ${afterSeq} exceeds last readable sequence 0`,
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
cursor: undefined,
|
|
1419
|
+
value: freezeReplayReadResult([], 0),
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const initialSequence = snapshot.incremental ? cursor.sequence : 0;
|
|
1424
|
+
const initialDigest = snapshot.incremental
|
|
1425
|
+
? cursor.digest
|
|
1426
|
+
: EMPTY_REPLAY_PREFIX_DIGEST;
|
|
1427
|
+
const parsed = parseReplayLines(
|
|
1428
|
+
snapshot.bytes,
|
|
1429
|
+
initialSequence,
|
|
1430
|
+
initialDigest,
|
|
1431
|
+
snapshot.incremental ? undefined : cursor?.sequence,
|
|
1432
|
+
);
|
|
1433
|
+
const lastReadableSeq = parsed.sequence;
|
|
1434
|
+
|
|
1435
|
+
if (!snapshot.incremental && cursor !== undefined && cursor.sequence > 0) {
|
|
1436
|
+
if (
|
|
1437
|
+
lastReadableSeq < cursor.sequence ||
|
|
1438
|
+
parsed.observedDigest !== cursor.digest
|
|
1439
|
+
) {
|
|
1440
|
+
throw new Error('replay stream rolled back or changed its observed prefix');
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (afterSeq > lastReadableSeq) {
|
|
1444
|
+
throw new Error(
|
|
1445
|
+
`replay stream afterSeq ${afterSeq} exceeds last readable sequence ${lastReadableSeq}`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const completeOffset = snapshot.startOffset + parsed.completeBytes;
|
|
1450
|
+
const nextCursor = {
|
|
1451
|
+
identity: snapshot.identity,
|
|
1452
|
+
offset: completeOffset,
|
|
1453
|
+
sequence: lastReadableSeq,
|
|
1454
|
+
digest: parsed.digest,
|
|
1455
|
+
reusable: true,
|
|
1456
|
+
};
|
|
1457
|
+
const entries = parsed.entries.filter((entry) => entry.seq > afterSeq);
|
|
1458
|
+
return {
|
|
1459
|
+
cursor: nextCursor,
|
|
1460
|
+
value: freezeReplayReadResult(entries, lastReadableSeq),
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function readReplaySnapshot({
|
|
1465
|
+
sessionsDir,
|
|
1466
|
+
path,
|
|
1467
|
+
afterSeq,
|
|
1468
|
+
cursor,
|
|
1469
|
+
forceFullRead,
|
|
1470
|
+
fs,
|
|
1471
|
+
}) {
|
|
1472
|
+
try {
|
|
1473
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1474
|
+
} catch (cause) {
|
|
1475
|
+
if (cause?.code === 'ENOENT') return { absent: true };
|
|
1476
|
+
throw cause;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
let pathStat;
|
|
1480
|
+
try {
|
|
1481
|
+
pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
|
|
1482
|
+
} catch (cause) {
|
|
1483
|
+
if (cause?.code === 'ENOENT') return { absent: true };
|
|
1484
|
+
throw cause;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
let handle;
|
|
1488
|
+
try {
|
|
1489
|
+
handle = await fs.open(
|
|
1490
|
+
path,
|
|
1491
|
+
constants.O_RDONLY |
|
|
1492
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
1493
|
+
(constants.O_NONBLOCK ?? 0),
|
|
1494
|
+
);
|
|
1495
|
+
} catch (cause) {
|
|
1496
|
+
if (cause?.code === 'ENOENT') {
|
|
1497
|
+
throw new ReplaySnapshotChangedError(
|
|
1498
|
+
'replay stream disappeared while opening its snapshot',
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
throw cause;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
try {
|
|
1505
|
+
const openedStat = await handle.stat();
|
|
1506
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1507
|
+
if (!sameFileIdentity(pathStat, openedStat)) {
|
|
1508
|
+
throw new ReplaySnapshotChangedError(
|
|
1509
|
+
'replay stream was replaced while opening its snapshot',
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
const snapshotLength = requireReplayFileSize(openedStat.size);
|
|
1513
|
+
const identity = replayFileIdentity(openedStat);
|
|
1514
|
+
const cursorBoundaryChanged =
|
|
1515
|
+
cursor !== undefined &&
|
|
1516
|
+
(!sameReplayIdentity(cursor.identity, identity) ||
|
|
1517
|
+
snapshotLength < cursor.offset);
|
|
1518
|
+
if (cursorBoundaryChanged) cursor.reusable = false;
|
|
1519
|
+
const incremental =
|
|
1520
|
+
!forceFullRead &&
|
|
1521
|
+
cursor !== undefined &&
|
|
1522
|
+
cursor.reusable &&
|
|
1523
|
+
sameReplayIdentity(cursor.identity, identity) &&
|
|
1524
|
+
snapshotLength >= cursor.offset &&
|
|
1525
|
+
afterSeq >= cursor.sequence;
|
|
1526
|
+
const startOffset = incremental ? cursor.offset : 0;
|
|
1527
|
+
const bytes = await readReplayRange(handle, startOffset, snapshotLength);
|
|
1528
|
+
|
|
1529
|
+
const finalHandleStat = await handle.stat();
|
|
1530
|
+
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
|
|
1531
|
+
if (
|
|
1532
|
+
!sameFileIdentity(openedStat, finalHandleStat) ||
|
|
1533
|
+
requireReplayFileSize(finalHandleStat.size) < snapshotLength
|
|
1534
|
+
) {
|
|
1535
|
+
throw new ReplaySnapshotChangedError(
|
|
1536
|
+
'replay stream was truncated within its pinned snapshot',
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
let finalPathStat;
|
|
1541
|
+
try {
|
|
1542
|
+
finalPathStat = await assertPrivateRegularPath(
|
|
1543
|
+
path,
|
|
1544
|
+
0o600,
|
|
1545
|
+
fs,
|
|
1546
|
+
'replay stream',
|
|
1547
|
+
);
|
|
1548
|
+
} catch (cause) {
|
|
1549
|
+
if (cause?.code === 'ENOENT') {
|
|
1550
|
+
throw new ReplaySnapshotChangedError(
|
|
1551
|
+
'replay stream disappeared within its pinned snapshot',
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
throw cause;
|
|
1555
|
+
}
|
|
1556
|
+
if (!sameFileIdentity(openedStat, finalPathStat)) {
|
|
1557
|
+
throw new ReplaySnapshotChangedError(
|
|
1558
|
+
'replay stream was replaced within its pinned snapshot',
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
absent: false,
|
|
1565
|
+
bytes,
|
|
1566
|
+
identity,
|
|
1567
|
+
incremental,
|
|
1568
|
+
startOffset,
|
|
1569
|
+
};
|
|
1570
|
+
} finally {
|
|
1571
|
+
await handle.close();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
async function readReplayRange(handle, start, end) {
|
|
1576
|
+
const buffer = Buffer.allocUnsafe(end - start);
|
|
1577
|
+
let offset = 0;
|
|
1578
|
+
while (offset < buffer.length) {
|
|
1579
|
+
const length = Math.min(REPLAY_READ_CHUNK_SIZE, buffer.length - offset);
|
|
1580
|
+
const result = await handle.read(buffer, offset, length, start + offset);
|
|
1581
|
+
if (
|
|
1582
|
+
result === null ||
|
|
1583
|
+
typeof result !== 'object' ||
|
|
1584
|
+
!Number.isSafeInteger(result.bytesRead) ||
|
|
1585
|
+
result.bytesRead <= 0 ||
|
|
1586
|
+
result.bytesRead > length
|
|
1587
|
+
) {
|
|
1588
|
+
throw new ReplaySnapshotChangedError(
|
|
1589
|
+
'replay stream ended before its pinned snapshot boundary',
|
|
1590
|
+
);
|
|
1591
|
+
}
|
|
1592
|
+
offset += result.bytesRead;
|
|
1593
|
+
}
|
|
1594
|
+
return buffer;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function writeReplayRange(handle, bytes, start) {
|
|
1598
|
+
let offset = 0;
|
|
1599
|
+
while (offset < bytes.length) {
|
|
1600
|
+
const result = await handle.write(
|
|
1601
|
+
bytes,
|
|
1602
|
+
offset,
|
|
1603
|
+
bytes.length - offset,
|
|
1604
|
+
start + offset,
|
|
1605
|
+
);
|
|
1606
|
+
if (
|
|
1607
|
+
result === null ||
|
|
1608
|
+
typeof result !== 'object' ||
|
|
1609
|
+
!Number.isSafeInteger(result.bytesWritten) ||
|
|
1610
|
+
result.bytesWritten <= 0 ||
|
|
1611
|
+
result.bytesWritten > bytes.length - offset
|
|
1612
|
+
) {
|
|
1613
|
+
throw new Error('replay stream append did not write its complete bytes');
|
|
1614
|
+
}
|
|
1615
|
+
offset += result.bytesWritten;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async function readReplayWriterSnapshot({
|
|
1620
|
+
sessionsDir,
|
|
1621
|
+
path,
|
|
1622
|
+
identity,
|
|
1623
|
+
fs,
|
|
1624
|
+
}) {
|
|
1625
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1626
|
+
let pathStat;
|
|
1627
|
+
try {
|
|
1628
|
+
pathStat = await assertPrivateRegularPath(path, 0o600, fs, 'replay stream');
|
|
1629
|
+
} catch (cause) {
|
|
1630
|
+
if (cause?.code === 'ENOENT' && identity === undefined) {
|
|
1631
|
+
return { absent: true };
|
|
1632
|
+
}
|
|
1633
|
+
throw cause;
|
|
1634
|
+
}
|
|
1635
|
+
let handle;
|
|
1636
|
+
try {
|
|
1637
|
+
handle = await fs.open(
|
|
1638
|
+
path,
|
|
1639
|
+
constants.O_RDONLY |
|
|
1640
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
1641
|
+
(constants.O_NONBLOCK ?? 0),
|
|
1642
|
+
);
|
|
1643
|
+
const openedStat = await handle.stat();
|
|
1644
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1645
|
+
if (
|
|
1646
|
+
!sameFileIdentity(pathStat, openedStat) ||
|
|
1647
|
+
(identity !== undefined && !sameReplayIdentity(identity, openedStat))
|
|
1648
|
+
) {
|
|
1649
|
+
throw new Error('replay stream identity changed for its writer');
|
|
1650
|
+
}
|
|
1651
|
+
const size = requireReplayFileSize(openedStat.size);
|
|
1652
|
+
const bytes = await readReplayRange(handle, 0, size);
|
|
1653
|
+
const finalHandleStat = await handle.stat();
|
|
1654
|
+
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
|
|
1655
|
+
if (
|
|
1656
|
+
!sameFileIdentity(openedStat, finalHandleStat) ||
|
|
1657
|
+
requireReplayFileSize(finalHandleStat.size) !== size
|
|
1658
|
+
) {
|
|
1659
|
+
throw new Error('replay stream changed during writer validation');
|
|
1660
|
+
}
|
|
1661
|
+
const finalPathStat = await assertPrivateRegularPath(
|
|
1662
|
+
path,
|
|
1663
|
+
0o600,
|
|
1664
|
+
fs,
|
|
1665
|
+
'replay stream',
|
|
1666
|
+
);
|
|
1667
|
+
if (!sameFileIdentity(openedStat, finalPathStat)) {
|
|
1668
|
+
throw new Error('replay stream path changed during writer validation');
|
|
1669
|
+
}
|
|
1670
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1671
|
+
return {
|
|
1672
|
+
absent: false,
|
|
1673
|
+
bytes,
|
|
1674
|
+
identity: replayFileIdentity(openedStat),
|
|
1675
|
+
};
|
|
1676
|
+
} finally {
|
|
1677
|
+
await handle?.close();
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function mutateReplayWriterFile({
|
|
1682
|
+
sessionsDir,
|
|
1683
|
+
path,
|
|
1684
|
+
identity,
|
|
1685
|
+
expectedSize,
|
|
1686
|
+
expectedFinalSize,
|
|
1687
|
+
fs,
|
|
1688
|
+
retainHandle = false,
|
|
1689
|
+
operation,
|
|
1690
|
+
}) {
|
|
1691
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1692
|
+
const pathStat = await assertPrivateRegularPath(
|
|
1693
|
+
path,
|
|
1694
|
+
0o600,
|
|
1695
|
+
fs,
|
|
1696
|
+
'replay stream',
|
|
1697
|
+
);
|
|
1698
|
+
if (!sameReplayIdentity(identity, pathStat)) {
|
|
1699
|
+
throw new Error('replay stream identity changed for its writer');
|
|
1700
|
+
}
|
|
1701
|
+
let handle;
|
|
1702
|
+
try {
|
|
1703
|
+
handle = await fs.open(
|
|
1704
|
+
path,
|
|
1705
|
+
constants.O_RDWR |
|
|
1706
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
1707
|
+
(constants.O_NONBLOCK ?? 0),
|
|
1708
|
+
);
|
|
1709
|
+
const openedStat = await handle.stat();
|
|
1710
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1711
|
+
if (
|
|
1712
|
+
!sameFileIdentity(pathStat, openedStat) ||
|
|
1713
|
+
!sameReplayIdentity(identity, openedStat) ||
|
|
1714
|
+
requireReplayFileSize(openedStat.size) !== expectedSize
|
|
1715
|
+
) {
|
|
1716
|
+
throw new Error('replay stream changed before writer mutation');
|
|
1717
|
+
}
|
|
1718
|
+
await operation(handle);
|
|
1719
|
+
const finalStat = await handle.stat();
|
|
1720
|
+
assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
|
|
1721
|
+
if (
|
|
1722
|
+
!sameFileIdentity(openedStat, finalStat) ||
|
|
1723
|
+
(expectedFinalSize !== undefined &&
|
|
1724
|
+
requireReplayFileSize(finalStat.size) !== expectedFinalSize)
|
|
1725
|
+
) {
|
|
1726
|
+
throw new Error('replay stream changed during writer mutation');
|
|
1727
|
+
}
|
|
1728
|
+
const finalPathStat = await assertPrivateRegularPath(
|
|
1729
|
+
path,
|
|
1730
|
+
0o600,
|
|
1731
|
+
fs,
|
|
1732
|
+
'replay stream',
|
|
1733
|
+
);
|
|
1734
|
+
if (!sameFileIdentity(openedStat, finalPathStat)) {
|
|
1735
|
+
throw new Error('replay stream path changed during writer mutation');
|
|
1736
|
+
}
|
|
1737
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1738
|
+
if (retainHandle) {
|
|
1739
|
+
const retained = handle;
|
|
1740
|
+
handle = undefined;
|
|
1741
|
+
return retained;
|
|
1742
|
+
}
|
|
1743
|
+
} finally {
|
|
1744
|
+
await handle?.close();
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function appendReplayWriterFile({
|
|
1749
|
+
handle,
|
|
1750
|
+
identity,
|
|
1751
|
+
expectedSize,
|
|
1752
|
+
bytes,
|
|
1753
|
+
}) {
|
|
1754
|
+
const openedStat = await handle.stat();
|
|
1755
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1756
|
+
if (
|
|
1757
|
+
!sameReplayIdentity(identity, openedStat) ||
|
|
1758
|
+
requireReplayFileSize(openedStat.size) !== expectedSize
|
|
1759
|
+
) {
|
|
1760
|
+
throw new Error('replay stream changed before writer append');
|
|
1761
|
+
}
|
|
1762
|
+
await writeReplayRange(handle, bytes, expectedSize);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async function checkpointReplayWriterFile({
|
|
1766
|
+
sessionsDir,
|
|
1767
|
+
path,
|
|
1768
|
+
handle,
|
|
1769
|
+
identity,
|
|
1770
|
+
expectedSize,
|
|
1771
|
+
fs,
|
|
1772
|
+
}) {
|
|
1773
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1774
|
+
const pathStat = await assertPrivateRegularPath(
|
|
1775
|
+
path,
|
|
1776
|
+
0o600,
|
|
1777
|
+
fs,
|
|
1778
|
+
'replay stream',
|
|
1779
|
+
);
|
|
1780
|
+
const openedStat = await handle.stat();
|
|
1781
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1782
|
+
if (
|
|
1783
|
+
!sameFileIdentity(pathStat, openedStat) ||
|
|
1784
|
+
!sameReplayIdentity(identity, openedStat) ||
|
|
1785
|
+
requireReplayFileSize(openedStat.size) !== expectedSize
|
|
1786
|
+
) {
|
|
1787
|
+
throw new Error('replay stream changed before writer checkpoint');
|
|
1788
|
+
}
|
|
1789
|
+
await handle.sync();
|
|
1790
|
+
const finalStat = await handle.stat();
|
|
1791
|
+
assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
|
|
1792
|
+
if (
|
|
1793
|
+
!sameFileIdentity(openedStat, finalStat) ||
|
|
1794
|
+
requireReplayFileSize(finalStat.size) !== expectedSize
|
|
1795
|
+
) {
|
|
1796
|
+
throw new Error('replay stream changed during writer checkpoint');
|
|
1797
|
+
}
|
|
1798
|
+
const finalPathStat = await assertPrivateRegularPath(
|
|
1799
|
+
path,
|
|
1800
|
+
0o600,
|
|
1801
|
+
fs,
|
|
1802
|
+
'replay stream',
|
|
1803
|
+
);
|
|
1804
|
+
if (!sameFileIdentity(openedStat, finalPathStat)) {
|
|
1805
|
+
throw new Error('replay stream path changed during writer checkpoint');
|
|
1806
|
+
}
|
|
1807
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async function publishReplayWriterFile({ sessionsDir, path, bytes, fs }) {
|
|
1811
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1812
|
+
let handle;
|
|
1813
|
+
try {
|
|
1814
|
+
handle = await fs.open(
|
|
1815
|
+
path,
|
|
1816
|
+
constants.O_RDWR |
|
|
1817
|
+
constants.O_CREAT |
|
|
1818
|
+
constants.O_EXCL |
|
|
1819
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
1820
|
+
(constants.O_NONBLOCK ?? 0),
|
|
1821
|
+
0o600,
|
|
1822
|
+
);
|
|
1823
|
+
await handle.chmod(0o600);
|
|
1824
|
+
const openedStat = await handle.stat();
|
|
1825
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1826
|
+
if (requireReplayFileSize(openedStat.size) !== 0) {
|
|
1827
|
+
throw new Error('new replay stream is not empty');
|
|
719
1828
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1829
|
+
await writeReplayRange(handle, bytes, 0);
|
|
1830
|
+
const finalStat = await handle.stat();
|
|
1831
|
+
assertPrivateRegularStat(finalStat, 0o600, 'replay stream');
|
|
1832
|
+
if (
|
|
1833
|
+
!sameFileIdentity(openedStat, finalStat) ||
|
|
1834
|
+
requireReplayFileSize(finalStat.size) !== bytes.length
|
|
1835
|
+
) {
|
|
1836
|
+
throw new Error('new replay stream did not retain its complete append');
|
|
727
1837
|
}
|
|
728
|
-
await
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const publishLeaseStage = async (sessionId, stage, onRenamed) => {
|
|
733
|
-
const canonicalPath = leasePathFor(sessionId);
|
|
734
|
-
await validateRetiredLeases(sessionId);
|
|
735
|
-
await assertPathMissing(
|
|
736
|
-
canonicalPath,
|
|
1838
|
+
const pathStat = await assertPrivateRegularPath(
|
|
1839
|
+
path,
|
|
1840
|
+
0o600,
|
|
737
1841
|
fs,
|
|
738
|
-
'
|
|
1842
|
+
'replay stream',
|
|
739
1843
|
);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// racing rename cannot replace one; it fails closed instead. Static empty
|
|
743
|
-
// or malformed destinations are rejected by the preflight above.
|
|
744
|
-
await fs.rename(stage.stagePath, canonicalPath);
|
|
745
|
-
onRenamed();
|
|
746
|
-
} catch (cause) {
|
|
747
|
-
throw new Error(
|
|
748
|
-
`Captain session lease publication lost its race: ${errorMessage(cause)}`,
|
|
749
|
-
);
|
|
1844
|
+
if (!sameFileIdentity(openedStat, pathStat)) {
|
|
1845
|
+
throw new Error('new replay stream path changed during publication');
|
|
750
1846
|
}
|
|
751
|
-
await
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1847
|
+
await assertPrivateDirectory(sessionsDir, fs);
|
|
1848
|
+
const retained = handle;
|
|
1849
|
+
handle = undefined;
|
|
1850
|
+
return {
|
|
1851
|
+
handle: retained,
|
|
1852
|
+
identity: replayFileIdentity(openedStat),
|
|
1853
|
+
};
|
|
1854
|
+
} finally {
|
|
1855
|
+
await handle?.close();
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function parseReplayLines(bytes, initialSequence, initialDigest, observedSeq) {
|
|
1860
|
+
const entries = [];
|
|
1861
|
+
let sequence = initialSequence;
|
|
1862
|
+
let digest = initialDigest;
|
|
1863
|
+
let observedDigest = observedSeq === 0 ? initialDigest : undefined;
|
|
1864
|
+
let lineStart = 0;
|
|
1865
|
+
|
|
1866
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
1867
|
+
if (bytes[index] !== 0x0a) continue;
|
|
1868
|
+
const line = bytes.subarray(lineStart, index);
|
|
1869
|
+
const entry = parseReplayEnvelope(line, sequence + 1);
|
|
1870
|
+
sequence = entry.seq;
|
|
1871
|
+
digest = digestReplayEntry(digest, entry);
|
|
1872
|
+
if (sequence === observedSeq) observedDigest = digest;
|
|
1873
|
+
entries.push(entry);
|
|
1874
|
+
lineStart = index + 1;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
return {
|
|
1878
|
+
completeBytes: lineStart,
|
|
1879
|
+
digest,
|
|
1880
|
+
entries,
|
|
1881
|
+
observedDigest,
|
|
1882
|
+
sequence,
|
|
757
1883
|
};
|
|
1884
|
+
}
|
|
758
1885
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
existing = await readLeaseOwner(sessionId);
|
|
769
|
-
} catch (cause) {
|
|
770
|
-
if (cause?.code !== 'ENOENT') throw cause;
|
|
771
|
-
}
|
|
1886
|
+
function parseReplayEnvelope(bytes, expectedSequence) {
|
|
1887
|
+
let text;
|
|
1888
|
+
try {
|
|
1889
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
1890
|
+
} catch (cause) {
|
|
1891
|
+
throw new Error(
|
|
1892
|
+
`replay stream sequence ${expectedSequence} is not valid UTF-8: ${errorMessage(cause)}`,
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
772
1895
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1896
|
+
let parsed;
|
|
1897
|
+
try {
|
|
1898
|
+
parsed = JSON.parse(text);
|
|
1899
|
+
} catch (cause) {
|
|
1900
|
+
throw new Error(
|
|
1901
|
+
`replay stream sequence ${expectedSequence} is not valid JSON: ${errorMessage(cause)}`,
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
const envelope = requireRecord(
|
|
1905
|
+
snapshotJsonValue(parsed, `replay stream sequence ${expectedSequence}`),
|
|
1906
|
+
`replay stream sequence ${expectedSequence}`,
|
|
1907
|
+
);
|
|
1908
|
+
exactOptionalKeys(
|
|
1909
|
+
envelope,
|
|
1910
|
+
['v', 'seq', 'record'],
|
|
1911
|
+
['role'],
|
|
1912
|
+
`replay stream sequence ${expectedSequence}`,
|
|
1913
|
+
);
|
|
1914
|
+
if (envelope.v !== RECORDS_STREAM_VERSION) {
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
`replay stream sequence ${expectedSequence} version must be ${RECORDS_STREAM_VERSION}`,
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
if (!Number.isSafeInteger(envelope.seq) || envelope.seq <= 0) {
|
|
1920
|
+
throw new Error(
|
|
1921
|
+
`replay stream sequence ${expectedSequence} seq must be a positive safe integer`,
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
if (envelope.seq !== expectedSequence) {
|
|
1925
|
+
throw new Error(
|
|
1926
|
+
`replay stream expected sequence ${expectedSequence}, received ${envelope.seq}`,
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
if (envelope.role !== undefined && typeof envelope.role !== 'string') {
|
|
1930
|
+
throw new Error(
|
|
1931
|
+
`replay stream sequence ${expectedSequence} role must be a string`,
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
requireRecord(
|
|
1935
|
+
envelope.record,
|
|
1936
|
+
`replay stream sequence ${expectedSequence} record`,
|
|
1937
|
+
);
|
|
1938
|
+
return envelope;
|
|
1939
|
+
}
|
|
802
1940
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1941
|
+
function validateReplayReadOptions(options) {
|
|
1942
|
+
if (
|
|
1943
|
+
options === undefined ||
|
|
1944
|
+
isExactUndefinedReplayReadOption(options)
|
|
1945
|
+
) {
|
|
1946
|
+
return 0;
|
|
1947
|
+
}
|
|
1948
|
+
const value = requireRecord(
|
|
1949
|
+
snapshotJsonValue(options, 'replay stream read options'),
|
|
1950
|
+
'replay stream read options',
|
|
1951
|
+
);
|
|
1952
|
+
exactOptionalKeys(value, [], ['afterSeq'], 'replay stream read options');
|
|
1953
|
+
const afterSeq = Object.hasOwn(value, 'afterSeq') ? value.afterSeq : 0;
|
|
1954
|
+
if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) {
|
|
1955
|
+
throw new Error(
|
|
1956
|
+
'replay stream read options afterSeq must be a nonnegative safe integer',
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1959
|
+
return afterSeq;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function isExactUndefinedReplayReadOption(options) {
|
|
1963
|
+
if (
|
|
1964
|
+
options === null ||
|
|
1965
|
+
typeof options !== 'object' ||
|
|
1966
|
+
Array.isArray(options)
|
|
1967
|
+
) {
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
const prototype = Object.getPrototypeOf(options);
|
|
1971
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
1972
|
+
const descriptors = Object.getOwnPropertyDescriptors(options);
|
|
1973
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
1974
|
+
if (keys.length !== 1 || keys[0] !== 'afterSeq') return false;
|
|
1975
|
+
const descriptor = descriptors.afterSeq;
|
|
1976
|
+
return (
|
|
1977
|
+
descriptor.enumerable &&
|
|
1978
|
+
Object.hasOwn(descriptor, 'value') &&
|
|
1979
|
+
descriptor.value === undefined
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function freezeReplayReadResult(entries, lastReadableSeq) {
|
|
1984
|
+
return Object.freeze({
|
|
1985
|
+
entries: Object.freeze(entries),
|
|
1986
|
+
lastReadableSeq,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function projectPlaybookSessionSummary(record) {
|
|
1991
|
+
return Object.freeze({
|
|
1992
|
+
schemaVersion: record.schemaVersion,
|
|
1993
|
+
sessionId: record.sessionId,
|
|
1994
|
+
state: record.state,
|
|
1995
|
+
cwd: record.cwd,
|
|
1996
|
+
updatedAt: record.updatedAt,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function freezeReplayLeaseReadResult(
|
|
2001
|
+
entries,
|
|
2002
|
+
lastReadableSeq,
|
|
2003
|
+
lastDurableSeq,
|
|
2004
|
+
incomplete,
|
|
2005
|
+
) {
|
|
2006
|
+
return Object.freeze({
|
|
2007
|
+
entries: Object.freeze(entries),
|
|
2008
|
+
lastReadableSeq,
|
|
2009
|
+
lastDurableSeq,
|
|
2010
|
+
incomplete,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function replayFileIdentity(stat) {
|
|
2015
|
+
return Object.freeze({ dev: stat.dev, ino: stat.ino });
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function sameFileIdentity(left, right) {
|
|
2019
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function sameReplayIdentity(left, right) {
|
|
2023
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function requireReplayFileSize(value) {
|
|
2027
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
2028
|
+
throw new Error('replay stream size must be a nonnegative safe integer');
|
|
2029
|
+
}
|
|
2030
|
+
return value;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function assertPrivateRegularStat(stat, mode, label) {
|
|
2034
|
+
if (!stat.isFile()) {
|
|
2035
|
+
throw new Error(`${label} path is not a regular file`);
|
|
2036
|
+
}
|
|
2037
|
+
if ((stat.mode & 0o7777) !== mode) {
|
|
2038
|
+
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function digestReplayEntry(previous, entry) {
|
|
2043
|
+
return createHash('sha256')
|
|
2044
|
+
.update(previous)
|
|
2045
|
+
.update('\u0000')
|
|
2046
|
+
.update(canonicalReplayJson(entry))
|
|
2047
|
+
.digest('hex');
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function canonicalReplayJson(value) {
|
|
2051
|
+
if (Array.isArray(value)) {
|
|
2052
|
+
return `[${value.map(canonicalReplayJson).join(',')}]`;
|
|
2053
|
+
}
|
|
2054
|
+
if (value !== null && typeof value === 'object') {
|
|
2055
|
+
return `{${Object.keys(value)
|
|
2056
|
+
.sort()
|
|
2057
|
+
.map(
|
|
2058
|
+
(key) =>
|
|
2059
|
+
`${JSON.stringify(key)}:${canonicalReplayJson(value[key])}`,
|
|
2060
|
+
)
|
|
2061
|
+
.join(',')}}`;
|
|
2062
|
+
}
|
|
2063
|
+
return JSON.stringify(value);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function sanitizeReplayValue(value, path, ancestors) {
|
|
2067
|
+
if (
|
|
2068
|
+
value === null ||
|
|
2069
|
+
typeof value === 'string' ||
|
|
2070
|
+
typeof value === 'boolean'
|
|
2071
|
+
) {
|
|
2072
|
+
return value;
|
|
2073
|
+
}
|
|
2074
|
+
if (typeof value === 'number') {
|
|
2075
|
+
if (!Number.isFinite(value)) {
|
|
2076
|
+
throw new TypeError(`${path} must contain a finite JSON number`);
|
|
2077
|
+
}
|
|
2078
|
+
if (Object.is(value, -0)) {
|
|
2079
|
+
throw new TypeError(`${path} must not contain negative zero`);
|
|
2080
|
+
}
|
|
2081
|
+
return value;
|
|
2082
|
+
}
|
|
2083
|
+
if (Array.isArray(value)) {
|
|
2084
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
|
2085
|
+
throw new TypeError(`${path} must be a plain JSON array`);
|
|
2086
|
+
}
|
|
2087
|
+
if (ancestors.has(value)) {
|
|
2088
|
+
throw new TypeError(`${path} must not contain a JSON cycle`);
|
|
2089
|
+
}
|
|
2090
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2091
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
2092
|
+
if (keys.some((key) => typeof key === 'symbol')) {
|
|
2093
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
2094
|
+
}
|
|
2095
|
+
const lengthDescriptor = descriptors.length;
|
|
2096
|
+
if (
|
|
2097
|
+
lengthDescriptor === undefined ||
|
|
2098
|
+
!Object.hasOwn(lengthDescriptor, 'value') ||
|
|
2099
|
+
!Number.isSafeInteger(lengthDescriptor.value) ||
|
|
2100
|
+
lengthDescriptor.value < 0
|
|
2101
|
+
) {
|
|
2102
|
+
throw new TypeError(`${path} must be a plain JSON array`);
|
|
2103
|
+
}
|
|
2104
|
+
const length = lengthDescriptor.value;
|
|
2105
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
2106
|
+
const copy = [];
|
|
2107
|
+
for (let index = 0; index < length; index += 1) {
|
|
2108
|
+
const descriptor = descriptors[String(index)];
|
|
2109
|
+
if (
|
|
2110
|
+
descriptor === undefined ||
|
|
2111
|
+
!descriptor.enumerable ||
|
|
2112
|
+
!Object.hasOwn(descriptor, 'value')
|
|
2113
|
+
) {
|
|
2114
|
+
throw new TypeError(`${path} must not be a sparse JSON array`);
|
|
839
2115
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
2116
|
+
copy.push(
|
|
2117
|
+
sanitizeReplayValue(
|
|
2118
|
+
descriptor.value,
|
|
2119
|
+
`${path}[${index}]`,
|
|
2120
|
+
nextAncestors,
|
|
2121
|
+
),
|
|
843
2122
|
);
|
|
844
2123
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
2124
|
+
const extra = keys.find(
|
|
2125
|
+
(key) =>
|
|
2126
|
+
typeof key === 'string' &&
|
|
2127
|
+
key !== 'length' &&
|
|
2128
|
+
(!Number.isSafeInteger(Number(key)) ||
|
|
2129
|
+
Number(key) < 0 ||
|
|
2130
|
+
Number(key) >= length ||
|
|
2131
|
+
String(Number(key)) !== key),
|
|
2132
|
+
);
|
|
2133
|
+
if (extra !== undefined) {
|
|
2134
|
+
throw new TypeError(`${path}.${extra} is not a JSON array index`);
|
|
2135
|
+
}
|
|
2136
|
+
return Object.freeze(copy);
|
|
2137
|
+
}
|
|
2138
|
+
if (value === null || typeof value !== 'object') {
|
|
2139
|
+
throw new TypeError(`${path} must be a JSON value`);
|
|
2140
|
+
}
|
|
2141
|
+
const prototype = Object.getPrototypeOf(value);
|
|
2142
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
2143
|
+
throw new TypeError(`${path} must be a JSON value`);
|
|
2144
|
+
}
|
|
2145
|
+
if (ancestors.has(value)) {
|
|
2146
|
+
throw new TypeError(`${path} must not contain a JSON cycle`);
|
|
2147
|
+
}
|
|
2148
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
2149
|
+
const keys = Reflect.ownKeys(descriptors);
|
|
2150
|
+
if (keys.some((key) => typeof key === 'symbol')) {
|
|
2151
|
+
throw new TypeError(`${path} must not contain symbol-keyed properties`);
|
|
2152
|
+
}
|
|
2153
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
2154
|
+
const copy = {};
|
|
2155
|
+
for (const key of keys) {
|
|
2156
|
+
if (typeof key !== 'string') continue;
|
|
2157
|
+
if (key === 'resumeToken') continue;
|
|
2158
|
+
const descriptor = descriptors[key];
|
|
2159
|
+
if (
|
|
2160
|
+
descriptor === undefined ||
|
|
2161
|
+
!descriptor.enumerable ||
|
|
2162
|
+
!Object.hasOwn(descriptor, 'value')
|
|
2163
|
+
) {
|
|
2164
|
+
throw new TypeError(
|
|
2165
|
+
`${path}.${key} must be an enumerable JSON data property`,
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
if (key === 'resume' && typeof descriptor.value === 'string') continue;
|
|
2169
|
+
if (descriptor.value === undefined) continue;
|
|
2170
|
+
Object.defineProperty(copy, key, {
|
|
2171
|
+
value: sanitizeReplayValue(
|
|
2172
|
+
descriptor.value,
|
|
2173
|
+
`${path}.${key}`,
|
|
2174
|
+
nextAncestors,
|
|
2175
|
+
),
|
|
2176
|
+
enumerable: true,
|
|
2177
|
+
configurable: true,
|
|
2178
|
+
writable: true,
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
return Object.freeze(copy);
|
|
848
2182
|
}
|
|
849
2183
|
|
|
850
|
-
function createLease({
|
|
2184
|
+
async function createLease({
|
|
851
2185
|
sessionId,
|
|
852
2186
|
owner,
|
|
2187
|
+
sessionsDir,
|
|
2188
|
+
replayPath,
|
|
2189
|
+
replayFs,
|
|
2190
|
+
readReplayStream,
|
|
853
2191
|
readRecord,
|
|
854
2192
|
writeRecord,
|
|
855
2193
|
syncRecordDirectory,
|
|
@@ -893,6 +2231,19 @@ function createLease({
|
|
|
893
2231
|
|
|
894
2232
|
const assertOwner = () => runExclusive(assertOwnerUnchecked);
|
|
895
2233
|
|
|
2234
|
+
const replayWriter = await createLeaseReplayWriter({
|
|
2235
|
+
sessionsDir,
|
|
2236
|
+
path: replayPath,
|
|
2237
|
+
fs: replayFs,
|
|
2238
|
+
assertOwner: assertOwnerUnchecked,
|
|
2239
|
+
readStream: readReplayStream,
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
const finishSettlement = async (record) => {
|
|
2243
|
+
await replayWriter.checkpoint();
|
|
2244
|
+
return record;
|
|
2245
|
+
};
|
|
2246
|
+
|
|
896
2247
|
const read = () =>
|
|
897
2248
|
runExclusive(async () => {
|
|
898
2249
|
await assertOwnerUnchecked();
|
|
@@ -1636,7 +2987,7 @@ function createLease({
|
|
|
1636
2987
|
await assertOwnerUnchecked();
|
|
1637
2988
|
await syncRecordDirectory();
|
|
1638
2989
|
await assertOwnerUnchecked();
|
|
1639
|
-
return current;
|
|
2990
|
+
return finishSettlement(current);
|
|
1640
2991
|
}
|
|
1641
2992
|
if (settledAbandonment.phase === 'final') {
|
|
1642
2993
|
throw new Error(
|
|
@@ -1666,7 +3017,7 @@ function createLease({
|
|
|
1666
3017
|
await assertOwnerUnchecked();
|
|
1667
3018
|
await writeRecord(record, { noReplace: false });
|
|
1668
3019
|
await assertOwnerUnchecked();
|
|
1669
|
-
return record;
|
|
3020
|
+
return finishSettlement(record);
|
|
1670
3021
|
}
|
|
1671
3022
|
if (Object.hasOwn(prior.uncertain, 'abandonment')) {
|
|
1672
3023
|
const abandonment = prior.uncertain.abandonment;
|
|
@@ -1724,7 +3075,7 @@ function createLease({
|
|
|
1724
3075
|
await assertOwnerUnchecked();
|
|
1725
3076
|
await writeRecord(record, { noReplace: false });
|
|
1726
3077
|
await assertOwnerUnchecked();
|
|
1727
|
-
return record;
|
|
3078
|
+
return finishSettlement(record);
|
|
1728
3079
|
});
|
|
1729
3080
|
|
|
1730
3081
|
const writeEffectLedger = (authorityValue, commandsValue) =>
|
|
@@ -1845,16 +3196,23 @@ function createLease({
|
|
|
1845
3196
|
return record;
|
|
1846
3197
|
});
|
|
1847
3198
|
|
|
1848
|
-
const release = () =>
|
|
1849
|
-
|
|
3199
|
+
const release = () => {
|
|
3200
|
+
replayWriter.closeAppendAdmission();
|
|
3201
|
+
return runExclusive(async () => {
|
|
3202
|
+
await replayWriter.prepareRelease();
|
|
1850
3203
|
const current = await assertOwnerUnchecked();
|
|
1851
3204
|
await retireObservedLease(sessionId, current);
|
|
1852
3205
|
released = true;
|
|
3206
|
+
return replayWriter.status();
|
|
1853
3207
|
});
|
|
3208
|
+
};
|
|
1854
3209
|
|
|
1855
3210
|
return Object.freeze({
|
|
1856
3211
|
sessionId,
|
|
1857
3212
|
ownerToken: owner.ownerToken,
|
|
3213
|
+
append: replayWriter.append,
|
|
3214
|
+
readStream: replayWriter.read,
|
|
3215
|
+
streamStatus: replayWriter.status,
|
|
1858
3216
|
read,
|
|
1859
3217
|
initializeSettledWithPredecessor,
|
|
1860
3218
|
abandonFreshSettled,
|
|
@@ -3463,7 +4821,9 @@ function validateCaptainSessionProjection(
|
|
|
3463
4821
|
structural
|
|
3464
4822
|
? ['id', 'adapter']
|
|
3465
4823
|
: ['id', 'adapter', 'model', 'effort'],
|
|
3466
|
-
|
|
4824
|
+
structural
|
|
4825
|
+
? ['instruction', 'permissions']
|
|
4826
|
+
: ['instruction', 'permissions', 'fastMode'],
|
|
3467
4827
|
playerPath,
|
|
3468
4828
|
);
|
|
3469
4829
|
assertPlayerId(player.id, `${playerPath}.id`);
|
|
@@ -3559,15 +4919,17 @@ function validateCaptainSessionProjection(
|
|
|
3559
4919
|
for (const roleId of requiredRoleIds) {
|
|
3560
4920
|
const bindingPath = `${itemPath}.roles.${roleId}`;
|
|
3561
4921
|
const binding = requireRecord(roles[roleId], bindingPath);
|
|
3562
|
-
|
|
4922
|
+
exactOptionalKeys(
|
|
3563
4923
|
binding,
|
|
3564
4924
|
structural
|
|
3565
4925
|
? ['playerId']
|
|
3566
4926
|
: ['playerId', 'model', 'effort'],
|
|
4927
|
+
structural ? [] : ['fastMode'],
|
|
3567
4928
|
bindingPath,
|
|
3568
4929
|
);
|
|
3569
4930
|
assertPlayerId(binding.playerId, `${bindingPath}.playerId`);
|
|
3570
4931
|
if (!structural) {
|
|
4932
|
+
validateProjectedFastMode(binding, `${bindingPath}.fastMode`);
|
|
3571
4933
|
validateTuningSelection(binding.model, `${bindingPath}.model`);
|
|
3572
4934
|
const player = projection.players.find(
|
|
3573
4935
|
(candidate) => candidate.id === binding.playerId,
|
|
@@ -3604,13 +4966,27 @@ function validateCaptainSessionProjection(
|
|
|
3604
4966
|
}
|
|
3605
4967
|
}
|
|
3606
4968
|
|
|
4969
|
+
// Absence is the canonical provider default, so the key stays optional and
|
|
4970
|
+
// the structural projection erases it beside model and effort.
|
|
4971
|
+
function validateProjectedFastMode(record, path) {
|
|
4972
|
+
if (
|
|
4973
|
+
Object.hasOwn(record, 'fastMode') &&
|
|
4974
|
+
typeof record.fastMode !== 'boolean'
|
|
4975
|
+
) {
|
|
4976
|
+
throw new Error(`${path} must be a boolean`);
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
|
|
3607
4980
|
function validateProjectedAgent(value, path, { structural, hasId = false }) {
|
|
3608
4981
|
const agent = requireRecord(value, path);
|
|
4982
|
+
if (!structural) validateProjectedFastMode(agent, `${path}.fastMode`);
|
|
3609
4983
|
if (!hasId) {
|
|
3610
4984
|
exactOptionalKeys(
|
|
3611
4985
|
agent,
|
|
3612
4986
|
structural ? ['adapter'] : ['adapter', 'model', 'effort'],
|
|
3613
|
-
|
|
4987
|
+
structural
|
|
4988
|
+
? ['instruction', 'permissions']
|
|
4989
|
+
: ['instruction', 'permissions', 'fastMode'],
|
|
3614
4990
|
path,
|
|
3615
4991
|
);
|
|
3616
4992
|
}
|