@sublang/playbook 17.6.0 → 17.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "17.6.0",
3
+ "version": "17.7.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, PR, and INSPECT workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -112,6 +112,24 @@ export function resolveMachineIdentity(options = {}) {
112
112
  return pending;
113
113
  }
114
114
 
115
+ /** One store's or coordinator's lazy resolution (playbook-cli-97): the
116
+ * first identity it resolves is kept for its lifetime, and a refusal is
117
+ * not, so its next lease or claim resolves again once the file is
118
+ * repaired. */
119
+ export function lazyMachineIdentity(resolve) {
120
+ let pending;
121
+ return () => {
122
+ if (pending === undefined) {
123
+ const attempt = Promise.resolve(resolve());
124
+ pending = attempt;
125
+ attempt.catch(() => {
126
+ if (pending === attempt) pending = undefined;
127
+ });
128
+ }
129
+ return pending;
130
+ };
131
+ }
132
+
115
133
  async function readOrPublish(path, fs, createIdentity) {
116
134
  const directory = dirname(path);
117
135
  await prepareDirectory(directory, path, fs);
@@ -30,6 +30,7 @@ import {
30
30
  classifyOwnerMachine,
31
31
  describeOwnerMachine,
32
32
  isMachineIdentity,
33
+ lazyMachineIdentity,
33
34
  resolveMachineIdentity,
34
35
  } from './machine-identity.js';
35
36
 
@@ -1328,8 +1329,9 @@ function validateCohort(options) {
1328
1329
 
1329
1330
  export function createRepositoryEffectCoordinator(options = {}) {
1330
1331
  // DR-087: the claim owner's `hostname` carries this machine's identity,
1331
- // read once at the first claim; an explicit value stands in for it
1332
- // (tests). An untagged owner is a legacy host name compared with the
1332
+ // read at the first claim and kept once read, while a refusal is read
1333
+ // again at the next (playbook-cli-97); an explicit value stands in for
1334
+ // it (tests). An untagged owner is a legacy host name compared with the
1333
1335
  // current one — or with an explicit untagged value, which then names
1334
1336
  // this machine both ways.
1335
1337
  const explicitHostname = options.hostname;
@@ -1338,15 +1340,13 @@ export function createRepositoryEffectCoordinator(options = {}) {
1338
1340
  (typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
1339
1341
  ? explicitHostname
1340
1342
  : systemHostname());
1341
- let currentIdentityPending;
1342
- const currentIdentity = () =>
1343
- (currentIdentityPending ??=
1344
- explicitHostname !== undefined
1345
- ? Promise.resolve(explicitHostname)
1346
- : resolveMachineIdentity({
1347
- ...(options.env !== undefined ? { env: options.env } : {}),
1348
- ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
1349
- }));
1343
+ const currentIdentity = lazyMachineIdentity(() =>
1344
+ explicitHostname !== undefined
1345
+ ? explicitHostname
1346
+ : resolveMachineIdentity({
1347
+ ...(options.env !== undefined ? { env: options.env } : {}),
1348
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
1349
+ }));
1350
1350
  const currentPid = options.pid ?? process.pid;
1351
1351
  const pollIntervalMs = options.pollIntervalMs ?? 10;
1352
1352
  const probeProcess = options.probeProcess ?? defaultProbeProcess;
@@ -28,7 +28,7 @@ import {
28
28
  unlink,
29
29
  } from 'node:fs/promises';
30
30
  import { homedir, hostname as systemHostname } from 'node:os';
31
- import { dirname, isAbsolute, join, resolve } from 'node:path';
31
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
32
32
  import { fileURLToPath, pathToFileURL } from 'node:url';
33
33
  import { isDeepStrictEqual } from 'node:util';
34
34
  import { assertSupportedEffort } from '@sublang/cligent';
@@ -48,6 +48,7 @@ import {
48
48
  classifyOwnerMachine,
49
49
  describeOwnerMachine,
50
50
  isMachineIdentity,
51
+ lazyMachineIdentity,
51
52
  resolveMachineIdentity,
52
53
  } from './machine-identity.js';
53
54
  import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
@@ -638,41 +639,50 @@ export function createCaptainSessionStore(options = {}) {
638
639
  const createTempId = options.createTempId ?? randomUUID;
639
640
  const createLeaseToken = options.createLeaseToken ?? randomUUID;
640
641
  // DR-087: the owner's `hostname` carries this machine's identity, read
641
- // once at the first lease boundary; an explicit value stands in for it
642
- // (tests, migration sub-stores). An untagged owner is a legacy host
643
- // name, compared with the current one — or with an explicit untagged
644
- // value, which then names this machine both ways.
642
+ // at the first lease boundary and kept once read, while a refusal is
643
+ // read again at the next (playbook-cli-97); an explicit value stands in
644
+ // for it (tests, migration sub-stores). An untagged owner is a legacy
645
+ // host name, compared with the current one — or with an explicit
646
+ // untagged value, which then names this machine both ways.
645
647
  const explicitHostname = options.hostname;
646
648
  const legacyHostname =
647
649
  options.legacyHostname ??
648
650
  (typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
649
651
  ? explicitHostname
650
652
  : systemHostname());
651
- let localIdentityPending;
652
- const localIdentity = () =>
653
- (localIdentityPending ??=
654
- explicitHostname !== undefined
655
- ? Promise.resolve(explicitHostname)
656
- : options.resolveHostname !== undefined
657
- ? options.resolveHostname()
658
- : resolveMachineIdentity({ env, homeDir: home }));
653
+ const localIdentity = lazyMachineIdentity(() =>
654
+ explicitHostname !== undefined
655
+ ? explicitHostname
656
+ : options.resolveHostname !== undefined
657
+ ? options.resolveHostname()
658
+ : resolveMachineIdentity({ env, homeDir: home }));
659
659
  const localPid = options.pid ?? process.pid;
660
660
  const probeProcess =
661
661
  options.probeProcess ?? ((pid) => process.kill(pid, 0));
662
662
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
663
+ // session-storage-11: a host that prepared the directory may forbid
664
+ // creating it or an ancestor, so a directory that moves or vanishes
665
+ // mid-operation is refused rather than recreated at its former path.
666
+ const createSessionsDir =
667
+ options.createSessionsDir === undefined ? true : options.createSessionsDir;
668
+ const mayCreateDirectory = (directory) =>
669
+ createSessionsDir || !isPathOrAncestor(directory, sessionsDir);
663
670
  const replayReadCursors = new Map();
664
671
  const replayReadQueues = new Map();
665
672
  const portableWriters = new Map();
666
673
  const assetStores = new Map();
667
674
  const assetStoreFor = (sessionId) => {
668
675
  assertSessionId(sessionId);
669
- if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
676
+ if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), createAncestors: createSessionsDir, ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
670
677
  return assetStores.get(sessionId);
671
678
  };
672
679
 
673
680
  if (!isAbsolute(sessionsDir)) {
674
681
  throw new Error('Captain session store path must be absolute');
675
682
  }
683
+ if (typeof createSessionsDir !== 'boolean') {
684
+ throw new TypeError('Captain session store createSessionsDir must be a boolean');
685
+ }
676
686
  if (
677
687
  explicitHostname !== undefined &&
678
688
  (typeof explicitHostname !== 'string' ||
@@ -1101,7 +1111,7 @@ export function createCaptainSessionStore(options = {}) {
1101
1111
  : await portableWriters.get(recordValue.sessionId)?.checkpoint(recordValue);
1102
1112
  if (record === undefined) throw new Error('session persistence requires its owning lifecycle');
1103
1113
  const destination = recordPathFor(record.sessionId);
1104
- await ensurePrivateDirectory(sessionsDir, fs);
1114
+ await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
1105
1115
 
1106
1116
  if (noReplace) {
1107
1117
  await assertPathMissing(
@@ -1358,7 +1368,7 @@ export function createCaptainSessionStore(options = {}) {
1358
1368
  };
1359
1369
 
1360
1370
  const makeLeaseStage = async (sessionId) => {
1361
- await ensurePrivateDirectory(sessionsDir, fs);
1371
+ await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
1362
1372
  const ownerToken = createLeaseToken();
1363
1373
  assertUuid(ownerToken, 'Captain session lease owner token');
1364
1374
  const retiredPath = retiredPathFor(sessionId, ownerToken);
@@ -1625,10 +1635,10 @@ export function createCaptainSessionStore(options = {}) {
1625
1635
  // the lease below; a preflight read never authorizes migration writes.
1626
1636
  try { decodeSource(await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source')); }
1627
1637
  catch (cause) { if (cause?.code !== 'ENOENT' || !sidecar && !external) throw cause; }
1628
- let sourceLease, lease;
1638
+ let sourceLease, lease, failure;
1629
1639
  try {
1630
1640
  if (external) {
1631
- const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1641
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1632
1642
  sourceLease = await sourceStore.acquireManagement(sessionId);
1633
1643
  }
1634
1644
  lease = await acquire(sessionId, true);
@@ -1719,7 +1729,7 @@ export function createCaptainSessionStore(options = {}) {
1719
1729
  if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
1720
1730
  } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1721
1731
  }
1722
- await ensurePrivateDirectory(inputsDir, fs);
1732
+ await ensurePrivateDirectory(inputsDir, fs, mayCreateDirectory);
1723
1733
  const inputs = [{ path: sourcePath, bytes: Buffer.from(sourceBytes, 'utf8') }, ...(originalReplay === undefined ? [] : [{ path: sourceReplayPath, bytes: originalReplay }])];
1724
1734
  for (const [index, input] of inputs.entries()) {
1725
1735
  const path = join(inputsDir, String(index));
@@ -1743,8 +1753,11 @@ export function createCaptainSessionStore(options = {}) {
1743
1753
  if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
1744
1754
  await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1745
1755
  return { manifest, migrated: true, reasons: [] };
1756
+ } catch (cause) {
1757
+ failure = { cause };
1758
+ throw cause;
1746
1759
  } finally {
1747
- try { await lease?.release(); } finally { await sourceLease?.release(); }
1760
+ await releaseInOrder([lease, sourceLease], failure, `cannot migrate Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`);
1748
1761
  }
1749
1762
  };
1750
1763
 
@@ -1754,7 +1767,7 @@ export function createCaptainSessionStore(options = {}) {
1754
1767
  const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
1755
1768
  const result = { sourceDir, migrated: [], skipped: [] };
1756
1769
  if (sourceDir === sessionsDir) return result;
1757
- const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1770
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1758
1771
  await sourceStore.prepare();
1759
1772
  try { await assertPrivateDirectory(sourceDir, fs); }
1760
1773
  catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
@@ -1778,8 +1791,10 @@ export function createCaptainSessionStore(options = {}) {
1778
1791
 
1779
1792
  const remove = async (sessionId) => {
1780
1793
  const lease = await acquire(sessionId, true);
1794
+ let failure;
1781
1795
  try { await lease.assertOwner(); await deleteRecord(sessionId); }
1782
- finally { await lease.release(); }
1796
+ catch (cause) { failure = { cause }; throw cause; }
1797
+ finally { await releaseInOrder([lease], failure, `cannot delete Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`); }
1783
1798
  };
1784
1799
 
1785
1800
  const exportBundle = async (sessionId) => {
@@ -6706,7 +6721,7 @@ async function assertPathMissing(path, fs, message) {
6706
6721
  }
6707
6722
 
6708
6723
 
6709
- async function ensurePrivateDirectory(path, fs) {
6724
+ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
6710
6725
  try {
6711
6726
  await assertPrivateDirectory(path, fs);
6712
6727
  return;
@@ -6728,6 +6743,15 @@ async function ensurePrivateDirectory(path, fs) {
6728
6743
  cursor = parent;
6729
6744
  }
6730
6745
  }
6746
+ // Refuse before creating anything, so a forbidden ancestor is never
6747
+ // recreated beneath a permitted descendant (session-storage-11).
6748
+ const forbidden = missing.find((directory) => !mayCreate(directory));
6749
+ if (forbidden !== undefined) {
6750
+ throw Object.assign(
6751
+ new Error(`Captain session store directory ${JSON.stringify(forbidden)} is missing and this store may not create it`),
6752
+ { code: 'ENOENT' },
6753
+ );
6754
+ }
6731
6755
  for (const directory of missing.reverse()) {
6732
6756
  let created = false;
6733
6757
  try {
@@ -6750,6 +6774,32 @@ async function ensurePrivateDirectory(path, fs) {
6750
6774
  await assertPrivateDirectory(path, fs);
6751
6775
  }
6752
6776
 
6777
+ // Releases every acquired lease in order. Without an earlier failure the
6778
+ // last release error propagates, as nested try/finally releases would; a
6779
+ // release error never hides the failure that preceded it.
6780
+ async function releaseInOrder(leases, failure, message) {
6781
+ const errors = [];
6782
+ for (const lease of leases) {
6783
+ try {
6784
+ await lease?.release();
6785
+ } catch (error) {
6786
+ errors.push(error);
6787
+ }
6788
+ }
6789
+ if (errors.length === 0) return;
6790
+ if (failure === undefined) throw errors.at(-1);
6791
+ throw new AggregateError([failure.cause, ...errors], message);
6792
+ }
6793
+
6794
+ function isPathOrAncestor(directory, path) {
6795
+ const fromDirectory = relative(directory, path);
6796
+ return fromDirectory === '' || (
6797
+ fromDirectory !== '..' &&
6798
+ !fromDirectory.startsWith(`..${sep}`) &&
6799
+ !isAbsolute(fromDirectory)
6800
+ );
6801
+ }
6802
+
6753
6803
  async function assertPrivateDirectory(path, fs) {
6754
6804
  const stat = await assertDirectoryNotLink(path, fs);
6755
6805
  if ((stat.mode & 0o7777) !== 0o700) {
@@ -87,9 +87,15 @@ export function createAssetStore(options) {
87
87
  const directory = resolve(options.directory);
88
88
  const maxAssetBytes = options.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES;
89
89
  if (!Number.isSafeInteger(maxAssetBytes) || maxAssetBytes <= 0) throw new TypeError('maximum asset bytes must be a positive safe integer');
90
+ // A session store forbidding creation of its sessions directory creates
91
+ // only this leaf inside it (session-storage-11).
92
+ const createAncestors = options.createAncestors ?? true;
90
93
  let importTail = Promise.resolve();
91
94
  const ensureDirectory = async (create) => {
92
- if (create) await mkdir(directory, { recursive: true, mode: 0o700 });
95
+ if (create) {
96
+ try { await mkdir(directory, { recursive: createAncestors, mode: 0o700 }); }
97
+ catch (error) { if (createAncestors || error.code !== 'EEXIST') throw error; }
98
+ }
93
99
  assertDirectory(await lstat(directory));
94
100
  };
95
101
  const prepare = async () => {
@@ -285,7 +285,19 @@ export interface SharedSessionStore {
285
285
  migrateLegacyDefault(options?: { env?: Readonly<Record<string, string | undefined>>; homeDir?: string }): Promise<{ sourceDir: string; migrated: readonly string[]; skipped: readonly { sessionId: string; reason: string }[] }>;
286
286
  migrate(sessionId: string, options?: { sourcePath?: string; cwd?: string; backupDir?: string }): Promise<{ manifest: SessionManifest; migrated: boolean; reasons: readonly string[] }>;
287
287
  }
288
- export declare function createSessionStore(options?: { maxAssetBytes?: number; sessionsDir?: string; env?: Readonly<Record<string, string | undefined>>; homeDir?: string; [key: string]: any }): SharedSessionStore;
288
+ export declare function createSessionStore(options?: {
289
+ maxAssetBytes?: number;
290
+ sessionsDir?: string;
291
+ /**
292
+ * Default `true`. `false` refuses, rather than creates, a missing sessions directory or ancestor when preparing
293
+ * session payload, lease, asset or migration directories. Machine-identity provisioning keeps its own rule and may
294
+ * still create its directory, including an ancestor it shares with the sessions directory.
295
+ */
296
+ createSessionsDir?: boolean;
297
+ env?: Readonly<Record<string, string | undefined>>;
298
+ homeDir?: string;
299
+ [key: string]: any;
300
+ }): SharedSessionStore;
289
301
  export declare function validateSessionManifest(value: unknown): SessionManifest;
290
302
  export declare function validateSessionContext(value: unknown): SessionContext;
291
303
  export declare function projectCaptainSessionStructure(value: SessionExecutionProjection): SessionStructuralProjection;
@@ -21,9 +21,19 @@ export function openSessionStore(sessionsDir) {
21
21
  }
22
22
 
23
23
  function wrapStore(store) {
24
+ // Preparation is kept once it succeeds; a refusal is not, so the next
25
+ // operation prepares again once the directory is repaired
26
+ // (session-storage-1).
24
27
  let preparation;
25
28
  const prepared = async (operation) => {
26
- await (preparation ??= store.prepare());
29
+ if (preparation === undefined) {
30
+ const attempt = store.prepare();
31
+ preparation = attempt;
32
+ attempt.catch(() => {
33
+ if (preparation === attempt) preparation = undefined;
34
+ });
35
+ }
36
+ await preparation;
27
37
  return operation();
28
38
  };
29
39
  return Object.freeze({