@sublang/playbook 8.0.0 → 10.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 (66) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +66 -23
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +45 -14
  5. package/package.json +7 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +151 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
  14. package/reference/sdlc/code.md +0 -1
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  17. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  18. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  19. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  20. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
  22. package/reference/sdlc/code.playbook/code.fsm.js +85 -29
  23. package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
  24. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  25. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  26. package/reference/sdlc/code.playbook/code.playbook.js +67 -4
  27. package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
  28. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  29. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  31. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  32. package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
  33. package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
  34. package/reference/sdlc/decide.md +0 -1
  35. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
  36. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  37. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  38. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  39. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
  40. package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
  41. package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
  42. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  43. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  44. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  47. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  48. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  49. package/reference/sdlc/review.playbook/review.playbook.js +78 -4
  50. package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
  51. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  52. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  53. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  54. package/slc/gears2fsm.md +25 -7
  55. package/slc/link.md +727 -82
  56. package/src/accepted-outcome.d.ts +18 -0
  57. package/src/accepted-outcome.js +94 -0
  58. package/src/accepted-outcome.ts +140 -0
  59. package/src/runtime.d.ts +165 -3
  60. package/src/runtime.ts +214 -2
  61. package/src/xstate-playbook-runtime.d.ts +162 -13
  62. package/src/xstate-playbook-runtime.js +3344 -564
  63. package/src/xstate-playbook-runtime.ts +4873 -637
  64. package/src/xstate-runtime.d.ts +76 -8
  65. package/src/xstate-runtime.js +1001 -64
  66. package/src/xstate-runtime.ts +1640 -91
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { constants, realpathSync } from 'node:fs';
11
- import { lstat, open, readFile, realpath, unlink } from 'node:fs/promises';
11
+ import { lstat, open, realpath, unlink } from 'node:fs/promises';
12
12
  import {
13
13
  basename,
14
14
  dirname,
@@ -30,6 +30,8 @@ import {
30
30
  CaptainSessionHostCleanupError,
31
31
  captainOptionsFromConfig,
32
32
  createCaptainSessionHost,
33
+ installRetainedGenerationsForLaunch,
34
+ reportSkippedCaptainSession,
33
35
  validateFrozenExecutionConfig,
34
36
  } from './run.js';
35
37
  import { prepareConfiguredRegistries } from './provision.js';
@@ -47,6 +49,8 @@ export const MANAGED_INTERACTIVE_PAYLOAD_FILE =
47
49
  export const MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION = 1;
48
50
  export const MANAGED_INTERACTIVE_PAYLOAD_KIND =
49
51
  'playbook-managed-interactive-launch';
52
+ export const MANAGED_INTERACTIVE_READINESS_WITNESS_FILE =
53
+ 'playbook-managed-readiness';
50
54
 
51
55
  const PAYLOAD_KEYS = [
52
56
  'schemaVersion',
@@ -156,6 +160,18 @@ export async function writeManagedInteractivePayload(workDir, value) {
156
160
  return path;
157
161
  }
158
162
 
163
+ export async function publishManagedInteractiveReadinessWitness(
164
+ workDir,
165
+ sessionId,
166
+ ) {
167
+ return writeManagedInteractiveReadinessClaim({
168
+ workDir,
169
+ sessionId,
170
+ claim: 'ready',
171
+ rejectExisting: true,
172
+ });
173
+ }
174
+
159
175
  export async function readManagedInteractivePayload(path) {
160
176
  assertCanonicalAbsolutePath(path, 'managed interactive launch descriptor');
161
177
  assertManagedDescriptorPath(path);
@@ -300,6 +316,7 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
300
316
  let leaseQuarantined = false;
301
317
  let activeTurn;
302
318
  let executionProjection;
319
+ let freshLaunchRecord;
303
320
 
304
321
  const release = async () => {
305
322
  if (released || lease === undefined) return;
@@ -309,6 +326,30 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
309
326
  released = true;
310
327
  };
311
328
 
329
+ const abandonFreshLaunchRecord = async ({ claim = false } = {}) => {
330
+ const record = freshLaunchRecord;
331
+ if (
332
+ record === undefined ||
333
+ lease === undefined ||
334
+ Object.keys(record.retainedGenerations ?? {}).length !== 0
335
+ ) {
336
+ return;
337
+ }
338
+ if (
339
+ claim &&
340
+ !(await writeManagedInteractiveReadinessClaim({
341
+ workDir: payload.workDir,
342
+ sessionId: payload.sessionId,
343
+ claim: 'abandon',
344
+ rejectExisting: false,
345
+ }))
346
+ ) {
347
+ return;
348
+ }
349
+ freshLaunchRecord = undefined;
350
+ await lease.abandonFreshSettled({ expected: record });
351
+ };
352
+
312
353
  return Object.freeze({
313
354
  async initializeRuntime(context) {
314
355
  if (initialized || lease !== undefined || released) {
@@ -328,7 +369,11 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
328
369
  let host;
329
370
  try {
330
371
  lease = await store.acquire(payload.sessionId);
331
- const authoritative = await lease.read();
372
+ const authoritative =
373
+ typeof lease.recoverUnresolvedEffectAbandonment === 'function'
374
+ ? await lease.recoverUnresolvedEffectAbandonment()
375
+ : await lease.read();
376
+ let retainedGenerations = {};
332
377
  let restoreSnapshot;
333
378
  const prepareRegistryModule =
334
379
  options.prepareRegistryModule ??
@@ -381,12 +426,14 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
381
426
  { loadModule, prepareRegistryModule },
382
427
  );
383
428
  restoreSnapshot = record.snapshot;
429
+ retainedGenerations = record.retainedGenerations ?? {};
384
430
  }
385
431
 
386
432
  const created = await createSessionHost({
387
433
  config: executionProjection,
388
434
  sessionId: payload.sessionId,
389
435
  cwd: payload.cwd,
436
+ sessionLease: lease,
390
437
  loadModule,
391
438
  observers: context.observers,
392
439
  ...(options.adapterImports
@@ -401,18 +448,49 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
401
448
  ...(options.createHostRuntime
402
449
  ? { createHostRuntime: options.createHostRuntime }
403
450
  : {}),
451
+ ...(options.createEffectLedgerWriteAhead
452
+ ? {
453
+ createEffectLedgerWriteAhead:
454
+ options.createEffectLedgerWriteAhead,
455
+ }
456
+ : {}),
404
457
  ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
405
458
  });
406
459
  ({ host, shell } = created);
407
- if (payload.mode === 'fresh') {
408
- await lease.initializeSettled({
409
- cwd: payload.cwd,
410
- structuralProjection:
411
- projectCaptainSessionStructure(executionProjection),
412
- executionProjection,
413
- snapshot: created.snapshot,
414
- });
415
- }
460
+ await installRetainedGenerationsForLaunch({
461
+ lease,
462
+ shell,
463
+ ...(payload.mode === 'fresh'
464
+ ? {
465
+ freshBoundary: {
466
+ cwd: payload.cwd,
467
+ structuralProjection:
468
+ projectCaptainSessionStructure(executionProjection),
469
+ executionProjection,
470
+ snapshot: created.snapshot,
471
+ },
472
+ onFreshRecord(record) {
473
+ freshLaunchRecord = record;
474
+ },
475
+ onLegacyRecord: (record) =>
476
+ reportSkippedCaptainSession(
477
+ options.stderr ?? process.stderr,
478
+ 'playbook',
479
+ 'legacy',
480
+ record,
481
+ ),
482
+ onInvalidRecord: (record) =>
483
+ reportSkippedCaptainSession(
484
+ options.stderr ?? process.stderr,
485
+ 'playbook',
486
+ 'invalid',
487
+ record,
488
+ ),
489
+ }
490
+ : {}),
491
+ retainedGenerations,
492
+ reconcileRepositoryEffects: created.reconcileRepositoryEffects,
493
+ });
416
494
  initialized = true;
417
495
  return {
418
496
  abortActiveTurn: (...args) => host.abortActiveTurn(...args),
@@ -445,6 +523,13 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
445
523
  failures.push(disposeError);
446
524
  }
447
525
  }
526
+ if (!leaseQuarantined) {
527
+ try {
528
+ await abandonFreshLaunchRecord();
529
+ } catch (abandonmentError) {
530
+ failures.push(abandonmentError);
531
+ }
532
+ }
448
533
  shell = undefined;
449
534
  if (!leaseQuarantined) {
450
535
  try {
@@ -503,25 +588,93 @@ export function createManagedInteractiveLifecycle(payloadValue, options = {}) {
503
588
  `managed interactive Captain turn produced ${replies.length} usable Boss-visible replies; expected exactly one`,
504
589
  );
505
590
  }
506
- const snapshot = shell?.exportSnapshot();
507
- if (snapshot === undefined) {
591
+ const settlement = shell?.exportSettlement();
592
+ if (settlement === undefined) {
508
593
  throw new Error(
509
- 'managed interactive Captain turn settled without an exportable session snapshot',
594
+ 'managed interactive Captain turn settled without an exportable session settlement',
510
595
  );
511
596
  }
512
- await lease.settle({ attemptId: activeTurn.attemptId, snapshot });
597
+ await lease.settle({
598
+ attemptId: activeTurn.attemptId,
599
+ snapshot: settlement.snapshot,
600
+ unresolvedEffects: settlement.unresolvedEffects,
601
+ retentionUpdates: settlement.retentionUpdates,
602
+ });
513
603
  activeTurn = undefined;
514
604
  },
515
605
 
516
- async shutdown() {
606
+ async shutdown(context) {
517
607
  // Cligent invokes lifecycle shutdown only after runtime disposal and the
518
608
  // complete turn transaction, so ownership retirement cannot race either.
519
609
  if (leaseQuarantined) return;
520
- await release();
610
+ const failures = [];
611
+ if (context !== undefined) {
612
+ try {
613
+ await abandonFreshLaunchRecord({ claim: true });
614
+ } catch (error) {
615
+ failures.push(error);
616
+ }
617
+ }
618
+ try {
619
+ await release();
620
+ } catch (error) {
621
+ failures.push(error);
622
+ }
623
+ if (failures.length === 1) throw failures[0];
624
+ if (failures.length > 1) {
625
+ throw new AggregateError(
626
+ failures,
627
+ 'managed interactive shutdown could not retract its unused fresh session and retire its lease',
628
+ );
629
+ }
521
630
  },
522
631
  });
523
632
  }
524
633
 
634
+ async function writeManagedInteractiveReadinessClaim({
635
+ workDir,
636
+ sessionId,
637
+ claim,
638
+ rejectExisting,
639
+ }) {
640
+ assertCanonicalAbsolutePath(
641
+ workDir,
642
+ 'managed interactive readiness work directory',
643
+ );
644
+ assertUuid(sessionId, 'managed interactive readiness session id');
645
+ const path = join(workDir, MANAGED_INTERACTIVE_READINESS_WITNESS_FILE);
646
+ let handle;
647
+ try {
648
+ handle = await open(path, 'wx', 0o600);
649
+ } catch (cause) {
650
+ if (cause?.code === 'EEXIST' && !rejectExisting) return false;
651
+ throw new Error(
652
+ `managed interactive readiness claim could not be created: ${message(cause)}`,
653
+ { cause },
654
+ );
655
+ }
656
+ try {
657
+ await handle.chmod(0o600);
658
+ const stat = await handle.stat();
659
+ if (!stat.isFile() || (stat.mode & 0o7777) !== 0o600) {
660
+ throw new Error(
661
+ 'managed interactive readiness claim is not a private regular file',
662
+ );
663
+ }
664
+ if (
665
+ typeof process.getuid === 'function' &&
666
+ stat.uid !== process.getuid()
667
+ ) {
668
+ throw new Error('managed interactive readiness claim has a foreign owner');
669
+ }
670
+ await handle.writeFile(`${claim} ${sessionId}\n`, 'utf8');
671
+ await handle.sync();
672
+ } finally {
673
+ await handle.close();
674
+ }
675
+ return true;
676
+ }
677
+
525
678
  export function parseManagedInteractiveChildArgs(argv) {
526
679
  if (argv.length !== 1) {
527
680
  throw new Error(
@@ -37,6 +37,7 @@ const DEFAULT_TEMPLATE_PATH = resolve(
37
37
  export const PLAYBOOK_CAPTAIN_MODULE =
38
38
  '@sublang/playbook/playbook-captain';
39
39
  const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'roles'];
40
+ const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
40
41
  const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
41
42
  'captain',
42
43
  'players',
@@ -291,6 +292,7 @@ export async function normalizeSelectedLaunchPlanDataOnly(
291
292
  if (hasOwn(block, 'players')) {
292
293
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
293
294
  }
295
+ rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
294
296
  if (
295
297
  typeof block.from !== 'string' ||
296
298
  block.from.trim().length === 0 ||
@@ -519,6 +521,19 @@ export function resolveAgent(value, path, reservedKeys = []) {
519
521
  throw new Error(`${path} must be an adapter shorthand or an agent block`);
520
522
  }
521
523
 
524
+ function rejectConfiguredHostCapabilities(value, path) {
525
+ if (
526
+ value !== null &&
527
+ typeof value === 'object' &&
528
+ !Array.isArray(value) &&
529
+ hasOwn(value, HOST_CAPABILITIES_OPTION_KEY)
530
+ ) {
531
+ throw new Error(
532
+ `${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`,
533
+ );
534
+ }
535
+ }
536
+
522
537
  // PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
523
538
  // only execution data and presentation data; imported registry functions are
524
539
  // consulted for validation and then discarded.
@@ -597,6 +612,7 @@ export async function normalizeLaunchPlan(
597
612
  if (hasOwn(block, 'players')) {
598
613
  throw legacyPlayersError(`playbooks.${id}.players`, configPath);
599
614
  }
615
+ rejectConfiguredHostCapabilities(block, `playbooks.${id}`);
600
616
  const from = block.from;
601
617
  if (
602
618
  typeof from !== 'string' ||
@@ -806,7 +822,7 @@ export async function normalizeLaunchPlan(
806
822
  `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
807
823
  );
808
824
  }
809
- const entry = mod?.default;
825
+ const entry = snapshotRegistryEntry(mod?.default);
810
826
  const registryProblem = invalidRegistryEntryReason(entry);
811
827
  if (registryProblem !== undefined) {
812
828
  throw new Error(
@@ -1252,7 +1268,26 @@ function assertNoRetiredProfiles(top, configPath) {
1252
1268
  }
1253
1269
  }
1254
1270
 
1255
- function invalidRegistryEntryReason(value) {
1271
+ // Imported manifests remain live JavaScript objects. Capture every member this
1272
+ // host consumes once, then validate and project only the detached record so a
1273
+ // getter or later mutation cannot make those two phases observe different
1274
+ // compatibility declarations or identities.
1275
+ export function snapshotRegistryEntry(value) {
1276
+ if (!isObject(value)) return value;
1277
+ return {
1278
+ id: value.id,
1279
+ command: value.command,
1280
+ intent: value.intent,
1281
+ artifactSchema: value.artifactSchema,
1282
+ runtimeProfile: value.runtimeProfile,
1283
+ requiredRoleIds: value.requiredRoleIds,
1284
+ concurrentRoleSets: value.concurrentRoleSets,
1285
+ validateOptions: value.validateOptions,
1286
+ createRuntime: value.createRuntime,
1287
+ };
1288
+ }
1289
+
1290
+ export function invalidRegistryEntryReason(value) {
1256
1291
  if (!isObject(value)) return 'the default export must be an object';
1257
1292
  if (
1258
1293
  typeof value.id !== 'string' ||
@@ -1269,9 +1304,14 @@ function invalidRegistryEntryReason(value) {
1269
1304
  return 'command must be a canonical trimmed nonblank string';
1270
1305
  }
1271
1306
  if (typeof value.intent !== 'string') return 'intent must be a string';
1272
- if (value.artifactSchema !== 2) {
1273
- return 'artifactSchema must be exactly 2';
1307
+ if (value.artifactSchema !== 3) {
1308
+ return 'artifactSchema must be 3';
1274
1309
  }
1310
+ const runtimeProfileProblem = invalidRuntimeProfileReason(
1311
+ value.runtimeProfile,
1312
+ value.artifactSchema,
1313
+ );
1314
+ if (runtimeProfileProblem !== undefined) return runtimeProfileProblem;
1275
1315
  const roleProblem = invalidManifestRoles(value.requiredRoleIds);
1276
1316
  if (roleProblem !== undefined) return `requiredRoleIds ${roleProblem}`;
1277
1317
  const concurrentProblem = invalidConcurrentRoleSets(
@@ -1290,6 +1330,89 @@ function invalidRegistryEntryReason(value) {
1290
1330
  return undefined;
1291
1331
  }
1292
1332
 
1333
+ function invalidRuntimeProfileReason(value, advertisedArtifactSchema) {
1334
+ if (!isPlainObject(value)) {
1335
+ return 'runtimeProfile must be a plain object';
1336
+ }
1337
+ const kindDescriptor = Object.getOwnPropertyDescriptor(value, 'kind');
1338
+ if (
1339
+ kindDescriptor === undefined ||
1340
+ kindDescriptor.get !== undefined ||
1341
+ kindDescriptor.set !== undefined ||
1342
+ kindDescriptor.enumerable !== true
1343
+ ) {
1344
+ return 'runtimeProfile.kind must be an enumerable data property';
1345
+ }
1346
+ const kind = kindDescriptor.value;
1347
+ if (kind === 'shared-factory') {
1348
+ const profile = exactPlainDataRecord(value, ['kind', 'compat']);
1349
+ if (profile === undefined) {
1350
+ return 'shared-factory runtimeProfile must contain exactly kind and compat data properties';
1351
+ }
1352
+ const compat = exactPlainDataRecord(profile.compat, [
1353
+ 'artifactSchema',
1354
+ 'runtimeAbi',
1355
+ ]);
1356
+ if (compat === undefined) {
1357
+ return 'shared-factory runtimeProfile.compat must contain exactly artifactSchema and runtimeAbi data properties';
1358
+ }
1359
+ if (!Number.isSafeInteger(compat.artifactSchema)) {
1360
+ return 'shared-factory runtimeProfile.compat.artifactSchema must be an integer';
1361
+ }
1362
+ if (!Number.isSafeInteger(compat.runtimeAbi)) {
1363
+ return 'shared-factory runtimeProfile.compat.runtimeAbi must be an integer';
1364
+ }
1365
+ if (compat.artifactSchema !== advertisedArtifactSchema) {
1366
+ return 'artifactSchema must match runtimeProfile.compat.artifactSchema';
1367
+ }
1368
+ return undefined;
1369
+ }
1370
+ if (kind === 'bespoke') {
1371
+ const profile = exactPlainDataRecord(value, ['kind', 'artifactSchema']);
1372
+ if (profile === undefined) {
1373
+ return 'bespoke runtimeProfile must contain exactly kind and artifactSchema data properties';
1374
+ }
1375
+ if (!Number.isSafeInteger(profile.artifactSchema)) {
1376
+ return 'bespoke runtimeProfile.artifactSchema must be an integer';
1377
+ }
1378
+ if (profile.artifactSchema !== advertisedArtifactSchema) {
1379
+ return 'artifactSchema must match runtimeProfile.artifactSchema';
1380
+ }
1381
+ return undefined;
1382
+ }
1383
+ return 'runtimeProfile.kind must be shared-factory or bespoke';
1384
+ }
1385
+
1386
+ function exactPlainDataRecord(value, expectedKeys) {
1387
+ if (!isPlainObject(value)) return undefined;
1388
+ const keys = Reflect.ownKeys(value);
1389
+ if (
1390
+ keys.length !== expectedKeys.length ||
1391
+ keys.some(
1392
+ (key) => typeof key !== 'string' || !expectedKeys.includes(key),
1393
+ )
1394
+ ) {
1395
+ return undefined;
1396
+ }
1397
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1398
+ if (
1399
+ expectedKeys.some((key) => {
1400
+ const descriptor = descriptors[key];
1401
+ return (
1402
+ descriptor === undefined ||
1403
+ descriptor.get !== undefined ||
1404
+ descriptor.set !== undefined ||
1405
+ descriptor.enumerable !== true
1406
+ );
1407
+ })
1408
+ ) {
1409
+ return undefined;
1410
+ }
1411
+ return Object.fromEntries(
1412
+ expectedKeys.map((key) => [key, descriptors[key].value]),
1413
+ );
1414
+ }
1415
+
1293
1416
  function validateStoredStructuralProjection(value) {
1294
1417
  const stored = cloneJson(value, 'stored structural projection');
1295
1418
  if (!isPlainObject(stored) || stored.schemaVersion !== 1) {
@@ -1340,6 +1463,15 @@ function validateStoredStructuralProjection(value) {
1340
1463
  ) {
1341
1464
  throw new Error(`stored structural catalog.${id} is malformed`);
1342
1465
  }
1466
+ if (item.artifactSchema !== 3) {
1467
+ throw new Error(
1468
+ `stored structural catalog.${id}.artifactSchema must be 3`,
1469
+ );
1470
+ }
1471
+ rejectConfiguredHostCapabilities(
1472
+ item.options,
1473
+ `stored structural catalog.${id}.options`,
1474
+ );
1343
1475
  if (
1344
1476
  JSON.stringify(Object.keys(item.roles)) !==
1345
1477
  JSON.stringify(item.requiredRoleIds)
@@ -34,6 +34,7 @@ import {
34
34
  createManagedInteractiveSessionCommand,
35
35
  MANAGED_INTERACTIVE_PAYLOAD_KIND,
36
36
  MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
37
+ publishManagedInteractiveReadinessWitness,
37
38
  } from './interactive-session.js';
38
39
  import { prepareConfiguredRegistries } from './provision.js';
39
40
  import {
@@ -115,6 +116,12 @@ export async function runPlaybookCli(options = {}) {
115
116
  ...(options.createHostRuntime
116
117
  ? { createHostRuntime: options.createHostRuntime }
117
118
  : {}),
119
+ ...(options.createEffectLedgerWriteAhead
120
+ ? {
121
+ createEffectLedgerWriteAhead:
122
+ options.createEffectLedgerWriteAhead,
123
+ }
124
+ : {}),
118
125
  ...(options.sessionStore
119
126
  ? { sessionStore: options.sessionStore }
120
127
  : {}),
@@ -126,6 +133,12 @@ export async function runPlaybookCli(options = {}) {
126
133
  ...(options.createAttemptId
127
134
  ? { createAttemptId: options.createAttemptId }
128
135
  : {}),
136
+ ...(options.installRetainedGenerationsForLaunch
137
+ ? {
138
+ installRetainedGenerationsForLaunch:
139
+ options.installRetainedGenerationsForLaunch,
140
+ }
141
+ : {}),
129
142
  ...(options.signal ? { signal: options.signal } : {}),
130
143
  // PBCLI-39: the run path gates on SDK availability too.
131
144
  ...(options.probeAdapterSdk
@@ -213,11 +226,45 @@ export async function runPlaybookCli(options = {}) {
213
226
  selectedRecord = validateCaptainSessionRecord(
214
227
  await store.read(interactiveArgs.sessionId),
215
228
  );
216
- if (selectedRecord.state !== 'settled') {
229
+ const needsAbandonmentRecovery =
230
+ (selectedRecord.state === 'uncertain' &&
231
+ Object.hasOwn(selectedRecord.uncertain, 'abandonment')) ||
232
+ (selectedRecord.state === 'settled' &&
233
+ Object.hasOwn(selectedRecord, 'settledAbandonment'));
234
+ if (selectedRecord.state !== 'settled' && !needsAbandonmentRecovery) {
217
235
  throw new Error(
218
236
  `Captain session ${JSON.stringify(interactiveArgs.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
219
237
  );
220
238
  }
239
+ if (needsAbandonmentRecovery) {
240
+ const recoveryLease = await store.acquire(interactiveArgs.sessionId);
241
+ let recoveryError;
242
+ try {
243
+ selectedRecord = validateCaptainSessionRecord(
244
+ await recoveryLease.recoverUnresolvedEffectAbandonment(),
245
+ );
246
+ } catch (error) {
247
+ recoveryError = error;
248
+ }
249
+ try {
250
+ await recoveryLease.release();
251
+ } catch (error) {
252
+ if (recoveryError !== undefined) {
253
+ throw aggregateOperationalFailures(
254
+ recoveryError,
255
+ error,
256
+ 'Captain session abandonment recovery failed and its lease could not be released',
257
+ );
258
+ }
259
+ throw error;
260
+ }
261
+ if (recoveryError !== undefined) throw recoveryError;
262
+ if (selectedRecord.state !== 'settled') {
263
+ throw new Error(
264
+ `Captain session ${JSON.stringify(interactiveArgs.sessionId)} abandonment recovery did not settle its turn`,
265
+ );
266
+ }
267
+ }
221
268
  } catch (error) {
222
269
  stderr.write(`playbook: ${errorMessage(error)}\n`);
223
270
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -358,6 +405,7 @@ export async function runPlaybookCli(options = {}) {
358
405
  projectTmuxConfig(plan),
359
406
  );
360
407
  let prepared;
408
+ let managedChildWorkDir;
361
409
  try {
362
410
  throwIfSignalAborted(options.signal);
363
411
  prepared = await awaitManagedPreparation(
@@ -372,8 +420,14 @@ export async function runPlaybookCli(options = {}) {
372
420
  ...(options.adapterImports
373
421
  ? { adapterImports: options.adapterImports }
374
422
  : {}),
375
- createSessionCommand: (context) =>
376
- createManagedInteractiveSessionCommand(
423
+ createSessionCommand: (context) => {
424
+ if (managedChildWorkDir !== undefined) {
425
+ throw new Error(
426
+ 'managed tmux-play requested more than one session command',
427
+ );
428
+ }
429
+ managedChildWorkDir = context.workDir;
430
+ return createManagedInteractiveSessionCommand(
377
431
  context,
378
432
  {
379
433
  schemaVersion: MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
@@ -393,7 +447,8 @@ export async function runPlaybookCli(options = {}) {
393
447
  ),
394
448
  ...(options.execPath ? { execPath: options.execPath } : {}),
395
449
  },
396
- ),
450
+ );
451
+ },
397
452
  }),
398
453
  options.signal,
399
454
  );
@@ -403,6 +458,28 @@ export async function runPlaybookCli(options = {}) {
403
458
  new Error('managed tmux-play prepared a mismatched session id'),
404
459
  );
405
460
  }
461
+ if (managedChildWorkDir === undefined) {
462
+ await cancelPreparedAfterFailure(
463
+ prepared,
464
+ new Error('managed tmux-play did not request a session command'),
465
+ );
466
+ }
467
+ if (prepared?.workDir !== managedChildWorkDir) {
468
+ await cancelPreparedAfterFailure(
469
+ prepared,
470
+ new Error('managed tmux-play prepared a mismatched work directory'),
471
+ );
472
+ }
473
+ if (!selectedRecord) {
474
+ try {
475
+ await (
476
+ options.publishManagedReadinessWitness ??
477
+ publishManagedInteractiveReadinessWitness
478
+ )(managedChildWorkDir, sessionId);
479
+ } catch (error) {
480
+ await cancelPreparedAfterFailure(prepared, error);
481
+ }
482
+ }
406
483
  try {
407
484
  await writeStream(
408
485
  stderr,