@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 +1 -1
- package/reference/sdlc/code.playbook/bin/machine-identity.js +18 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +11 -11
- package/reference/sdlc/code.playbook/bin/session-store.js +73 -23
- package/reference/sdlc/code.playbook/session-assets.js +7 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +13 -1
- package/reference/sdlc/code.playbook/session-store.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "17.
|
|
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
|
|
1332
|
-
//
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
?
|
|
656
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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?: {
|
|
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
|
-
|
|
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({
|