@sublang/playbook 17.6.0 → 17.8.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 +41 -23
- package/reference/sdlc/code.playbook/bin/session-store.js +73 -23
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +2 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +162 -12
- package/reference/sdlc/code.playbook/playbook-captain.ts +217 -11
- 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/slc/link.md +29 -6
- package/slc/text2gears.md +5 -3
- package/src/runtime.d.ts +6 -0
- package/src/runtime.ts +6 -0
- package/src/xstate-playbook-runtime.d.ts +20 -3
- package/src/xstate-playbook-runtime.js +267 -18
- package/src/xstate-playbook-runtime.ts +375 -21
- package/src/xstate-runtime.d.ts +23 -3
- package/src/xstate-runtime.js +97 -13
- package/src/xstate-runtime.ts +159 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.8.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
|
|
|
@@ -975,27 +976,43 @@ async function isAncestor(worktree, baselineHead, afterHead) {
|
|
|
975
976
|
}
|
|
976
977
|
}
|
|
977
978
|
|
|
978
|
-
|
|
979
|
-
|
|
979
|
+
// DR-088 §1: the commits reachable from the after HEAD and not from the
|
|
980
|
+
// baseline HEAD — every reachable commit for the null HEAD — oldest first, so
|
|
981
|
+
// the last is the after HEAD itself.
|
|
982
|
+
async function newCommits(worktree, baselineHead, afterHead) {
|
|
983
|
+
const raw = await runGitText(worktree, [
|
|
980
984
|
'rev-list',
|
|
981
|
-
'--
|
|
985
|
+
'--topo-order',
|
|
986
|
+
'--reverse',
|
|
982
987
|
baselineHead === NULL_GIT_OID ? afterHead : `${baselineHead}..${afterHead}`,
|
|
983
988
|
]);
|
|
984
|
-
const
|
|
985
|
-
if (
|
|
989
|
+
const commits = raw.length === 0 ? [] : raw.split('\n');
|
|
990
|
+
if (
|
|
991
|
+
commits.some((oid) => !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(oid)) ||
|
|
992
|
+
new Set(commits).size !== commits.length ||
|
|
993
|
+
(commits.length > 0 && commits.at(-1) !== afterHead)
|
|
994
|
+
) {
|
|
986
995
|
throw new RepositoryObservationAmbiguousError(
|
|
987
|
-
'Git returned an invalid
|
|
996
|
+
'Git returned an invalid list of new commits',
|
|
988
997
|
);
|
|
989
998
|
}
|
|
990
|
-
return
|
|
999
|
+
return commits;
|
|
991
1000
|
}
|
|
992
1001
|
|
|
993
|
-
function receipt(
|
|
1002
|
+
function receipt(
|
|
1003
|
+
classification,
|
|
1004
|
+
baseline,
|
|
1005
|
+
after,
|
|
1006
|
+
commitOid,
|
|
1007
|
+
preExisting,
|
|
1008
|
+
commits,
|
|
1009
|
+
) {
|
|
994
1010
|
return deepFreeze({
|
|
995
1011
|
classification,
|
|
996
1012
|
baseline,
|
|
997
1013
|
...(after === undefined ? {} : { after }),
|
|
998
1014
|
...(commitOid === undefined ? {} : { commitOid }),
|
|
1015
|
+
...(commits === undefined ? {} : { commits }),
|
|
999
1016
|
...(preExisting === undefined ? {} : { preExisting }),
|
|
1000
1017
|
});
|
|
1001
1018
|
}
|
|
@@ -1048,12 +1065,13 @@ export async function classifyRepositoryReceipt(
|
|
|
1048
1065
|
) {
|
|
1049
1066
|
return receipt('rewritten-or-non-descendant', baseline, after);
|
|
1050
1067
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1068
|
+
// DR-088 §1: HEAD advanced by one or more new commits; a receipt of more
|
|
1069
|
+
// than one lists every one, oldest first.
|
|
1070
|
+
const commits = await newCommits(after.worktree, baseline.head, after.head);
|
|
1071
|
+
if (commits.length === 0) {
|
|
1054
1072
|
return receipt('rewritten-or-non-descendant', baseline, after);
|
|
1055
1073
|
}
|
|
1056
|
-
// DR-062 §2: the
|
|
1074
|
+
// DR-062 §2: the advance proves itself when every baseline entry is
|
|
1057
1075
|
// preserved, absorbed, or altered and carried by it, and the after
|
|
1058
1076
|
// projection holds nothing else.
|
|
1059
1077
|
const fates = await baselineEntryFates(baseline, after);
|
|
@@ -1070,6 +1088,7 @@ export async function classifyRepositoryReceipt(
|
|
|
1070
1088
|
after,
|
|
1071
1089
|
unattributable ? undefined : after.head,
|
|
1072
1090
|
preExistingFromFates(fates),
|
|
1091
|
+
unattributable || commits.length === 1 ? undefined : commits,
|
|
1073
1092
|
);
|
|
1074
1093
|
}
|
|
1075
1094
|
|
|
@@ -1328,8 +1347,9 @@ function validateCohort(options) {
|
|
|
1328
1347
|
|
|
1329
1348
|
export function createRepositoryEffectCoordinator(options = {}) {
|
|
1330
1349
|
// DR-087: the claim owner's `hostname` carries this machine's identity,
|
|
1331
|
-
// read
|
|
1332
|
-
//
|
|
1350
|
+
// read at the first claim and kept once read, while a refusal is read
|
|
1351
|
+
// again at the next (playbook-cli-97); an explicit value stands in for
|
|
1352
|
+
// it (tests). An untagged owner is a legacy host name compared with the
|
|
1333
1353
|
// current one — or with an explicit untagged value, which then names
|
|
1334
1354
|
// this machine both ways.
|
|
1335
1355
|
const explicitHostname = options.hostname;
|
|
@@ -1338,15 +1358,13 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1338
1358
|
(typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
|
|
1339
1359
|
? explicitHostname
|
|
1340
1360
|
: systemHostname());
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
1349
|
-
}));
|
|
1361
|
+
const currentIdentity = lazyMachineIdentity(() =>
|
|
1362
|
+
explicitHostname !== undefined
|
|
1363
|
+
? explicitHostname
|
|
1364
|
+
: resolveMachineIdentity({
|
|
1365
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
1366
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
1367
|
+
}));
|
|
1350
1368
|
const currentPid = options.pid ?? process.pid;
|
|
1351
1369
|
const pollIntervalMs = options.pollIntervalMs ?? 10;
|
|
1352
1370
|
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) {
|
|
@@ -42,6 +42,8 @@ export interface PlaybookRepositoryReceipt {
|
|
|
42
42
|
readonly baseline: PlaybookRepositoryObservation;
|
|
43
43
|
readonly after?: PlaybookRepositoryObservation;
|
|
44
44
|
readonly commitOid?: string;
|
|
45
|
+
/** Every new commit, oldest first, where HEAD advanced by more than one (DR-088). */
|
|
46
|
+
readonly commits?: readonly string[];
|
|
45
47
|
readonly preExisting?: PlaybookRepositoryPreExistingChanges;
|
|
46
48
|
}
|
|
47
49
|
export interface RepositoryReceiptOptions {
|
|
@@ -621,6 +621,112 @@ function carriedPreExistingFacts(carried) {
|
|
|
621
621
|
return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
|
|
622
622
|
});
|
|
623
623
|
}
|
|
624
|
+
function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
|
|
625
|
+
// An entry names a commit by its proven OID, or — for a receipt listing
|
|
626
|
+
// several commits, which projects as `multiple-commits` (DR-088 §3) — by
|
|
627
|
+
// its after HEAD, the last of them.
|
|
628
|
+
const named = new Set(unresolvedEffects.flatMap(({ classification, afterHead, commitOid }) => commitOid !== undefined
|
|
629
|
+
? [commitOid]
|
|
630
|
+
: classification === 'multiple-commits' && afterHead !== undefined
|
|
631
|
+
? [afterHead]
|
|
632
|
+
: []));
|
|
633
|
+
const commits = [];
|
|
634
|
+
for (const boundary of ledger.boundaries) {
|
|
635
|
+
if (boundary.sequence <= boundaryPrefixCount)
|
|
636
|
+
continue;
|
|
637
|
+
const receipt = boundary.physicalReceipt;
|
|
638
|
+
if (receipt?.classification !== 'one-descendant-commit' ||
|
|
639
|
+
receipt.commitOid === undefined ||
|
|
640
|
+
named.has(receipt.commitOid)) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
named.add(receipt.commitOid);
|
|
644
|
+
commits.push({
|
|
645
|
+
baselineHead: receipt.baseline.head,
|
|
646
|
+
commitOid: receipt.commitOid,
|
|
647
|
+
...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return commits;
|
|
651
|
+
}
|
|
652
|
+
/** What one step committed, with every commit where it made several (DR-088). */
|
|
653
|
+
function commitBeforeFailureClause({ baselineHead, commitOid, commits, }) {
|
|
654
|
+
const made = commits === undefined
|
|
655
|
+
? 'made one new commit'
|
|
656
|
+
: `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
|
|
657
|
+
return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
|
|
658
|
+
}
|
|
659
|
+
function commitBeforeFailureFact(commit) {
|
|
660
|
+
return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
|
|
661
|
+
}
|
|
662
|
+
function commitsBeforeFailureReport(commits) {
|
|
663
|
+
if (commits.length === 0)
|
|
664
|
+
return undefined;
|
|
665
|
+
return [
|
|
666
|
+
'Repository changes made before the failure:',
|
|
667
|
+
...commits.map((commit) => `- A step ${commitBeforeFailureClause(commit)}`),
|
|
668
|
+
'The failure does not undo these commits.',
|
|
669
|
+
].join('\n');
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* DR-088 §2: whether a final text names one commit as a word, by its full OID
|
|
673
|
+
* or by an abbreviation of at least seven leading hex digits — the
|
|
674
|
+
* reconciler's own designation rule, read here to say what it accepted.
|
|
675
|
+
*/
|
|
676
|
+
function finalTextNamesCommit(finalText, oid) {
|
|
677
|
+
for (const [word] of finalText.matchAll(/(?<![0-9A-Za-z_])[0-9A-Fa-f]{7,64}(?![0-9A-Za-z_])/g)) {
|
|
678
|
+
if (oid.startsWith(word.toLowerCase()))
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* DR-088 §3: what the steps completed after `boundaryPrefixCount` committed,
|
|
685
|
+
* one statement each, in boundary sequence order: every new commit where a
|
|
686
|
+
* receipt proves more than one, and the commit already at HEAD where a step
|
|
687
|
+
* whose outcomes all commit or ask named it over an `unchanged` receipt. A
|
|
688
|
+
* step in a deferred chain reads the chain's cumulative receipt at its last
|
|
689
|
+
* boundary.
|
|
690
|
+
*/
|
|
691
|
+
function stepCommitStatements(ledger, boundaryPrefixCount) {
|
|
692
|
+
const statements = [];
|
|
693
|
+
for (const boundary of ledger.boundaries) {
|
|
694
|
+
if (boundary.sequence <= boundaryPrefixCount)
|
|
695
|
+
continue;
|
|
696
|
+
let receipt = boundary.physicalReceipt;
|
|
697
|
+
if (boundary.logicalOperationId !== undefined) {
|
|
698
|
+
const operation = ledger.logicalOperations.find(({ operationId }) => operationId === boundary.logicalOperationId);
|
|
699
|
+
if (operation?.boundaryIds.at(-1) !== boundary.boundaryId)
|
|
700
|
+
continue;
|
|
701
|
+
receipt = operation.logicalReceipt;
|
|
702
|
+
}
|
|
703
|
+
if (receipt === undefined)
|
|
704
|
+
continue;
|
|
705
|
+
if (receipt.classification === 'one-descendant-commit' &&
|
|
706
|
+
receipt.commits !== undefined) {
|
|
707
|
+
statements.push(`The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const candidate = boundary.semanticCandidate;
|
|
711
|
+
const guard = candidate !== null &&
|
|
712
|
+
typeof candidate === 'object' &&
|
|
713
|
+
!Array.isArray(candidate)
|
|
714
|
+
? candidate.guard
|
|
715
|
+
: undefined;
|
|
716
|
+
const head = receipt.after?.head;
|
|
717
|
+
if (receipt.classification === 'unchanged' &&
|
|
718
|
+
head !== undefined &&
|
|
719
|
+
!/^0+$/.test(head) &&
|
|
720
|
+
boundary.dispositions.includes('one-descendant-commit') &&
|
|
721
|
+
!boundary.dispositions.includes('unchanged') &&
|
|
722
|
+
typeof guard === 'string' &&
|
|
723
|
+
guard !== BOSS_QUESTION_OUTCOME &&
|
|
724
|
+
finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)) {
|
|
725
|
+
statements.push(`The step made no new commit; it named ${head}, already at HEAD, as its work.`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return statements;
|
|
729
|
+
}
|
|
624
730
|
// DR-063 §4: the Boss-visible failure report. Every host sees the same
|
|
625
731
|
// sentences because the shell composes them from the structured cause instead
|
|
626
732
|
// of asking a model to describe facts it may drop. The report exposes no file
|
|
@@ -2402,6 +2508,15 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
|
|
|
2402
2508
|
const unresolvedEffectFromReceipt = (receipt, baselineHead = receipt.baseline.head) => {
|
|
2403
2509
|
if (receipt.classification === 'unchanged')
|
|
2404
2510
|
return undefined;
|
|
2511
|
+
// DR-088 §3: a receipt listing more than one new commit reads as
|
|
2512
|
+
// `multiple-commits`, whose entry names more than one commit and no OID.
|
|
2513
|
+
if (receipt.commits !== undefined) {
|
|
2514
|
+
return {
|
|
2515
|
+
classification: 'multiple-commits',
|
|
2516
|
+
baselineHead,
|
|
2517
|
+
...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2405
2520
|
return {
|
|
2406
2521
|
classification: receipt.classification,
|
|
2407
2522
|
baselineHead,
|
|
@@ -2478,12 +2593,18 @@ const cumulativeOpenLogicalEffect = (operation, boundaries) => {
|
|
|
2478
2593
|
}
|
|
2479
2594
|
if (receipt.classification === 'one-descendant-commit' &&
|
|
2480
2595
|
(sameProjection || receiptAccountsFromOriginal)) {
|
|
2481
|
-
return
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2596
|
+
return receipt.commits === undefined
|
|
2597
|
+
? {
|
|
2598
|
+
classification: 'one-descendant-commit',
|
|
2599
|
+
baselineHead: original.head,
|
|
2600
|
+
afterHead: after.head,
|
|
2601
|
+
commitOid: after.head,
|
|
2602
|
+
}
|
|
2603
|
+
: {
|
|
2604
|
+
classification: 'multiple-commits',
|
|
2605
|
+
baselineHead: original.head,
|
|
2606
|
+
afterHead: after.head,
|
|
2607
|
+
};
|
|
2487
2608
|
}
|
|
2488
2609
|
return {
|
|
2489
2610
|
classification: 'observation-ambiguous',
|
|
@@ -2792,21 +2913,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
2792
2913
|
if (turn.carriedEffectPrefix !== undefined)
|
|
2793
2914
|
return;
|
|
2794
2915
|
let carried;
|
|
2916
|
+
let commitStatements;
|
|
2795
2917
|
try {
|
|
2796
2918
|
const ledger = assertPlaybookEffectLedger(currentEffectLedger());
|
|
2797
2919
|
carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
|
|
2920
|
+
commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
|
|
2798
2921
|
turn.carriedEffectPrefix = ledger.boundaries.length;
|
|
2799
2922
|
}
|
|
2800
2923
|
catch {
|
|
2801
2924
|
return;
|
|
2802
2925
|
}
|
|
2803
2926
|
const report = carriedPreExistingReport(carried);
|
|
2804
|
-
if (report
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2927
|
+
if (report !== undefined) {
|
|
2928
|
+
const facts = carriedPreExistingFacts(carried);
|
|
2929
|
+
appendMandatoryPresentationSuffix(turn, report, facts);
|
|
2930
|
+
for (const fact of facts) {
|
|
2931
|
+
joinSettlementFact(turn, fact);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
// DR-088 §3: the commits each step took, read from the same boundaries.
|
|
2935
|
+
if (commitStatements.length > 0) {
|
|
2936
|
+
appendMandatoryPresentationSuffix(turn, commitStatements.join('\n'), commitStatements);
|
|
2937
|
+
for (const fact of commitStatements) {
|
|
2938
|
+
joinSettlementFact(turn, fact);
|
|
2939
|
+
}
|
|
2810
2940
|
}
|
|
2811
2941
|
};
|
|
2812
2942
|
/**
|
|
@@ -6203,6 +6333,26 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
6203
6333
|
controls: failureControlLines(view, fenced),
|
|
6204
6334
|
};
|
|
6205
6335
|
joinSettlementFact(turn, fact, statement?.fact, speak);
|
|
6336
|
+
reportCommitsBeforeFailure(turn, unresolvedEffects);
|
|
6337
|
+
};
|
|
6338
|
+
// DR-089: the commits the turn's receipts proved before its leaf parked,
|
|
6339
|
+
// stated beside the failure report and joined to the settlement facts the
|
|
6340
|
+
// closing-reply prompt reads.
|
|
6341
|
+
const reportCommitsBeforeFailure = (turn, unresolvedEffects) => {
|
|
6342
|
+
let commits;
|
|
6343
|
+
try {
|
|
6344
|
+
commits = commitsBeforeFailure(assertPlaybookEffectLedger(currentEffectLedger()), presentedEffectPrefix, unresolvedEffects);
|
|
6345
|
+
}
|
|
6346
|
+
catch {
|
|
6347
|
+
return;
|
|
6348
|
+
}
|
|
6349
|
+
const report = commitsBeforeFailureReport(commits);
|
|
6350
|
+
if (report === undefined)
|
|
6351
|
+
return;
|
|
6352
|
+
const facts = commits.map(commitBeforeFailureFact);
|
|
6353
|
+
appendMandatoryPresentationSuffix(turn, report, facts);
|
|
6354
|
+
for (const fact of facts)
|
|
6355
|
+
joinSettlementFact(turn, fact);
|
|
6206
6356
|
};
|
|
6207
6357
|
// The runtimes own each frame's position. The shell only joins their stack.
|
|
6208
6358
|
const saveProgress = async (starting, step, required = true) => {
|