@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.
@@ -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(worktree, [
527
- '-c',
528
- 'core.fileMode=true',
529
- '-c',
530
- 'core.fsmonitor=false',
531
- '-c',
532
- 'core.ignoreStat=false',
533
- '-c',
534
- 'core.trustctime=true',
535
- '-c',
536
- 'core.checkStat=default',
537
- 'status',
538
- '--porcelain=v2',
539
- '-z',
540
- '--untracked-files=all',
541
- '--ignored=no',
542
- '--ignore-submodules=none',
543
- '--no-renames',
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
- async function rawIndexVisibility(worktree) {
548
- return runGit(worktree, ['ls-files', '-v', '-z']);
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
- export async function resolveCanonicalGitWorktree(cwd) {
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
- const inside = await runGitText(cwd, ['rev-parse', '--is-inside-work-tree']);
572
- if (inside !== 'true') {
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({ worktree, gitDir });
647
+ return Object.freeze({
648
+ identity: Object.freeze({ worktree, gitDir }),
649
+ repository: true,
650
+ });
584
651
  }
585
652
 
586
- async function observeResolvedWorktree(identity, options = {}) {
587
- const headBefore = await runGitText(identity.worktree, [
588
- 'rev-parse',
589
- '--verify',
590
- 'HEAD^{commit}',
591
- ]);
592
- assertOid(headBefore, 'repository HEAD');
593
- const indexVisibilityBefore = await rawIndexVisibility(identity.worktree);
594
- assertIndexVisibility(indexVisibilityBefore);
595
- const statusBefore = await rawRepositoryStatus(identity.worktree);
596
- const projectionBefore = await projectionFromStatus(
597
- identity.worktree,
598
- statusBefore,
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 headAfter = await runGitText(identity.worktree, [
602
- 'rev-parse',
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
- headBefore !== headAfter ||
616
- !indexVisibilityBefore.equals(indexVisibilityAfter) ||
617
- !statusBefore.equals(statusAfter) ||
618
- beforeText !== projectionText(projectionAfter)
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: headBefore,
626
- projection: projectionBefore,
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
- const identity = await resolveCanonicalGitWorktree(cwd);
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
- if (!(await isAncestor(after.worktree, baseline.head, after.head))) {
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 resolveCanonicalGitWorktree(cwd);
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
- let released = false;
1130
- let operationInProgress = false;
1131
- const assertOwnerUnserialized = async () => {
1132
- if (released) throw new Error('repository claim was already released');
1133
- const activeOwner = await readClaimOwner(activePath);
1134
- if (activeOwner.ownerToken !== owner.ownerToken) {
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
- const result = await captureRepositoryReceipt(baseline, receiptOptions);
1174
- await assertOwnerUnserialized();
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
- released = true;
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
- async function runDurableExclusive({
1818
- coordinator,
1819
- identity,
1820
- authority,
1821
- ledgerService,
1822
- options,
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 claim = await coordinator.acquire(identity.worktree, {
1833
- signal: options.signal,
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.acquire(identity.worktree, {
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
- identity,
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 claim = await coordinator.acquire(identity.worktree, {
2389
- signal: options.signal,
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
- identity,
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
- identity,
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
- identity,
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
  });