@sublang/playbook 12.0.0 → 12.2.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/docs/embedding.md +83 -1
- package/package.json +7 -1
- package/reference/sdlc/code.playbook/bin/repository-effects.js +501 -170
- package/reference/sdlc/code.playbook/bin/session-store.js +4 -1
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +291 -0
- package/reference/sdlc/code.playbook/host-capabilities.js +40 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +10 -0
- package/reference/sdlc/code.playbook/playbook-captain.ts +9 -0
- package/reference/sdlc/decide.playbook/decide.playbook.js +11 -7
- package/reference/sdlc/decide.playbook/decide.playbook.ts +11 -8
- package/slc/gears2fsm.md +20 -0
- package/slc/link.md +28 -1
- package/slc/text2gears.md +19 -1
- package/src/xstate-playbook-runtime.d.ts +15 -0
- package/src/xstate-playbook-runtime.js +127 -22
- package/src/xstate-playbook-runtime.ts +149 -24
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
chmod,
|
|
9
9
|
lstat,
|
|
10
10
|
mkdir,
|
|
11
|
+
mkdtemp,
|
|
11
12
|
open,
|
|
12
13
|
readFile,
|
|
13
14
|
readdir,
|
|
@@ -16,7 +17,7 @@ import {
|
|
|
16
17
|
rename,
|
|
17
18
|
rm,
|
|
18
19
|
} from 'node:fs/promises';
|
|
19
|
-
import { hostname as systemHostname } from 'node:os';
|
|
20
|
+
import { hostname as systemHostname, tmpdir } from 'node:os';
|
|
20
21
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
21
22
|
import { isDeepStrictEqual } from 'node:util';
|
|
22
23
|
import {
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
emptyPlaybookEffectLedger,
|
|
25
26
|
snapshotJsonValue,
|
|
26
27
|
} from '../../../../src/xstate-runtime.js';
|
|
28
|
+
import { applyEffectLedgerCommands } from './session-store.js';
|
|
27
29
|
|
|
28
30
|
const CLAIM_SCHEMA = 1;
|
|
29
31
|
const CLAIM_OWNER_FILE = 'owner.json';
|
|
@@ -31,6 +33,10 @@ const CLAIM_ROOT_NAME = 'playbook-effect-claims';
|
|
|
31
33
|
const UUID_PATTERN =
|
|
32
34
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
33
35
|
const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
36
|
+
// The HEAD of a worktree whose HEAD names no commit yet (an unborn branch) or
|
|
37
|
+
// of a directory that is not a repository yet: the all-zero OID Git itself
|
|
38
|
+
// reports for "no object". Exactly one root commit descends from it.
|
|
39
|
+
export const NULL_GIT_OID = '0'.repeat(40);
|
|
34
40
|
const CLAIM_COLLISION_CODES = new Set(['EEXIST', 'ENOTEMPTY']);
|
|
35
41
|
const processClaims = new Map();
|
|
36
42
|
const capabilityLedgerServices = new WeakMap();
|
|
@@ -64,9 +70,9 @@ function processClaimKey(identity) {
|
|
|
64
70
|
return `${identity.gitDir}\0${identity.worktree}`;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
function registerProcessClaim(claim) {
|
|
73
|
+
function registerProcessClaim(claim, local) {
|
|
68
74
|
const key = processClaimKey(claim.identity);
|
|
69
|
-
const entry = { claim, key, state: 'active' };
|
|
75
|
+
const entry = { claim, key, state: 'active', local };
|
|
70
76
|
processClaims.set(key, entry);
|
|
71
77
|
processClaimEntries.set(claim, entry);
|
|
72
78
|
}
|
|
@@ -216,8 +222,8 @@ function runGit(cwd, args, options = {}) {
|
|
|
216
222
|
});
|
|
217
223
|
}
|
|
218
224
|
|
|
219
|
-
async function runGitText(cwd, args) {
|
|
220
|
-
const raw = await runGit(cwd, args);
|
|
225
|
+
async function runGitText(cwd, args, options = {}) {
|
|
226
|
+
const raw = await runGit(cwd, args, options);
|
|
221
227
|
const content = raw.at(-1) === 0x0a ? raw.subarray(0, -1) : raw;
|
|
222
228
|
const value = content.toString('utf8');
|
|
223
229
|
if (!Buffer.from(value, 'utf8').equals(content)) {
|
|
@@ -522,30 +528,62 @@ function projectionPreservesBaseline(baseline, after) {
|
|
|
522
528
|
);
|
|
523
529
|
}
|
|
524
530
|
|
|
525
|
-
async function rawRepositoryStatus(worktree) {
|
|
526
|
-
return runGit(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
531
|
+
async function rawRepositoryStatus(worktree, env) {
|
|
532
|
+
return runGit(
|
|
533
|
+
worktree,
|
|
534
|
+
[
|
|
535
|
+
'-c',
|
|
536
|
+
'core.fileMode=true',
|
|
537
|
+
'-c',
|
|
538
|
+
'core.fsmonitor=false',
|
|
539
|
+
'-c',
|
|
540
|
+
'core.ignoreStat=false',
|
|
541
|
+
'-c',
|
|
542
|
+
'core.trustctime=true',
|
|
543
|
+
'-c',
|
|
544
|
+
'core.checkStat=default',
|
|
545
|
+
'status',
|
|
546
|
+
'--porcelain=v2',
|
|
547
|
+
'-z',
|
|
548
|
+
'--untracked-files=all',
|
|
549
|
+
'--ignored=no',
|
|
550
|
+
'--ignore-submodules=none',
|
|
551
|
+
'--no-renames',
|
|
552
|
+
],
|
|
553
|
+
{ env },
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function rawIndexVisibility(worktree, env) {
|
|
558
|
+
return runGit(worktree, ['ls-files', '-v', '-z'], { env });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// The commit HEAD names, or the null OID when HEAD names none: `--verify
|
|
562
|
+
// --quiet` exits 1 without output for an unborn branch, and every other
|
|
563
|
+
// failure stays a failure.
|
|
564
|
+
async function resolveHead(worktree, env) {
|
|
565
|
+
try {
|
|
566
|
+
const head = await runGitText(
|
|
567
|
+
worktree,
|
|
568
|
+
['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'],
|
|
569
|
+
{ env },
|
|
570
|
+
);
|
|
571
|
+
assertOid(head, 'repository HEAD');
|
|
572
|
+
return head;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error?.code === 1 && error.stdout?.length === 0) return NULL_GIT_OID;
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
545
577
|
}
|
|
546
578
|
|
|
547
|
-
|
|
548
|
-
return
|
|
579
|
+
function gitFailureSays(error, exitCode, text) {
|
|
580
|
+
return (
|
|
581
|
+
typeof error === 'object' &&
|
|
582
|
+
error !== null &&
|
|
583
|
+
error.code === exitCode &&
|
|
584
|
+
Buffer.isBuffer(error.stderr) &&
|
|
585
|
+
error.stderr.toString('utf8').includes(text)
|
|
586
|
+
);
|
|
549
587
|
}
|
|
550
588
|
|
|
551
589
|
function assertIndexVisibility(raw) {
|
|
@@ -564,73 +602,131 @@ function assertIndexVisibility(raw) {
|
|
|
564
602
|
}
|
|
565
603
|
}
|
|
566
604
|
|
|
567
|
-
|
|
605
|
+
// The governed worktree of a working directory, resolved afresh at every
|
|
606
|
+
// binding: the canonical root of the nearest Git worktree containing `cwd`
|
|
607
|
+
// when one exists (`repository: true`), otherwise the prospective root `cwd`
|
|
608
|
+
// itself with the `.git` a later `git init` there creates (`repository:
|
|
609
|
+
// false`). A prospective identity therefore equals the identity `git init`
|
|
610
|
+
// binds, so a boundary that initializes its own directory keeps one identity.
|
|
611
|
+
async function resolveGovernedWorktree(cwd) {
|
|
568
612
|
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
569
613
|
throw new TypeError('repository working directory must be a nonempty string');
|
|
570
614
|
}
|
|
571
|
-
|
|
572
|
-
|
|
615
|
+
let report;
|
|
616
|
+
try {
|
|
617
|
+
report = await runGitText(cwd, [
|
|
618
|
+
'rev-parse',
|
|
619
|
+
'--is-inside-work-tree',
|
|
620
|
+
'--show-toplevel',
|
|
621
|
+
'--absolute-git-dir',
|
|
622
|
+
]);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (gitFailureSays(error, 128, 'must be run in a work tree')) {
|
|
625
|
+
throw new Error(`${JSON.stringify(cwd)} is not inside a Git worktree`, {
|
|
626
|
+
cause: error,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (!gitFailureSays(error, 128, 'not a git repository')) throw error;
|
|
630
|
+
const worktree = await realpath(cwd);
|
|
631
|
+
if (!(await lstat(worktree)).isDirectory()) {
|
|
632
|
+
throw new Error(`${JSON.stringify(cwd)} is not a directory`);
|
|
633
|
+
}
|
|
634
|
+
return Object.freeze({
|
|
635
|
+
identity: Object.freeze({ worktree, gitDir: join(worktree, '.git') }),
|
|
636
|
+
repository: false,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
const [inside, reportedRoot, reportedGitDir, ...rest] = report.split('\n');
|
|
640
|
+
if (inside !== 'true' || reportedGitDir === undefined || rest.length > 0) {
|
|
573
641
|
throw new Error(`${JSON.stringify(cwd)} is not inside a Git worktree`);
|
|
574
642
|
}
|
|
575
|
-
const [reportedRoot, reportedGitDir] = await Promise.all([
|
|
576
|
-
runGitText(cwd, ['rev-parse', '--show-toplevel']),
|
|
577
|
-
runGitText(cwd, ['rev-parse', '--absolute-git-dir']),
|
|
578
|
-
]);
|
|
579
643
|
const [worktree, gitDir] = await Promise.all([
|
|
580
644
|
realpath(reportedRoot),
|
|
581
645
|
realpath(reportedGitDir),
|
|
582
646
|
]);
|
|
583
|
-
return Object.freeze({
|
|
647
|
+
return Object.freeze({
|
|
648
|
+
identity: Object.freeze({ worktree, gitDir }),
|
|
649
|
+
repository: true,
|
|
650
|
+
});
|
|
584
651
|
}
|
|
585
652
|
|
|
586
|
-
async function
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
);
|
|
653
|
+
export async function resolveCanonicalGitWorktree(cwd) {
|
|
654
|
+
const resolved = await resolveGovernedWorktree(cwd);
|
|
655
|
+
if (!resolved.repository) {
|
|
656
|
+
throw new Error(`${JSON.stringify(cwd)} is not inside a Git worktree`);
|
|
657
|
+
}
|
|
658
|
+
return resolved.identity;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function sampleWorktree(worktree, env) {
|
|
662
|
+
const head = await resolveHead(worktree, env);
|
|
663
|
+
const indexVisibility = await rawIndexVisibility(worktree, env);
|
|
664
|
+
assertIndexVisibility(indexVisibility);
|
|
665
|
+
const status = await rawRepositoryStatus(worktree, env);
|
|
666
|
+
const projection = await projectionFromStatus(worktree, status);
|
|
667
|
+
return { head, indexVisibility, status, projection };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function observeWorktreeSamples(identity, env, options) {
|
|
671
|
+
const before = await sampleWorktree(identity.worktree, env);
|
|
600
672
|
await options.afterFirstSample?.();
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
'--verify',
|
|
604
|
-
'HEAD^{commit}',
|
|
605
|
-
]);
|
|
606
|
-
const indexVisibilityAfter = await rawIndexVisibility(identity.worktree);
|
|
607
|
-
assertIndexVisibility(indexVisibilityAfter);
|
|
608
|
-
const statusAfter = await rawRepositoryStatus(identity.worktree);
|
|
609
|
-
const projectionAfter = await projectionFromStatus(
|
|
610
|
-
identity.worktree,
|
|
611
|
-
statusAfter,
|
|
612
|
-
);
|
|
613
|
-
const beforeText = projectionText(projectionBefore);
|
|
673
|
+
const after = await sampleWorktree(identity.worktree, env);
|
|
674
|
+
const beforeText = projectionText(before.projection);
|
|
614
675
|
if (
|
|
615
|
-
|
|
616
|
-
!
|
|
617
|
-
!
|
|
618
|
-
beforeText !== projectionText(
|
|
676
|
+
before.head !== after.head ||
|
|
677
|
+
!before.indexVisibility.equals(after.indexVisibility) ||
|
|
678
|
+
!before.status.equals(after.status) ||
|
|
679
|
+
beforeText !== projectionText(after.projection)
|
|
619
680
|
) {
|
|
620
681
|
throw new RepositoryObservationAmbiguousError();
|
|
621
682
|
}
|
|
622
683
|
return deepFreeze({
|
|
623
684
|
worktree: identity.worktree,
|
|
624
685
|
gitDir: identity.gitDir,
|
|
625
|
-
head:
|
|
626
|
-
projection:
|
|
686
|
+
head: before.head,
|
|
687
|
+
projection: before.projection,
|
|
627
688
|
projectionDigest: `sha256:${sha256(beforeText)}`,
|
|
628
689
|
});
|
|
629
690
|
}
|
|
630
691
|
|
|
692
|
+
// A directory that is not a repository yet is observed exactly as `git init`
|
|
693
|
+
// would then see it — the null HEAD over every non-ignored path as untracked
|
|
694
|
+
// content — by pointing Git's own status at it from an empty scratch
|
|
695
|
+
// repository, so initializing the directory in place changes nothing.
|
|
696
|
+
async function observeGovernedWorktree(resolved, options = {}) {
|
|
697
|
+
if (resolved.repository) {
|
|
698
|
+
return observeWorktreeSamples(resolved.identity, undefined, options);
|
|
699
|
+
}
|
|
700
|
+
const scratch = await mkdtemp(join(tmpdir(), 'playbook-effect-observe-'));
|
|
701
|
+
try {
|
|
702
|
+
await runGit(scratch, ['init', '--quiet', '--bare'], {
|
|
703
|
+
env: { GIT_DIR: scratch },
|
|
704
|
+
});
|
|
705
|
+
return await observeWorktreeSamples(
|
|
706
|
+
resolved.identity,
|
|
707
|
+
{ GIT_DIR: scratch, GIT_WORK_TREE: resolved.identity.worktree },
|
|
708
|
+
options,
|
|
709
|
+
);
|
|
710
|
+
} finally {
|
|
711
|
+
await rm(scratch, { recursive: true, force: true });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Every observation of a known identity re-binds it first, so a `.git` that
|
|
716
|
+
// appeared in or vanished from the worktree since is observed, and a root
|
|
717
|
+
// that moved elsewhere fails closed.
|
|
718
|
+
async function observeResolvedWorktree(identity, options = {}) {
|
|
719
|
+
const resolved = await resolveGovernedWorktree(identity.worktree);
|
|
720
|
+
if (!isDeepStrictEqual(resolved.identity, identity)) {
|
|
721
|
+
throw new RepositoryObservationAmbiguousError(
|
|
722
|
+
'repository canonical worktree identity changed since it was bound',
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
return observeGovernedWorktree(resolved, options);
|
|
726
|
+
}
|
|
727
|
+
|
|
631
728
|
export async function observeGitRepository(cwd, options = {}) {
|
|
632
|
-
|
|
633
|
-
return observeResolvedWorktree(identity, options);
|
|
729
|
+
return observeGovernedWorktree(await resolveGovernedWorktree(cwd), options);
|
|
634
730
|
}
|
|
635
731
|
|
|
636
732
|
function assertObservation(value, label) {
|
|
@@ -673,7 +769,7 @@ async function descendantCount(worktree, baselineHead, afterHead) {
|
|
|
673
769
|
const value = await runGitText(worktree, [
|
|
674
770
|
'rev-list',
|
|
675
771
|
'--count',
|
|
676
|
-
`${baselineHead}..${afterHead}`,
|
|
772
|
+
baselineHead === NULL_GIT_OID ? afterHead : `${baselineHead}..${afterHead}`,
|
|
677
773
|
]);
|
|
678
774
|
const count = Number(value);
|
|
679
775
|
if (!Number.isSafeInteger(count) || count < 0) {
|
|
@@ -729,7 +825,13 @@ export async function classifyRepositoryReceipt(
|
|
|
729
825
|
after,
|
|
730
826
|
);
|
|
731
827
|
}
|
|
732
|
-
|
|
828
|
+
// A HEAD that stopped naming a commit lost its history; every commit
|
|
829
|
+
// descends from the null HEAD, so a first root commit is one descendant.
|
|
830
|
+
if (
|
|
831
|
+
after.head === NULL_GIT_OID ||
|
|
832
|
+
(baseline.head !== NULL_GIT_OID &&
|
|
833
|
+
!(await isAncestor(after.worktree, baseline.head, after.head)))
|
|
834
|
+
) {
|
|
733
835
|
return receipt('rewritten-or-non-descendant', baseline, after);
|
|
734
836
|
}
|
|
735
837
|
const count = await descendantCount(after.worktree, baseline.head, after.head);
|
|
@@ -1025,12 +1127,75 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1025
1127
|
throw new TypeError('repository coordinator publication hook must be a function');
|
|
1026
1128
|
}
|
|
1027
1129
|
|
|
1130
|
+
const issueClaim = (identity, ownerToken, { assertActive, retire, local }) => {
|
|
1131
|
+
let released = false;
|
|
1132
|
+
let operationInProgress = false;
|
|
1133
|
+
let claim;
|
|
1134
|
+
const assertOwnerUnserialized = async () => {
|
|
1135
|
+
if (released) throw new Error('repository claim was already released');
|
|
1136
|
+
await assertActive(claim);
|
|
1137
|
+
};
|
|
1138
|
+
const runClaimOperation = async (operation) => {
|
|
1139
|
+
if (released) throw new Error('repository claim was already released');
|
|
1140
|
+
if (operationInProgress) {
|
|
1141
|
+
throw new Error('repository claim operation is already in progress');
|
|
1142
|
+
}
|
|
1143
|
+
operationInProgress = true;
|
|
1144
|
+
try {
|
|
1145
|
+
return await operation();
|
|
1146
|
+
} finally {
|
|
1147
|
+
operationInProgress = false;
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
const assertOwner = () => runClaimOperation(assertOwnerUnserialized);
|
|
1151
|
+
const observe = (observationOptions = {}) =>
|
|
1152
|
+
runClaimOperation(async () => {
|
|
1153
|
+
await assertOwnerUnserialized();
|
|
1154
|
+
const observation = await observeResolvedWorktree(
|
|
1155
|
+
identity,
|
|
1156
|
+
observationOptions,
|
|
1157
|
+
);
|
|
1158
|
+
await assertOwnerUnserialized();
|
|
1159
|
+
return observation;
|
|
1160
|
+
});
|
|
1161
|
+
const capture = (baseline, receiptOptions = {}) =>
|
|
1162
|
+
runClaimOperation(async () => {
|
|
1163
|
+
await assertOwnerUnserialized();
|
|
1164
|
+
assertObservation(baseline, 'baseline');
|
|
1165
|
+
if (
|
|
1166
|
+
baseline.worktree !== identity.worktree ||
|
|
1167
|
+
baseline.gitDir !== identity.gitDir
|
|
1168
|
+
) {
|
|
1169
|
+
throw new TypeError(
|
|
1170
|
+
'repository receipt baseline does not match the active claim',
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
const result = await captureRepositoryReceipt(baseline, receiptOptions);
|
|
1174
|
+
await assertOwnerUnserialized();
|
|
1175
|
+
return result;
|
|
1176
|
+
});
|
|
1177
|
+
const release = () =>
|
|
1178
|
+
runClaimOperation(async () => {
|
|
1179
|
+
await assertOwnerUnserialized();
|
|
1180
|
+
await retire();
|
|
1181
|
+
released = true;
|
|
1182
|
+
forgetProcessClaim(claim);
|
|
1183
|
+
});
|
|
1184
|
+
claim = Object.freeze({
|
|
1185
|
+
identity,
|
|
1186
|
+
ownerToken,
|
|
1187
|
+
assertOwner,
|
|
1188
|
+
observe,
|
|
1189
|
+
capture,
|
|
1190
|
+
release,
|
|
1191
|
+
});
|
|
1192
|
+
registerProcessClaim(claim, local);
|
|
1193
|
+
return claim;
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1028
1196
|
const acquire = async (cwd, claimOptions = {}) => {
|
|
1029
1197
|
assertSignal(claimOptions.signal);
|
|
1030
|
-
const identity = await
|
|
1031
|
-
const root = join(identity.gitDir, CLAIM_ROOT_NAME);
|
|
1032
|
-
await ensureClaimRoot(root);
|
|
1033
|
-
const activePath = join(root, 'active');
|
|
1198
|
+
const { identity, repository } = await resolveGovernedWorktree(cwd);
|
|
1034
1199
|
const ownerToken = createOwnerToken();
|
|
1035
1200
|
if (typeof ownerToken !== 'string' || !UUID_PATTERN.test(ownerToken)) {
|
|
1036
1201
|
throw new TypeError('repository coordinator owner-token generator returned an invalid token');
|
|
@@ -1042,6 +1207,33 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1042
1207
|
hostname: currentHostname,
|
|
1043
1208
|
});
|
|
1044
1209
|
|
|
1210
|
+
// A directory that is not a repository yet has no `.git` to publish a
|
|
1211
|
+
// claim in, so its claim is process-local: every same-process acquisition
|
|
1212
|
+
// of that identity waits for it, including the repository claim that
|
|
1213
|
+
// follows a `git init` inside it. Repository claims exclude one another
|
|
1214
|
+
// through the published owner below, as they always have.
|
|
1215
|
+
const key = processClaimKey(identity);
|
|
1216
|
+
while (true) {
|
|
1217
|
+
if (claimOptions.signal?.aborted) throw claimOptions.signal.reason;
|
|
1218
|
+
const entry = processClaims.get(key);
|
|
1219
|
+
if (entry === undefined || (repository && !entry.local)) break;
|
|
1220
|
+
await waitForRetry(pollIntervalMs, claimOptions.signal);
|
|
1221
|
+
}
|
|
1222
|
+
if (!repository) {
|
|
1223
|
+
return issueClaim(identity, ownerToken, {
|
|
1224
|
+
local: true,
|
|
1225
|
+
assertActive: (claim) => {
|
|
1226
|
+
if (processClaims.get(key)?.claim !== claim) {
|
|
1227
|
+
throw new Error('repository claim is owned by a different token');
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
retire: async () => {},
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const root = join(identity.gitDir, CLAIM_ROOT_NAME);
|
|
1235
|
+
await ensureClaimRoot(root);
|
|
1236
|
+
const activePath = join(root, 'active');
|
|
1045
1237
|
while (true) {
|
|
1046
1238
|
if (claimOptions.signal?.aborted) throw claimOptions.signal.reason;
|
|
1047
1239
|
let activeOwner;
|
|
@@ -1126,74 +1318,20 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1126
1318
|
break;
|
|
1127
1319
|
}
|
|
1128
1320
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
throw new Error('repository claim is owned by a different token');
|
|
1136
|
-
}
|
|
1137
|
-
};
|
|
1138
|
-
const runClaimOperation = async (operation) => {
|
|
1139
|
-
if (released) throw new Error('repository claim was already released');
|
|
1140
|
-
if (operationInProgress) {
|
|
1141
|
-
throw new Error('repository claim operation is already in progress');
|
|
1142
|
-
}
|
|
1143
|
-
operationInProgress = true;
|
|
1144
|
-
try {
|
|
1145
|
-
return await operation();
|
|
1146
|
-
} finally {
|
|
1147
|
-
operationInProgress = false;
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
const assertOwner = () => runClaimOperation(assertOwnerUnserialized);
|
|
1151
|
-
const observe = (observationOptions = {}) =>
|
|
1152
|
-
runClaimOperation(async () => {
|
|
1153
|
-
await assertOwnerUnserialized();
|
|
1154
|
-
const observation = await observeResolvedWorktree(
|
|
1155
|
-
identity,
|
|
1156
|
-
observationOptions,
|
|
1157
|
-
);
|
|
1158
|
-
await assertOwnerUnserialized();
|
|
1159
|
-
return observation;
|
|
1160
|
-
});
|
|
1161
|
-
const capture = (baseline, receiptOptions = {}) =>
|
|
1162
|
-
runClaimOperation(async () => {
|
|
1163
|
-
await assertOwnerUnserialized();
|
|
1164
|
-
assertObservation(baseline, 'baseline');
|
|
1165
|
-
if (
|
|
1166
|
-
baseline.worktree !== identity.worktree ||
|
|
1167
|
-
baseline.gitDir !== identity.gitDir
|
|
1168
|
-
) {
|
|
1169
|
-
throw new TypeError(
|
|
1170
|
-
'repository receipt baseline does not match the active claim',
|
|
1171
|
-
);
|
|
1321
|
+
return issueClaim(identity, owner.ownerToken, {
|
|
1322
|
+
local: false,
|
|
1323
|
+
assertActive: async () => {
|
|
1324
|
+
const activeOwner = await readClaimOwner(activePath);
|
|
1325
|
+
if (activeOwner.ownerToken !== owner.ownerToken) {
|
|
1326
|
+
throw new Error('repository claim is owned by a different token');
|
|
1172
1327
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
return result;
|
|
1176
|
-
});
|
|
1177
|
-
let claim;
|
|
1178
|
-
const release = () =>
|
|
1179
|
-
runClaimOperation(async () => {
|
|
1180
|
-
await assertOwnerUnserialized();
|
|
1328
|
+
},
|
|
1329
|
+
retire: async () => {
|
|
1181
1330
|
if (!(await retireClaim(root, activePath, owner))) {
|
|
1182
1331
|
throw new Error('repository claim retirement target is occupied');
|
|
1183
1332
|
}
|
|
1184
|
-
|
|
1185
|
-
forgetProcessClaim(claim);
|
|
1186
|
-
});
|
|
1187
|
-
claim = Object.freeze({
|
|
1188
|
-
identity,
|
|
1189
|
-
ownerToken: owner.ownerToken,
|
|
1190
|
-
assertOwner,
|
|
1191
|
-
observe,
|
|
1192
|
-
capture,
|
|
1193
|
-
release,
|
|
1333
|
+
},
|
|
1194
1334
|
});
|
|
1195
|
-
registerProcessClaim(claim);
|
|
1196
|
-
return claim;
|
|
1197
1335
|
};
|
|
1198
1336
|
|
|
1199
1337
|
const runExclusive = async (runOptions) => {
|
|
@@ -1814,13 +1952,29 @@ async function releaseRepositoryClaim(claim, primaryError) {
|
|
|
1814
1952
|
}
|
|
1815
1953
|
}
|
|
1816
1954
|
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1955
|
+
// A host binds the governed worktree identity for each durable call — fixed
|
|
1956
|
+
// at construction for the Captain host, resolved afresh from `cwd` for the
|
|
1957
|
+
// worktree host capability — and derives the schema-3 authority naming it.
|
|
1958
|
+
function bindHostWorktree(host) {
|
|
1959
|
+
return host.resolveIdentity().then((identity) => ({
|
|
1960
|
+
identity,
|
|
1961
|
+
authority: host.authorityFor(identity),
|
|
1962
|
+
}));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async function acquireBoundClaim(coordinator, identity, signal) {
|
|
1966
|
+
const claim = await coordinator.acquire(identity.worktree, { signal });
|
|
1967
|
+
if (!isDeepStrictEqual(claim.identity, identity)) {
|
|
1968
|
+
const error = new Error(
|
|
1969
|
+
'repository canonical worktree identity changed before its claim was acquired',
|
|
1970
|
+
);
|
|
1971
|
+
await releaseRepositoryClaim(claim, error);
|
|
1972
|
+
throw error;
|
|
1973
|
+
}
|
|
1974
|
+
return claim;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
async function runDurableExclusive({ coordinator, host, ledgerService, options }) {
|
|
1824
1978
|
if (!isPlainObject(options) || typeof options.operation !== 'function') {
|
|
1825
1979
|
throw new TypeError('exclusive repository operation must be a function');
|
|
1826
1980
|
}
|
|
@@ -1829,9 +1983,8 @@ async function runDurableExclusive({
|
|
|
1829
1983
|
'exclusive repository operation',
|
|
1830
1984
|
);
|
|
1831
1985
|
rejectBoundRepositoryOverride(options, 'runExclusive', ['cwd']);
|
|
1832
|
-
const
|
|
1833
|
-
|
|
1834
|
-
});
|
|
1986
|
+
const { identity, authority } = await bindHostWorktree(host);
|
|
1987
|
+
const claim = await acquireBoundClaim(coordinator, identity, options.signal);
|
|
1835
1988
|
let effectPossible = false;
|
|
1836
1989
|
let recovery;
|
|
1837
1990
|
try {
|
|
@@ -2015,13 +2168,7 @@ function replaceLogicalOperationCommand(expected, next) {
|
|
|
2015
2168
|
};
|
|
2016
2169
|
}
|
|
2017
2170
|
|
|
2018
|
-
async function runDurableDeferred({
|
|
2019
|
-
coordinator,
|
|
2020
|
-
identity,
|
|
2021
|
-
authority,
|
|
2022
|
-
ledgerService,
|
|
2023
|
-
options,
|
|
2024
|
-
}) {
|
|
2171
|
+
async function runDurableDeferred({ coordinator, host, ledgerService, options }) {
|
|
2025
2172
|
if (!isPlainObject(options)) {
|
|
2026
2173
|
throw new TypeError('deferred repository operation options must be an object');
|
|
2027
2174
|
}
|
|
@@ -2033,6 +2180,7 @@ async function runDurableDeferred({
|
|
|
2033
2180
|
);
|
|
2034
2181
|
}
|
|
2035
2182
|
const operationId = options.operationId;
|
|
2183
|
+
const { identity, authority } = await bindHostWorktree(host);
|
|
2036
2184
|
deferredOperationForAuthority(
|
|
2037
2185
|
ledgerService.snapshot(),
|
|
2038
2186
|
operationId,
|
|
@@ -2058,9 +2206,7 @@ async function runDurableDeferred({
|
|
|
2058
2206
|
);
|
|
2059
2207
|
}
|
|
2060
2208
|
|
|
2061
|
-
const claim = await coordinator
|
|
2062
|
-
signal: options.signal,
|
|
2063
|
-
});
|
|
2209
|
+
const claim = await acquireBoundClaim(coordinator, identity, options.signal);
|
|
2064
2210
|
let effectPossible = false;
|
|
2065
2211
|
let recovery;
|
|
2066
2212
|
try {
|
|
@@ -2359,8 +2505,7 @@ async function runDurableDeferred({
|
|
|
2359
2505
|
|
|
2360
2506
|
async function runDurableCohort({
|
|
2361
2507
|
coordinator,
|
|
2362
|
-
|
|
2363
|
-
authority,
|
|
2508
|
+
host,
|
|
2364
2509
|
concurrentRoleSets,
|
|
2365
2510
|
ledgerService,
|
|
2366
2511
|
options,
|
|
@@ -2385,9 +2530,8 @@ async function runDurableCohort({
|
|
|
2385
2530
|
'repository cohort effect boundaries must exactly match its roles',
|
|
2386
2531
|
);
|
|
2387
2532
|
}
|
|
2388
|
-
const
|
|
2389
|
-
|
|
2390
|
-
});
|
|
2533
|
+
const { identity, authority } = await bindHostWorktree(host);
|
|
2534
|
+
const claim = await acquireBoundClaim(coordinator, identity, options.signal);
|
|
2391
2535
|
let effectPossible = false;
|
|
2392
2536
|
let recovery;
|
|
2393
2537
|
try {
|
|
@@ -2850,6 +2994,10 @@ export async function createRepositoryEffectCapabilities({
|
|
|
2850
2994
|
requiredRoleIds,
|
|
2851
2995
|
concurrentRoleSets,
|
|
2852
2996
|
});
|
|
2997
|
+
const host = Object.freeze({
|
|
2998
|
+
resolveIdentity: async () => identity,
|
|
2999
|
+
authorityFor: () => authority,
|
|
3000
|
+
});
|
|
2853
3001
|
const observe = async (options = {}) => {
|
|
2854
3002
|
rejectBoundRepositoryOverride(options, 'observe', ['cwd']);
|
|
2855
3003
|
return observeResolvedWorktree(identity, options);
|
|
@@ -2861,8 +3009,7 @@ export async function createRepositoryEffectCapabilities({
|
|
|
2861
3009
|
const runExclusive = async (options) => {
|
|
2862
3010
|
return runDurableExclusive({
|
|
2863
3011
|
coordinator,
|
|
2864
|
-
|
|
2865
|
-
authority,
|
|
3012
|
+
host,
|
|
2866
3013
|
ledgerService,
|
|
2867
3014
|
options,
|
|
2868
3015
|
});
|
|
@@ -2870,8 +3017,7 @@ export async function createRepositoryEffectCapabilities({
|
|
|
2870
3017
|
const runCohort = async (options) => {
|
|
2871
3018
|
return runDurableCohort({
|
|
2872
3019
|
coordinator,
|
|
2873
|
-
|
|
2874
|
-
authority,
|
|
3020
|
+
host,
|
|
2875
3021
|
concurrentRoleSets,
|
|
2876
3022
|
ledgerService,
|
|
2877
3023
|
options,
|
|
@@ -2880,8 +3026,7 @@ export async function createRepositoryEffectCapabilities({
|
|
|
2880
3026
|
const runDeferred = async (options) => {
|
|
2881
3027
|
return runDurableDeferred({
|
|
2882
3028
|
coordinator,
|
|
2883
|
-
|
|
2884
|
-
authority,
|
|
3029
|
+
host,
|
|
2885
3030
|
ledgerService,
|
|
2886
3031
|
options,
|
|
2887
3032
|
});
|
|
@@ -2925,6 +3070,192 @@ export async function refreshRepositoryEffectCapabilities(capabilities) {
|
|
|
2925
3070
|
return ledgerService.refresh();
|
|
2926
3071
|
}
|
|
2927
3072
|
|
|
3073
|
+
const WORKTREE_HOST_CAPABILITY_OPTION_KEYS = new Set([
|
|
3074
|
+
'cwd',
|
|
3075
|
+
'playbookId',
|
|
3076
|
+
'requiredRoleIds',
|
|
3077
|
+
'concurrentRoleSets',
|
|
3078
|
+
'effectLedger',
|
|
3079
|
+
]);
|
|
3080
|
+
|
|
3081
|
+
function assertWorktreeSeedLedger(seed, playbookId, identity) {
|
|
3082
|
+
const ledger = assertPlaybookEffectLedger(
|
|
3083
|
+
seed,
|
|
3084
|
+
'worktree host capability effect ledger seed',
|
|
3085
|
+
);
|
|
3086
|
+
for (const boundary of ledger.boundaries) {
|
|
3087
|
+
if (
|
|
3088
|
+
boundary.playbookId !== playbookId ||
|
|
3089
|
+
!isDeepStrictEqual(boundary.canonicalWorktree, identity)
|
|
3090
|
+
) {
|
|
3091
|
+
throw new TypeError(
|
|
3092
|
+
'worktree host capability effect ledger seed names another playbook or worktree',
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
for (const operation of ledger.logicalOperations) {
|
|
3097
|
+
if (operation.playbookId !== playbookId) {
|
|
3098
|
+
throw new TypeError(
|
|
3099
|
+
'worktree host capability effect ledger seed names another playbook',
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
return ledger;
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// The host-owned in-memory ledger behind a worktree host capability. Every
|
|
3107
|
+
// write applies the exact command semantics the durable Captain record
|
|
3108
|
+
// applies; each construction is one attempt over its seed.
|
|
3109
|
+
function createMemoryEffectLedgerService(seed) {
|
|
3110
|
+
let mirror = seed;
|
|
3111
|
+
const attempt = Object.freeze({
|
|
3112
|
+
attemptId: randomUUID(),
|
|
3113
|
+
attemptNumber:
|
|
3114
|
+
seed.boundaries.reduce(
|
|
3115
|
+
(highest, boundary) => Math.max(highest, boundary.attemptNumber),
|
|
3116
|
+
0,
|
|
3117
|
+
) + 1,
|
|
3118
|
+
});
|
|
3119
|
+
return Object.freeze({
|
|
3120
|
+
snapshot: () => mirror,
|
|
3121
|
+
async writeAhead(authority, commands) {
|
|
3122
|
+
mirror = applyEffectLedgerCommands(mirror, authority, attempt, commands);
|
|
3123
|
+
return mirror;
|
|
3124
|
+
},
|
|
3125
|
+
});
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
async function assertExistingDirectory(cwd, label) {
|
|
3129
|
+
try {
|
|
3130
|
+
if ((await lstat(await realpath(cwd))).isDirectory()) return;
|
|
3131
|
+
} catch (error) {
|
|
3132
|
+
throw new Error(`${label} ${JSON.stringify(cwd)} is not an existing directory`, {
|
|
3133
|
+
cause: error,
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
throw new Error(`${label} ${JSON.stringify(cwd)} is not an existing directory`);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// DR-046: the lease-free worktree capability an embedding host constructs
|
|
3140
|
+
// through `@sublang/playbook/host-capabilities`. It runs the same claim,
|
|
3141
|
+
// observation, receipt, completion, and deferred-operation path as the
|
|
3142
|
+
// Captain-hosted capability above, over one host-owned in-memory ledger.
|
|
3143
|
+
// Unlike that capability, it binds its worktree lazily: `cwd` need not be a
|
|
3144
|
+
// repository yet, and every governed call and observation re-resolves the
|
|
3145
|
+
// governed worktree of `cwd`, so a `git init` there — inside a governed call
|
|
3146
|
+
// or between calls — is observed rather than hidden behind a stale identity.
|
|
3147
|
+
export async function createWorktreeHostCapabilities(options = {}) {
|
|
3148
|
+
if (!isPlainObject(options)) {
|
|
3149
|
+
throw new TypeError('worktree host capability options must be an object');
|
|
3150
|
+
}
|
|
3151
|
+
for (const key of Object.keys(options)) {
|
|
3152
|
+
if (!WORKTREE_HOST_CAPABILITY_OPTION_KEYS.has(key)) {
|
|
3153
|
+
throw new TypeError(
|
|
3154
|
+
`worktree host capability option ${JSON.stringify(key)} is not supported`,
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
const {
|
|
3159
|
+
cwd,
|
|
3160
|
+
playbookId,
|
|
3161
|
+
requiredRoleIds,
|
|
3162
|
+
concurrentRoleSets = [],
|
|
3163
|
+
effectLedger: seed = emptyPlaybookEffectLedger(),
|
|
3164
|
+
} = options;
|
|
3165
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
3166
|
+
throw new TypeError(
|
|
3167
|
+
'worktree host capability working directory must be nonempty',
|
|
3168
|
+
);
|
|
3169
|
+
}
|
|
3170
|
+
if (typeof playbookId !== 'string' || playbookId.length === 0) {
|
|
3171
|
+
throw new TypeError('worktree host capability playbookId must be nonempty');
|
|
3172
|
+
}
|
|
3173
|
+
const [entry] = detachedSchema3CatalogEntries({
|
|
3174
|
+
[playbookId]: {
|
|
3175
|
+
id: playbookId,
|
|
3176
|
+
artifactSchema: 3,
|
|
3177
|
+
requiredRoleIds,
|
|
3178
|
+
concurrentRoleSets,
|
|
3179
|
+
},
|
|
3180
|
+
});
|
|
3181
|
+
await assertExistingDirectory(cwd, 'worktree host capability working directory');
|
|
3182
|
+
const resolveIdentity = async () => (await resolveGovernedWorktree(cwd)).identity;
|
|
3183
|
+
const identity = await resolveIdentity();
|
|
3184
|
+
const ledgerService = assertEffectLedgerService(
|
|
3185
|
+
createMemoryEffectLedgerService(
|
|
3186
|
+
assertWorktreeSeedLedger(seed, playbookId, identity),
|
|
3187
|
+
),
|
|
3188
|
+
);
|
|
3189
|
+
const coordinator = createRepositoryEffectCoordinator();
|
|
3190
|
+
const authorityFor = (canonicalWorktree) =>
|
|
3191
|
+
deepFreeze({
|
|
3192
|
+
playbookId,
|
|
3193
|
+
artifactSchema: 3,
|
|
3194
|
+
cwd,
|
|
3195
|
+
canonicalWorktree,
|
|
3196
|
+
requiredRoleIds: entry.requiredRoleIds,
|
|
3197
|
+
concurrentRoleSets: entry.concurrentRoleSets,
|
|
3198
|
+
});
|
|
3199
|
+
const host = Object.freeze({ resolveIdentity, authorityFor });
|
|
3200
|
+
return deepFreeze({
|
|
3201
|
+
repository: {
|
|
3202
|
+
identity,
|
|
3203
|
+
observe: async (observationOptions = {}) => {
|
|
3204
|
+
rejectBoundRepositoryOverride(observationOptions, 'observe', ['cwd']);
|
|
3205
|
+
return observeGovernedWorktree(
|
|
3206
|
+
await resolveGovernedWorktree(cwd),
|
|
3207
|
+
observationOptions,
|
|
3208
|
+
);
|
|
3209
|
+
},
|
|
3210
|
+
runExclusive: (runOptions) =>
|
|
3211
|
+
runDurableExclusive({
|
|
3212
|
+
coordinator,
|
|
3213
|
+
host,
|
|
3214
|
+
ledgerService,
|
|
3215
|
+
options: runOptions,
|
|
3216
|
+
}),
|
|
3217
|
+
runDeferred: (runOptions) =>
|
|
3218
|
+
runDurableDeferred({
|
|
3219
|
+
coordinator,
|
|
3220
|
+
host,
|
|
3221
|
+
ledgerService,
|
|
3222
|
+
options: runOptions,
|
|
3223
|
+
}),
|
|
3224
|
+
},
|
|
3225
|
+
effectLedger: {
|
|
3226
|
+
snapshot: () => ledgerService.snapshot(),
|
|
3227
|
+
writeAhead: async (commands) =>
|
|
3228
|
+
ledgerService.writeAhead(authorityFor(await resolveIdentity()), commands),
|
|
3229
|
+
},
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
// DR-046: the capability for a host that runs no governed player state. Every
|
|
3234
|
+
// repository operation and effect-ledger write rejects, and the ledger stays
|
|
3235
|
+
// the canonical empty ledger.
|
|
3236
|
+
export function createFailClosedHostCapabilities() {
|
|
3237
|
+
const rejectRepository = () =>
|
|
3238
|
+
Promise.reject(
|
|
3239
|
+
new Error(
|
|
3240
|
+
'fail-closed host capabilities run no governed repository operation',
|
|
3241
|
+
),
|
|
3242
|
+
);
|
|
3243
|
+
const rejectWrite = () =>
|
|
3244
|
+
Promise.reject(
|
|
3245
|
+
new Error('fail-closed host capabilities accept no effect-ledger write'),
|
|
3246
|
+
);
|
|
3247
|
+
return deepFreeze({
|
|
3248
|
+
repository: {
|
|
3249
|
+
runExclusive: rejectRepository,
|
|
3250
|
+
runDeferred: rejectRepository,
|
|
3251
|
+
},
|
|
3252
|
+
effectLedger: {
|
|
3253
|
+
snapshot: () => emptyPlaybookEffectLedger(),
|
|
3254
|
+
writeAhead: rejectWrite,
|
|
3255
|
+
},
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
|
|
2928
3259
|
export const _internal = Object.freeze({
|
|
2929
3260
|
claimRootName: CLAIM_ROOT_NAME,
|
|
2930
3261
|
});
|