wowbagger 0.1.0-alpha.12 → 0.1.0-alpha.13
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/CHANGELOG.md +53 -0
- package/README.md +8 -8
- package/docs/work-claim-contract.md +102 -4
- package/package.json +1 -1
- package/skills/wowbagger/SKILL.md +3 -3
- package/src/claim-coordinator.js +19 -1
- package/src/claim-journal.js +8 -0
- package/src/claim-publication.js +106 -35
- package/src/claim-store.js +9 -4
- package/src/cli.js +26 -1
- package/src/git-autocommit.js +6 -0
- package/src/git-reconciliation.js +72 -35
- package/src/git-worktrees.js +73 -0
- package/src/instrumentation.js +1 -0
- package/src/worktree-identity.js +165 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ consolidation. The first tagged release inherits this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.1.0-alpha.13 - 2026-08-28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Every reconciling command reads the same writer evidence.** `claim-verify`
|
|
15
|
+
and ordinary mutations named the worktree they spoke for when they
|
|
16
|
+
classified reconciliation; `publish-claimed`, `claim-adopt`, and the
|
|
17
|
+
`claim acquire`, `claim renew`, and `claim release` lifecycle commands did
|
|
18
|
+
not. An unreachable successor written by the current worktree therefore read
|
|
19
|
+
as advisory sibling synchronization on those surfaces, whose target scoping
|
|
20
|
+
let `publish-claimed` commit a publication and `claim acquire` grant a claim
|
|
21
|
+
in exactly the state a `patch` refused. `publish-claimed`, `claim acquire`,
|
|
22
|
+
and `claim renew` now report `unauthorized-revision` and refuse, matching
|
|
23
|
+
`claim-verify`. `claim release` stays available: it relinquishes authority
|
|
24
|
+
rather than extending it, and refusing it would strand the lease in the
|
|
25
|
+
worktree least able to clear the barrier, because no other worktree can take
|
|
26
|
+
the item over while the claim is held. The release compare-and-swap is
|
|
27
|
+
unchanged, so a wrong owner, epoch, or expiry still refuses with
|
|
28
|
+
`claim-conflict`. A genuine sibling successor and an authorization written
|
|
29
|
+
before writer identity existed remain advisory synchronization on every
|
|
30
|
+
surface. `claim-adopt` semantics are unchanged: it reports no reconciliation
|
|
31
|
+
diagnosis and stays available as the remedy. It now judges its own identity
|
|
32
|
+
bytes before the worktree roster, so its own malformed identity reports as
|
|
33
|
+
such instead of as a failed sibling enumeration.
|
|
34
|
+
|
|
35
|
+
### Known limitations
|
|
36
|
+
|
|
37
|
+
- **Creates in more than one worktree can commit duplicate item numbers, even
|
|
38
|
+
when the creates are sequential, and no sanctioned repair exists** (items
|
|
39
|
+
#181 and #182). Create is journal-silent, so nothing coordinates the number
|
|
40
|
+
it derives. Any two worktrees whose checkouts have not been integrated derive
|
|
41
|
+
the next schema-v2 `number` from the base each can see, and both derive the
|
|
42
|
+
same one. The two creates need not overlap in time: a create in one worktree
|
|
43
|
+
today and a create in another worktree tomorrow collide just as surely, so
|
|
44
|
+
long as neither worktree has seen the other's commit. Each create succeeds,
|
|
45
|
+
each refuses nothing, and reconciliation reports nothing. The collision only
|
|
46
|
+
exists once the branches are integrated. `validate` then fails globally with
|
|
47
|
+
`duplicate-number` on every colliding item, and because an invalid ledger
|
|
48
|
+
blocks every mutation, the whole ledger stops accepting work. `number` is
|
|
49
|
+
immutable and `patch` correctly rejects it, so Wowbagger currently offers no
|
|
50
|
+
operation that repairs the collision.
|
|
51
|
+
|
|
52
|
+
Until both items ship, serialize `create` through a single worktree, and run
|
|
53
|
+
`validate` immediately after you integrate branches so a collision surfaces
|
|
54
|
+
at the merge rather than at the next mutation.
|
|
55
|
+
|
|
56
|
+
The PropertyCompass repair — editing `number` in the item source by hand,
|
|
57
|
+
committing it, then running `claim-adopt` — was an emergency intervention
|
|
58
|
+
taken under an outage. It is **not a supported workaround**. It bypasses the
|
|
59
|
+
number-collision and reference checks every mutation performs, and it can
|
|
60
|
+
leave dangling `depends_on`, `related`, and parent references that nothing
|
|
61
|
+
reports. Do not adopt it as routine practice.
|
|
62
|
+
|
|
10
63
|
## 0.1.0-alpha.12 - 2026-08-28
|
|
11
64
|
|
|
12
65
|
### Fixed
|
package/README.md
CHANGED
|
@@ -30,8 +30,8 @@ agent to use those guarantees instead of hand-editing your Markdown.
|
|
|
30
30
|
|
|
31
31
|
**Start here:** [install the core and set up a ledger](#start-here).
|
|
32
32
|
|
|
33
|
-
> **Status: alpha, published, and self-hosted.** `0.1.0-alpha.
|
|
34
|
-
> the `next` tag and on this repository's `v0.1.0-alpha.
|
|
33
|
+
> **Status: alpha, published, and self-hosted.** `0.1.0-alpha.13` is on npm under
|
|
34
|
+
> the `next` tag and on this repository's `v0.1.0-alpha.13` tag. It is the
|
|
35
35
|
> version this repository runs its own backlog on. The API is not frozen and the
|
|
36
36
|
> version will move before a stable release.
|
|
37
37
|
>
|
|
@@ -77,7 +77,7 @@ Wowbagger is the core authority for a Git-native work ledger. Use it instead
|
|
|
77
77
|
of editing ledger Markdown by hand.
|
|
78
78
|
|
|
79
79
|
```sh
|
|
80
|
-
wowbagger --version # require 0.1.0-alpha.
|
|
80
|
+
wowbagger --version # require 0.1.0-alpha.13
|
|
81
81
|
wowbagger capabilities --json # require contract_version: 5
|
|
82
82
|
wowbagger validate --ledger ledger --json
|
|
83
83
|
wowbagger ready --ledger ledger --as-of YYYY-MM-DD --json
|
|
@@ -103,10 +103,10 @@ cooperating writers; they are not exclusive locks.
|
|
|
103
103
|
Install the core CLI, then verify it. The core requires Node.js 20 or later:
|
|
104
104
|
|
|
105
105
|
```sh
|
|
106
|
-
npm install -g wowbagger@0.1.0-alpha.
|
|
106
|
+
npm install -g wowbagger@0.1.0-alpha.13 # exact plugin-matched release
|
|
107
107
|
# or, from this release's Git tag:
|
|
108
|
-
# npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.
|
|
109
|
-
wowbagger --version # 0.1.0-alpha.
|
|
108
|
+
# npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13
|
|
109
|
+
wowbagger --version # 0.1.0-alpha.13
|
|
110
110
|
wowbagger capabilities --json # must report contract_version: 5
|
|
111
111
|
```
|
|
112
112
|
|
|
@@ -325,7 +325,7 @@ two supported install routes:
|
|
|
325
325
|
registry requires a `latest` tag), so a bare install resolves to the same
|
|
326
326
|
bytes.
|
|
327
327
|
- **git tag** —
|
|
328
|
-
`npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.
|
|
328
|
+
`npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13` installs this
|
|
329
329
|
release. Installing at a ref installs the core and every adapter that ref
|
|
330
330
|
carries.
|
|
331
331
|
|
|
@@ -397,7 +397,7 @@ Upgrade the pieces you installed:
|
|
|
397
397
|
|
|
398
398
|
```sh
|
|
399
399
|
npm install -g wowbagger@next # public npm registry
|
|
400
|
-
npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.
|
|
400
|
+
npm install -g github:lstutzman/wowbagger#v0.1.0-alpha.13 # immutable Git release
|
|
401
401
|
git pull && npm ci # or: a direct checkout
|
|
402
402
|
```
|
|
403
403
|
|
|
@@ -360,6 +360,31 @@ next mutation refuses. Inside the window, only an actor that bypasses this tool
|
|
|
360
360
|
can overwrite the item, and this protocol does not defend against that actor.
|
|
361
361
|
It is merge-coordinated, not exclusive.
|
|
362
362
|
|
|
363
|
+
**Warning: the same window lets more than one worktree commit one item number,
|
|
364
|
+
and nothing repairs it** (items #181 and #182). Because create is
|
|
365
|
+
journal-silent, nothing coordinates the number a create derives. Any two
|
|
366
|
+
worktrees whose checkouts have not been integrated derive the next schema-v2
|
|
367
|
+
`number` from the base each can see, and both derive the same one. The two
|
|
368
|
+
creates need not overlap in time: a create in one worktree today and a create
|
|
369
|
+
in another worktree tomorrow collide just as surely, so long as neither
|
|
370
|
+
worktree has seen the other's commit. Both creates succeed, both refuse
|
|
371
|
+
nothing, and reconciliation reports nothing, because there is no recorded
|
|
372
|
+
revision to compare. The collision becomes visible only when the branches are
|
|
373
|
+
integrated: `validate` then fails globally with `duplicate-number` on every
|
|
374
|
+
colliding item, and an invalid ledger blocks every mutation, so the whole
|
|
375
|
+
ledger stops accepting work. `number` is immutable and `patch` correctly
|
|
376
|
+
rejects it, so this protocol currently offers no operation that repairs the
|
|
377
|
+
collision.
|
|
378
|
+
|
|
379
|
+
Until both items ship, serialize `create` through a single worktree, and run
|
|
380
|
+
`validate` immediately after you integrate branches so a collision surfaces at
|
|
381
|
+
the merge rather than at the next mutation. Editing `number` in the item
|
|
382
|
+
source by hand, committing it, and then running `claim-adopt` is **not a
|
|
383
|
+
supported workaround**: one field deployment did exactly that as an emergency
|
|
384
|
+
intervention during an outage, and it bypasses the number-collision and
|
|
385
|
+
reference checks every mutation performs, so it can leave dangling
|
|
386
|
+
`depends_on`, `related`, and parent references that nothing reports.
|
|
387
|
+
|
|
363
388
|
**`unauthorized-revision` has two remedies, and only one of them is
|
|
364
389
|
destructive.** Restoring the authorized revision discards the out-of-protocol
|
|
365
390
|
edit. Adopting the committed revision keeps it and moves the coordinator's
|
|
@@ -384,6 +409,57 @@ worktree; it remains `unauthorized-revision` and blocks unrelated mutations.
|
|
|
384
409
|
This distinguishes a real sibling's uncommitted successor from history the
|
|
385
410
|
current checkout already owns.
|
|
386
411
|
|
|
412
|
+
**Writer identity.** A legacy mutation and a claimed publication record the
|
|
413
|
+
worktree that authorized them in their journal entries as
|
|
414
|
+
`writer_worktree_id`: `legacy-mutation-intent`, `legacy-mutation`,
|
|
415
|
+
`publish-intent`, and `publish-final`. The field is optional on all of them: an
|
|
416
|
+
entry written before the field existed stays valid and attributes nothing. A
|
|
417
|
+
publication resolves its identity once under the claim lock, so its intent and
|
|
418
|
+
its terminal name one writer, and a terminal that recovery reconstructs after a
|
|
419
|
+
lost response carries the identity its intent recorded. When the journal names
|
|
420
|
+
the current worktree as the writer of the expected revision and no reachable
|
|
421
|
+
ref carries that revision, no sibling can ever produce it, so the finding is
|
|
422
|
+
`unauthorized-revision` and blocks every mutation instead of
|
|
423
|
+
`worktree-synchronization-required`, which blocks only its own item.
|
|
424
|
+
|
|
425
|
+
Recording the field on publication entries is an additive optional journal
|
|
426
|
+
change, so core `contract_version` stays `5` and every public request, success
|
|
427
|
+
envelope, and refusal envelope is unchanged. The item #122 lock-coarsening
|
|
428
|
+
parity golden, `test/publication-parity-baseline.json`, was regenerated to
|
|
429
|
+
record it: that recording is a work-claim contract change, never routine
|
|
430
|
+
fixture maintenance.
|
|
431
|
+
|
|
432
|
+
**What that identity discloses.** The identity is an opaque random UUID,
|
|
433
|
+
created once per worktree in that worktree's private Git directory. It contains
|
|
434
|
+
no path, branch, ref, hostname, user, or machine data. Journal entries are
|
|
435
|
+
projected into the tracked reconciliation log, so the identity persists in
|
|
436
|
+
committed Git history: any reader of that history can correlate every entry
|
|
437
|
+
written from one worktree, and an identity stays in that history indefinitely
|
|
438
|
+
after the worktree it named is removed. It never becomes a claim owner, and no
|
|
439
|
+
protocol decision reads it other than the writer comparison above.
|
|
440
|
+
|
|
441
|
+
**Ambiguous identity.** A UUID names one worktree. Before it reasons from a
|
|
442
|
+
recorded writer, the coordinator enumerates the worktrees Git currently reports
|
|
443
|
+
live and reads the identity each one already holds; it creates none. Two live
|
|
444
|
+
worktrees answering to one UUID, or a roster the coordinator could not finish
|
|
445
|
+
reading, refuses `claim-verify`, every claim-protected mutation,
|
|
446
|
+
`publish-claimed`, and `claim-adopt` before anything is classified or written.
|
|
447
|
+
The refusal keeps the existing exit `6`, `claim-store-unavailable`,
|
|
448
|
+
`claim-store-unreadable`, `state: "unchanged"` form and adds one
|
|
449
|
+
`error.details.identity_diagnostic`: `code: "duplicate-worktree-identity"` with
|
|
450
|
+
`worktree_id` and `live_worktree_count`, or `code:
|
|
451
|
+
"worktree-enumeration-failed"` with no further member. Auto-commit surfaces the
|
|
452
|
+
same diagnostic inside its `auto-commit-preflight-failed` details with
|
|
453
|
+
`retryable: false`. Nothing else about the envelope changes, and core
|
|
454
|
+
`contract_version` stays `5`.
|
|
455
|
+
|
|
456
|
+
Detection covers only what Git reports live. A worktree Git marks prunable —
|
|
457
|
+
including one whose path is temporarily unavailable or unmounted — is excluded,
|
|
458
|
+
so a duplicate identity held there is not detected until Git sees that worktree
|
|
459
|
+
live again. Removing a worktree removes the private Git directory that held its
|
|
460
|
+
identity, and nothing restores it: a worktree recreated at the same path earns
|
|
461
|
+
a new UUID, and the removed UUID stays in journal history attributing nothing.
|
|
462
|
+
|
|
387
463
|
### 3.2 Recovering from a foreign-writer block
|
|
388
464
|
|
|
389
465
|
1. Stop writing the affected item in the blocked worktree. Unrelated item
|
|
@@ -452,10 +528,12 @@ answering with `namespace: "work-claim"`, `command: "claim-adopt"`, and
|
|
|
452
528
|
mutation runs and no item file changes. It is not a `claim-verify` flag, because
|
|
453
529
|
`claim-verify` is the read-mostly reconciliation report every remediation string
|
|
454
530
|
names, and one command name must not mean both "tell me the state" and "change
|
|
455
|
-
the authorization baseline". It is not a `claim` subcommand, because
|
|
456
|
-
|
|
457
|
-
reconciliation reports blocking
|
|
458
|
-
that state, so it runs while
|
|
531
|
+
the authorization baseline". It is not a `claim` subcommand, because
|
|
532
|
+
`work-claim.acquire` and `work-claim.renew` refuse with
|
|
533
|
+
`publication-reconciliation-required` while reconciliation reports blocking
|
|
534
|
+
findings, and adoption exists to clear exactly that state, so it runs while
|
|
535
|
+
those findings stand. `work-claim.release` also runs while they stand, for the
|
|
536
|
+
narrower reason that surrendering a lease takes no authority (section 5).
|
|
459
537
|
|
|
460
538
|
The request is UTF-8 JSON with exactly these members:
|
|
461
539
|
|
|
@@ -602,6 +680,22 @@ member at any depth, and exactly the listed members. Unknown members, wrong
|
|
|
602
680
|
types, noncanonical values, and unprovisioned namespaces are exit 2
|
|
603
681
|
`invalid-request`; no authoritative lease decision has then occurred.
|
|
604
682
|
|
|
683
|
+
**A reconciliation barrier stops a caller from taking or extending authority,
|
|
684
|
+
never from surrendering it.** `work-claim.acquire` and `work-claim.renew` MUST
|
|
685
|
+
refuse with exit 6 `claim-store-unavailable`, reason
|
|
686
|
+
`publication-reconciliation-required`, and the reconciliation `findings`,
|
|
687
|
+
whenever reconciliation reports a finding that blocks the request's item.
|
|
688
|
+
`work-claim.release` MUST NOT refuse for that reason. The holder has to be able
|
|
689
|
+
to hand the lease back: no other worktree can take the item over while the
|
|
690
|
+
claim is held, and the worktree holding it is often the one least able to clear
|
|
691
|
+
the barrier. Refusing the surrender strands the lease and the item with it.
|
|
692
|
+
|
|
693
|
+
Release keeps every other refusal. An identity the domain cannot resolve, a
|
|
694
|
+
journal it cannot read, and a clock floor it cannot persist all refuse before
|
|
695
|
+
any lease decision, and the owner, epoch, and expected-expiry CAS tuple below
|
|
696
|
+
still rules on the request: a mismatch is exit 4 `claim-conflict` and the claim
|
|
697
|
+
stays held. A barrier never turns a release into an unconditional discard.
|
|
698
|
+
|
|
605
699
|
### Read
|
|
606
700
|
|
|
607
701
|
`work-claim.read` accepts exactly:
|
|
@@ -673,6 +767,10 @@ It uses the same precedence. Success sets `active` to `null`, retains
|
|
|
673
767
|
`last_epoch`, and returns `released_claim` plus `read_back`. A later acquire
|
|
674
768
|
must allocate a greater epoch, preventing ABA even across restart.
|
|
675
769
|
|
|
770
|
+
A reconciliation barrier does not refuse a release; it refuses only acquire and
|
|
771
|
+
renew (section 5 preamble). The CAS tuple still applies, so a release under a
|
|
772
|
+
barrier is exactly as conditional as a release without one.
|
|
773
|
+
|
|
676
774
|
Success envelopes for these three commands have exactly `ok`, `namespace`,
|
|
677
775
|
`command`, `contract_version`, `state: "committed"`, and `result`. Semantic
|
|
678
776
|
failures replace `result` with `error`, use `state: "unchanged"`, and include
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wowbagger",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.13",
|
|
4
4
|
"description": "Git-native work ledger for coding agents: deterministic ready queues, guarded CAS mutations, claims and fencing, and self-contained HTML reports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,7 @@ publish something.
|
|
|
14
14
|
Install the core separately before using this skill:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
-
npm install -g wowbagger@0.1.0-alpha.
|
|
17
|
+
npm install -g wowbagger@0.1.0-alpha.13
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
The core requires Node.js 20 or later. This plugin ships only agent
|
|
@@ -34,9 +34,9 @@ wowbagger capabilities --json
|
|
|
34
34
|
|
|
35
35
|
Read the plain distribution version from the first command and the top-level
|
|
36
36
|
`contract_version` from the second. **This skill requires distribution version
|
|
37
|
-
`0.1.0-alpha.
|
|
37
|
+
`0.1.0-alpha.13` and core `contract_version: 5`.**
|
|
38
38
|
|
|
39
|
-
The distribution pin names the published `0.1.0-alpha.
|
|
39
|
+
The distribution pin names the published `0.1.0-alpha.13` release; the cut that
|
|
40
40
|
publishes core `contract_version: 5` moves it. Earlier cores report
|
|
41
41
|
`contract_version: 3` or lower and lack behavior this skill requires, including
|
|
42
42
|
the bounded item source, so the version check refuses them. Do not soften
|
package/src/claim-coordinator.js
CHANGED
|
@@ -14,6 +14,11 @@ import { readBack } from './claim-operations.js';
|
|
|
14
14
|
import { reconcileClaimJournal } from './claim-publication.js';
|
|
15
15
|
import { claimStorePath, resolveVerifiedGitCommonDir, withClaimLock } from './claim-store.js';
|
|
16
16
|
import { readNamespace } from './namespace.js';
|
|
17
|
+
import {
|
|
18
|
+
assertUniqueWorktreeIdentity,
|
|
19
|
+
ensureWorktreeIdentity,
|
|
20
|
+
identityDiagnosticDetails,
|
|
21
|
+
} from './worktree-identity.js';
|
|
17
22
|
|
|
18
23
|
export async function withLegacyMutationFence(
|
|
19
24
|
ledgerDirectory,
|
|
@@ -37,12 +42,22 @@ export async function withLegacyMutationFence(
|
|
|
37
42
|
let intent = null;
|
|
38
43
|
try {
|
|
39
44
|
return await withClaimLock(storePath, async () => {
|
|
45
|
+
// The writer's own identity must exist before anything it does can be
|
|
46
|
+
// attributed, so it is established under the same lock that serializes
|
|
47
|
+
// the journal, ahead of reconciliation and of any intent append. Its own
|
|
48
|
+
// file is judged first, so bytes this worktree owns keep reporting as its
|
|
49
|
+
// own invalid identity rather than as an unreadable roster.
|
|
50
|
+
const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
|
|
51
|
+
// Then the domain: a UUID two live worktrees answer to attributes
|
|
52
|
+
// nothing, so nothing may be written from it.
|
|
53
|
+
await assertUniqueWorktreeIdentity({ ledgerDirectory });
|
|
40
54
|
const replayed = await replayClaimJournal(journalPath, namespace);
|
|
41
55
|
const reconciled = await reconcileClaimJournal({
|
|
42
56
|
ledgerDirectory,
|
|
43
57
|
gitCommonDir,
|
|
44
58
|
namespace,
|
|
45
59
|
replayed,
|
|
60
|
+
currentWorktreeId,
|
|
46
61
|
physicalNow: new Date().toISOString(),
|
|
47
62
|
targetItemId: itemId,
|
|
48
63
|
writeLogOnUnsafe: false,
|
|
@@ -76,6 +91,7 @@ export async function withLegacyMutationFence(
|
|
|
76
91
|
expected_revision: expectedRevision,
|
|
77
92
|
candidate_revision: candidateRevision,
|
|
78
93
|
item_path: itemPath,
|
|
94
|
+
writer_worktree_id: currentWorktreeId,
|
|
79
95
|
observed_at: observedAt,
|
|
80
96
|
};
|
|
81
97
|
const terminalEntry = {
|
|
@@ -86,6 +102,7 @@ export async function withLegacyMutationFence(
|
|
|
86
102
|
command,
|
|
87
103
|
committed_revision: candidateRevision,
|
|
88
104
|
item_path: itemPath,
|
|
105
|
+
writer_worktree_id: currentWorktreeId,
|
|
89
106
|
observed_at: observedAt,
|
|
90
107
|
};
|
|
91
108
|
const abortEntry = {
|
|
@@ -148,6 +165,7 @@ export async function withLegacyMutationFence(
|
|
|
148
165
|
committed_revision: committedRevision,
|
|
149
166
|
observed_at: observedAt,
|
|
150
167
|
item_path: intent.item_path,
|
|
168
|
+
writer_worktree_id: currentWorktreeId,
|
|
151
169
|
}));
|
|
152
170
|
} catch {
|
|
153
171
|
return claimStoreUnavailable(responseCommand, 'legacy-mutation-record-failed', {
|
|
@@ -192,7 +210,7 @@ export async function withLegacyMutationFence(
|
|
|
192
210
|
} catch (error) {
|
|
193
211
|
return claimStoreUnavailable(responseCommand, error?.code === 'CLAIM_LOCK_HELD'
|
|
194
212
|
? 'claim-store-locked'
|
|
195
|
-
: 'claim-store-unreadable',
|
|
213
|
+
: 'claim-store-unreadable', identityDiagnosticDetails(error), intent ? 'unknown' : 'unchanged');
|
|
196
214
|
}
|
|
197
215
|
}
|
|
198
216
|
|
package/src/claim-journal.js
CHANGED
|
@@ -286,6 +286,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
286
286
|
&& typeof entry.expected_revision === 'string'
|
|
287
287
|
&& typeof entry.candidate_revision === 'string'
|
|
288
288
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
289
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
290
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
289
291
|
&& typeof entry.observed_at === 'string';
|
|
290
292
|
}
|
|
291
293
|
if (entry.type === 'legacy-mutation') {
|
|
@@ -296,6 +298,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
296
298
|
&& ['patch-v1', 'transition-v1'].includes(entry.command)
|
|
297
299
|
&& typeof entry.committed_revision === 'string'
|
|
298
300
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
301
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
302
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
299
303
|
&& typeof entry.observed_at === 'string';
|
|
300
304
|
}
|
|
301
305
|
if (entry.type === 'legacy-mutation-abort') {
|
|
@@ -325,6 +329,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
325
329
|
&& typeof entry.expected_revision === 'string'
|
|
326
330
|
&& typeof entry.candidate_sha256 === 'string'
|
|
327
331
|
&& (!Object.hasOwn(entry, 'item_path') || typeof entry.item_path === 'string')
|
|
332
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
333
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
328
334
|
&& isRecord(entry.fence)
|
|
329
335
|
&& (namespace === null || entry.fence.ledger_namespace === namespace)
|
|
330
336
|
&& entry.fence.item_id === entry.item_id;
|
|
@@ -335,6 +341,8 @@ function validJournalEntry(entry, namespace) {
|
|
|
335
341
|
&& typeof entry.ledger_namespace === 'string'
|
|
336
342
|
&& (namespace === null || entry.ledger_namespace === namespace)
|
|
337
343
|
&& typeof entry.item_id === 'string'
|
|
344
|
+
&& (!Object.hasOwn(entry, 'writer_worktree_id')
|
|
345
|
+
|| typeof entry.writer_worktree_id === 'string')
|
|
338
346
|
&& validPublicationOutcome(entry);
|
|
339
347
|
}
|
|
340
348
|
return typeof entry.operation_id === 'string'
|
package/src/claim-publication.js
CHANGED
|
@@ -19,6 +19,12 @@ import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
|
|
|
19
19
|
import { findRevisionOwner, readGitHeadLedger, readGitTreeFile } from './git-reconciliation.js';
|
|
20
20
|
import { publishClaimedCandidate, revisionFor } from './mutation.js';
|
|
21
21
|
import { validateLedger } from './validate.js';
|
|
22
|
+
import {
|
|
23
|
+
assertUniqueWorktreeIdentity,
|
|
24
|
+
ensureWorktreeIdentity,
|
|
25
|
+
identityDiagnosticDetails,
|
|
26
|
+
readWorktreeIdentity,
|
|
27
|
+
} from './worktree-identity.js';
|
|
22
28
|
|
|
23
29
|
const ITEM_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
24
30
|
const NAMESPACE_ID = /^wbns_[a-f0-9]{32}$/;
|
|
@@ -179,6 +185,15 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
179
185
|
// file, so the first of them is the snapshot the rest share. The read
|
|
180
186
|
// under lock, which the mutation engine still performs on its own, is
|
|
181
187
|
// what makes the revision compare-and-swap meaningful.
|
|
188
|
+
//
|
|
189
|
+
// A claimed publication authorizes an item revision, so an ambiguous
|
|
190
|
+
// domain is refused before reconciliation classifies one. The writer
|
|
191
|
+
// names itself first, under this same hold, so a domain that cannot
|
|
192
|
+
// resolve its own identity refuses as its own invalid identity rather
|
|
193
|
+
// than as an unreadable roster — and so every entry this hold appends
|
|
194
|
+
// carries one ID resolved exactly once.
|
|
195
|
+
const currentWorktreeId = await ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir });
|
|
196
|
+
await assertUniqueWorktreeIdentity({ ledgerDirectory });
|
|
182
197
|
let reconciled;
|
|
183
198
|
try {
|
|
184
199
|
reconciled = await reconcileClaimJournal({
|
|
@@ -188,6 +203,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
188
203
|
replayed,
|
|
189
204
|
physicalNow: new Date().toISOString(),
|
|
190
205
|
targetItemId: request.item_id,
|
|
206
|
+
currentWorktreeId,
|
|
191
207
|
writeLogOnUnsafe: false,
|
|
192
208
|
});
|
|
193
209
|
} catch (error) {
|
|
@@ -237,7 +253,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
237
253
|
active_owner_id: record.active?.owner_id ?? null,
|
|
238
254
|
active_epoch: record.active?.epoch ?? null,
|
|
239
255
|
}, 4);
|
|
240
|
-
return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
|
|
256
|
+
return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
|
|
241
257
|
}
|
|
242
258
|
await publicationTestCheckpoint(scenario, 'before-publish-intent', ledgerDirectory);
|
|
243
259
|
const intent = await appendClaimEntry(journalPath, {
|
|
@@ -249,6 +265,7 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
249
265
|
candidate_sha256: request.candidate_sha256,
|
|
250
266
|
fence: request.claim_fence,
|
|
251
267
|
floor: observedAt,
|
|
268
|
+
writer_worktree_id: currentWorktreeId,
|
|
252
269
|
state: 'pending',
|
|
253
270
|
});
|
|
254
271
|
entries.push(intent);
|
|
@@ -258,11 +275,11 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
258
275
|
});
|
|
259
276
|
if (!mutation.ok) {
|
|
260
277
|
const outcome = mutationFailure(request, mutation);
|
|
261
|
-
return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
|
|
278
|
+
return persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
|
|
262
279
|
}
|
|
263
280
|
await publicationTestCheckpoint(scenario, 'after-ledger-commit', ledgerDirectory);
|
|
264
281
|
const outcome = publicationSuccess(request, record, observedAt, mutation.item?.path, namespace);
|
|
265
|
-
const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath);
|
|
282
|
+
const terminal = await persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, replayed.state, storePath, currentWorktreeId);
|
|
266
283
|
await publicationTestCheckpoint(scenario, 'after-terminal-record', ledgerDirectory);
|
|
267
284
|
return terminal;
|
|
268
285
|
});
|
|
@@ -272,6 +289,15 @@ export async function publishClaimed({ ledgerDirectory, gitCommonDir, namespace,
|
|
|
272
289
|
reason: 'claim-store-locked',
|
|
273
290
|
}, 6);
|
|
274
291
|
}
|
|
292
|
+
// An identity the coordination domain cannot resolve is a store this
|
|
293
|
+
// command could not read, not a mutation whose outcome is unknown: it
|
|
294
|
+
// refused before it appended an intent or touched an item byte.
|
|
295
|
+
if (error?.code === 'CLAIM_WORKTREE_IDENTITY_INVALID') {
|
|
296
|
+
return publicationError(request, 'claim-store-unavailable', 'The durable claim store is unavailable.', {
|
|
297
|
+
reason: 'claim-store-unreadable',
|
|
298
|
+
...identityDiagnosticDetails(error),
|
|
299
|
+
}, 6);
|
|
300
|
+
}
|
|
275
301
|
return publicationUnknown(request);
|
|
276
302
|
}
|
|
277
303
|
}
|
|
@@ -350,6 +376,10 @@ export async function reconcileClaimJournal({
|
|
|
350
376
|
namespace,
|
|
351
377
|
replayed,
|
|
352
378
|
physicalNow,
|
|
379
|
+
// The worktree this invocation speaks for, when it has established an
|
|
380
|
+
// identity. A caller that cannot name itself passes null, and every
|
|
381
|
+
// recorded writer then reads as unknown.
|
|
382
|
+
currentWorktreeId = null,
|
|
353
383
|
targetItemId = null,
|
|
354
384
|
writeLogOnUnsafe = true,
|
|
355
385
|
writeLogWhenEmpty = true,
|
|
@@ -407,6 +437,12 @@ export async function reconcileClaimJournal({
|
|
|
407
437
|
command: intent.command,
|
|
408
438
|
committed_revision: actualRevision,
|
|
409
439
|
...(intent.item_path ? { item_path: intent.item_path } : {}),
|
|
440
|
+
// The writer that authorized the attempt owns the revision this
|
|
441
|
+
// resolution ratifies, so its identity travels with the terminal
|
|
442
|
+
// entry. Absent on alpha.12 intents, which stay valid without it.
|
|
443
|
+
...(intent.writer_worktree_id
|
|
444
|
+
? { writer_worktree_id: intent.writer_worktree_id }
|
|
445
|
+
: {}),
|
|
410
446
|
observed_at: observedAt,
|
|
411
447
|
}));
|
|
412
448
|
} else if (actualRevision === intent.expected_revision) {
|
|
@@ -496,6 +532,12 @@ export async function reconcileClaimJournal({
|
|
|
496
532
|
operation_digest: intent.operation_digest,
|
|
497
533
|
ledger_namespace: namespace,
|
|
498
534
|
item_id: intent.item_id,
|
|
535
|
+
// The writer that authorized the publication owns the revision this
|
|
536
|
+
// resolution ratifies, so its identity travels with the terminal entry.
|
|
537
|
+
// Absent on alpha.12 intents, which stay valid without it.
|
|
538
|
+
...(intent.writer_worktree_id
|
|
539
|
+
? { writer_worktree_id: intent.writer_worktree_id }
|
|
540
|
+
: {}),
|
|
499
541
|
outcome,
|
|
500
542
|
}));
|
|
501
543
|
const unknownPath = itemPathRelativeToLedger(ledgerDirectory, item?.file);
|
|
@@ -608,6 +650,13 @@ export async function reconcileClaimJournal({
|
|
|
608
650
|
});
|
|
609
651
|
continue;
|
|
610
652
|
}
|
|
653
|
+
// Who the journal says wrote the authorized revision, judged against who
|
|
654
|
+
// is asking. An entry from before writer identity existed, or a caller
|
|
655
|
+
// that cannot name itself, leaves the writer unknown.
|
|
656
|
+
const recordedWriter = latestAuthorized.writer_worktree_id ?? null;
|
|
657
|
+
const expectedWriter = recordedWriter === null || currentWorktreeId === null
|
|
658
|
+
? 'unknown'
|
|
659
|
+
: recordedWriter === currentWorktreeId ? 'current' : 'other';
|
|
611
660
|
const diagnosis = await reconciliationDiagnosis({
|
|
612
661
|
ledgerDirectory,
|
|
613
662
|
actualRevision,
|
|
@@ -615,6 +664,7 @@ export async function reconcileClaimJournal({
|
|
|
615
664
|
expectedPath,
|
|
616
665
|
expectedRevision,
|
|
617
666
|
headRevision,
|
|
667
|
+
expectedWriter,
|
|
618
668
|
workingTreeChanged,
|
|
619
669
|
});
|
|
620
670
|
findings.push({
|
|
@@ -755,14 +805,26 @@ function blocksTarget(finding, targetItemId) {
|
|
|
755
805
|
// Ownership is evidence, never a guess: an unreadable history or a missing
|
|
756
806
|
// expected path leaves the revision unattributed instead of naming a ref.
|
|
757
807
|
async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision) {
|
|
758
|
-
if (!expectedPath) return {
|
|
808
|
+
if (!expectedPath) return { kind: 'unreachable' };
|
|
759
809
|
try {
|
|
760
810
|
return await findRevisionOwner(ledgerDirectory, expectedPath, expectedRevision);
|
|
761
811
|
} catch {
|
|
762
|
-
return {
|
|
812
|
+
return { kind: 'unreachable' };
|
|
763
813
|
}
|
|
764
814
|
}
|
|
765
815
|
|
|
816
|
+
// One owner, one shape: only an active named worktree reaches this finding, and
|
|
817
|
+
// the remediation names the same ref and commit the finding does.
|
|
818
|
+
function namedOwnerFinding(owner, expectedPath) {
|
|
819
|
+
return {
|
|
820
|
+
reason: 'worktree-synchronization-required',
|
|
821
|
+
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
822
|
+
owner_ref: owner.ref,
|
|
823
|
+
owner_commit: owner.commit,
|
|
824
|
+
remediation: `WAIT for owner ${owner.ref} to publish ${owner.commit}, then synchronize this worktree and run claim-verify.`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
766
828
|
async function reconciliationDiagnosis({
|
|
767
829
|
ledgerDirectory,
|
|
768
830
|
actualRevision,
|
|
@@ -770,6 +832,7 @@ async function reconciliationDiagnosis({
|
|
|
770
832
|
expectedPath,
|
|
771
833
|
expectedRevision,
|
|
772
834
|
headRevision,
|
|
835
|
+
expectedWriter,
|
|
773
836
|
workingTreeChanged,
|
|
774
837
|
}) {
|
|
775
838
|
const pathLabel = expectedPath ?? 'the item path';
|
|
@@ -791,15 +854,7 @@ async function reconciliationDiagnosis({
|
|
|
791
854
|
if (headRevision !== null && actualRevision === headRevision
|
|
792
855
|
&& headRevision !== expectedRevision) {
|
|
793
856
|
expectedOwner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
794
|
-
if (expectedOwner.
|
|
795
|
-
return {
|
|
796
|
-
reason: 'worktree-synchronization-required',
|
|
797
|
-
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
798
|
-
owner_ref: expectedOwner.owner_ref,
|
|
799
|
-
owner_commit: expectedOwner.owner_commit,
|
|
800
|
-
remediation: `WAIT for owner ${expectedOwner.owner_ref} to publish ${expectedOwner.owner_commit}, then synchronize this worktree and run claim-verify.`,
|
|
801
|
-
};
|
|
802
|
-
}
|
|
857
|
+
if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
|
|
803
858
|
}
|
|
804
859
|
if (!workingTreeChanged && headRevision !== expectedRevision) {
|
|
805
860
|
return {
|
|
@@ -810,30 +865,23 @@ async function reconciliationDiagnosis({
|
|
|
810
865
|
}
|
|
811
866
|
if (actualRevision === null && headRevision !== expectedRevision) {
|
|
812
867
|
const owner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
813
|
-
|
|
814
|
-
? { owner_ref: owner.owner_ref, owner_commit: owner.owner_commit }
|
|
815
|
-
: { owner_unavailable: true };
|
|
868
|
+
if (owner.kind === 'named-sibling') return namedOwnerFinding(owner, expectedPath);
|
|
816
869
|
return {
|
|
817
870
|
reason: 'worktree-synchronization-required',
|
|
818
871
|
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
819
|
-
|
|
820
|
-
remediation:
|
|
821
|
-
? `WAIT for owner ${owner.owner_ref} to publish ${owner.owner_commit}, then synchronize this worktree and run claim-verify.`
|
|
822
|
-
: `Ownership of ${pathLabel} revision ${expectedRevision} cannot be established from reachable refs; inspect reachable or dangling commits, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
|
|
872
|
+
owner_unavailable: true,
|
|
873
|
+
remediation: `Ownership of ${pathLabel} revision ${expectedRevision} cannot be established from reachable refs; inspect reachable or dangling commits, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
|
|
823
874
|
};
|
|
824
875
|
}
|
|
825
876
|
if (authorizedRevisions.has(actualRevision)) {
|
|
826
877
|
expectedOwner ??= await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
827
|
-
if (expectedOwner.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
if (expectedOwner.owner_current_ref !== true) {
|
|
878
|
+
if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
|
|
879
|
+
// Advice to wait for an owning worktree needs an owner that could still
|
|
880
|
+
// appear. When the journal names this worktree as the writer of the
|
|
881
|
+
// expected revision and no active worktree carries it, the successor exists
|
|
882
|
+
// nowhere but in the journal: there is nothing to synchronize from, and the
|
|
883
|
+
// local bytes are simply unauthorized.
|
|
884
|
+
if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
|
|
837
885
|
return {
|
|
838
886
|
reason: 'worktree-synchronization-required',
|
|
839
887
|
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
@@ -913,12 +961,22 @@ export async function verifyClaimJournal({
|
|
|
913
961
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
914
962
|
try {
|
|
915
963
|
return await withClaimLock(storePath, async () => {
|
|
964
|
+
// Verification writes no item byte and creates no identity: it reports on
|
|
965
|
+
// the worktree it runs in, and an unidentified worktree simply cannot
|
|
966
|
+
// recognize itself in a recorded writer. Its own file is judged first, so
|
|
967
|
+
// bytes this worktree owns keep reporting as its own invalid identity
|
|
968
|
+
// rather than as an unreadable roster.
|
|
969
|
+
const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
|
|
970
|
+
// Verification reasons from recorded writers, so it refuses an ambiguous
|
|
971
|
+
// domain before it classifies anything, exactly as a mutation does.
|
|
972
|
+
await assertUniqueWorktreeIdentity({ ledgerDirectory });
|
|
916
973
|
const reconciled = await reconcileClaimJournal({
|
|
917
974
|
ledgerDirectory,
|
|
918
975
|
gitCommonDir,
|
|
919
976
|
namespace,
|
|
920
977
|
replayed: await replayClaimJournal(journalPath, namespace),
|
|
921
978
|
physicalNow: new Date().toISOString(),
|
|
979
|
+
currentWorktreeId,
|
|
922
980
|
targetItemId,
|
|
923
981
|
writeLogWhenEmpty,
|
|
924
982
|
writeLogOnUnsafe,
|
|
@@ -957,6 +1015,7 @@ export async function verifyClaimJournal({
|
|
|
957
1015
|
reason: error?.code === 'CLAIM_LOCK_HELD'
|
|
958
1016
|
? 'claim-store-locked'
|
|
959
1017
|
: 'claim-store-unreadable',
|
|
1018
|
+
...identityDiagnosticDetails(error),
|
|
960
1019
|
},
|
|
961
1020
|
},
|
|
962
1021
|
},
|
|
@@ -968,20 +1027,30 @@ export async function verifyClaimJournal({
|
|
|
968
1027
|
// re-baselines the coordinator's authorized revision onto bytes that are
|
|
969
1028
|
// already committed, and writes no item byte. It runs while reconciliation is
|
|
970
1029
|
// unsafe on purpose — clearing that state is the point — so it never refuses on
|
|
971
|
-
// `publication-reconciliation-required` the way
|
|
972
|
-
//
|
|
973
|
-
// reconciliation just observed.
|
|
1030
|
+
// `publication-reconciliation-required` the way `claim acquire` or
|
|
1031
|
+
// `claim renew` do. Every precondition is checked under the claim lock against
|
|
1032
|
+
// the state reconciliation just observed.
|
|
974
1033
|
export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespace, request }) {
|
|
975
1034
|
const storePath = claimStorePath(gitCommonDir, namespace);
|
|
976
1035
|
const journalPath = claimJournalPath(gitCommonDir, namespace);
|
|
977
1036
|
try {
|
|
978
1037
|
return await withClaimLock(storePath, async () => {
|
|
1038
|
+
// Adoption writes no item byte and creates no identity, so it names
|
|
1039
|
+
// itself by reading the ID it already answers to. Its own file is judged
|
|
1040
|
+
// first, so bytes this worktree owns keep reporting as its own invalid
|
|
1041
|
+
// identity rather than as an unreadable roster, exactly as claim-verify
|
|
1042
|
+
// reports them.
|
|
1043
|
+
const currentWorktreeId = await readWorktreeIdentity({ ledgerDirectory, gitCommonDir });
|
|
1044
|
+
// Adoption re-baselines an authorized revision, so it too refuses an
|
|
1045
|
+
// ambiguous domain before reconciliation classifies one.
|
|
1046
|
+
await assertUniqueWorktreeIdentity({ ledgerDirectory });
|
|
979
1047
|
const reconciled = await reconcileClaimJournal({
|
|
980
1048
|
ledgerDirectory,
|
|
981
1049
|
gitCommonDir,
|
|
982
1050
|
namespace,
|
|
983
1051
|
replayed: await replayClaimJournal(journalPath, namespace),
|
|
984
1052
|
physicalNow: new Date().toISOString(),
|
|
1053
|
+
currentWorktreeId,
|
|
985
1054
|
});
|
|
986
1055
|
const refusal = adoptionRefusal(reconciled, ledgerDirectory, request);
|
|
987
1056
|
if (refusal) return refusal;
|
|
@@ -1050,6 +1119,7 @@ export async function adoptItemRevision({ ledgerDirectory, gitCommonDir, namespa
|
|
|
1050
1119
|
reason: error?.code === 'CLAIM_LOCK_HELD'
|
|
1051
1120
|
? 'claim-store-locked'
|
|
1052
1121
|
: 'claim-store-unreadable',
|
|
1122
|
+
...identityDiagnosticDetails(error),
|
|
1053
1123
|
},
|
|
1054
1124
|
},
|
|
1055
1125
|
},
|
|
@@ -1156,13 +1226,14 @@ export function operationDigest(request) {
|
|
|
1156
1226
|
return `sha256:${createHash('sha256').update(canonicalJson(request)).digest('hex')}`;
|
|
1157
1227
|
}
|
|
1158
1228
|
|
|
1159
|
-
async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath) {
|
|
1229
|
+
async function persistTerminal(entries, journalPath, ledgerDirectory, namespace, request, outcome, state, storePath, writerWorktreeId) {
|
|
1160
1230
|
const terminal = await appendClaimEntry(journalPath, {
|
|
1161
1231
|
type: 'publish-final',
|
|
1162
1232
|
operation_id: request.operation_id,
|
|
1163
1233
|
operation_digest: operationDigest(request),
|
|
1164
1234
|
ledger_namespace: request.ledger_namespace,
|
|
1165
1235
|
item_id: request.item_id,
|
|
1236
|
+
writer_worktree_id: writerWorktreeId,
|
|
1166
1237
|
outcome,
|
|
1167
1238
|
});
|
|
1168
1239
|
entries.push(terminal);
|
package/src/claim-store.js
CHANGED
|
@@ -127,7 +127,12 @@ export function namespaceLockHeld(hold, storePath) {
|
|
|
127
127
|
&& hold.storePath === storePath;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// `withClaimLock` is the one file-lock primitive; the counter name says which
|
|
131
|
+
// lock a caller is taking, so a profile can hold each one to its own number
|
|
132
|
+
// instead of watching a single total drift.
|
|
133
|
+
export async function withClaimLock(storePath, fn, {
|
|
134
|
+
counter = 'namespace_lock_acquisitions',
|
|
135
|
+
} = {}) {
|
|
131
136
|
const directory = path.dirname(storePath);
|
|
132
137
|
await mkdir(directory, { recursive: true });
|
|
133
138
|
const lockPath = `${storePath}.lock`;
|
|
@@ -143,7 +148,7 @@ export async function withClaimLock(storePath, fn) {
|
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
try {
|
|
146
|
-
await acquireClaimLock(candidatePath, lockPath, recoveryPath);
|
|
151
|
+
await acquireClaimLock(candidatePath, lockPath, recoveryPath, counter);
|
|
147
152
|
const hold = { [NAMESPACE_LOCK_HOLD]: true, storePath, released: false };
|
|
148
153
|
try {
|
|
149
154
|
return await fn(hold);
|
|
@@ -158,7 +163,7 @@ export async function withClaimLock(storePath, fn) {
|
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
|
|
161
|
-
async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
|
|
166
|
+
async function acquireClaimLock(candidatePath, lockPath, recoveryPath, counter) {
|
|
162
167
|
for (;;) {
|
|
163
168
|
const recoveryOwner = await readLockOwner(recoveryPath);
|
|
164
169
|
if (recoveryOwner) {
|
|
@@ -168,7 +173,7 @@ async function acquireClaimLock(candidatePath, lockPath, recoveryPath) {
|
|
|
168
173
|
}
|
|
169
174
|
try {
|
|
170
175
|
await link(candidatePath, lockPath);
|
|
171
|
-
recordCount(
|
|
176
|
+
recordCount(counter);
|
|
172
177
|
return;
|
|
173
178
|
} catch (error) {
|
|
174
179
|
if (error?.code !== 'EEXIST') throw error;
|
package/src/cli.js
CHANGED
|
@@ -82,6 +82,7 @@ import { normalizeJsonValue, parseJsonRequest, sortIssues } from './request.js';
|
|
|
82
82
|
import { selectReady } from './ready.js';
|
|
83
83
|
import { isCalendarDate, validateLedger } from './validate.js';
|
|
84
84
|
import { inspectWorkbench } from './workbench.js';
|
|
85
|
+
import { readWorktreeIdentity } from './worktree-identity.js';
|
|
85
86
|
|
|
86
87
|
const CLAIM_OPERATIONS = { read: claimRead, acquire: claimAcquire, renew: claimRenew, release: claimRelease };
|
|
87
88
|
const MUTATION_CONTRACT_VERSION = 5;
|
|
@@ -1659,6 +1660,21 @@ async function runClaimCommand(claimCommand, argumentsList) {
|
|
|
1659
1660
|
} catch (error) {
|
|
1660
1661
|
throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
|
|
1661
1662
|
}
|
|
1663
|
+
// A claim lifecycle command classifies item reconciliation and refuses
|
|
1664
|
+
// on it, so it must reason from the same writer evidence every other
|
|
1665
|
+
// classifying surface uses; otherwise one command grants a claim on
|
|
1666
|
+
// exactly the state another command refuses to write. It writes no item
|
|
1667
|
+
// byte and no writer-attributed entry, so it reads the identity it
|
|
1668
|
+
// already answers to rather than creating one.
|
|
1669
|
+
let currentWorktreeId;
|
|
1670
|
+
try {
|
|
1671
|
+
currentWorktreeId = await readWorktreeIdentity({
|
|
1672
|
+
ledgerDirectory: parsedOptions.options.ledger,
|
|
1673
|
+
gitCommonDir,
|
|
1674
|
+
});
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
|
|
1677
|
+
}
|
|
1662
1678
|
const physicalNow = new Date().toISOString();
|
|
1663
1679
|
let reconciled;
|
|
1664
1680
|
try {
|
|
@@ -1669,6 +1685,7 @@ async function runClaimCommand(claimCommand, argumentsList) {
|
|
|
1669
1685
|
replayed,
|
|
1670
1686
|
physicalNow,
|
|
1671
1687
|
targetItemId: request.item_id,
|
|
1688
|
+
currentWorktreeId,
|
|
1672
1689
|
writeLogOnUnsafe: false,
|
|
1673
1690
|
});
|
|
1674
1691
|
} catch (error) {
|
|
@@ -1679,7 +1696,15 @@ async function runClaimCommand(claimCommand, argumentsList) {
|
|
|
1679
1696
|
error,
|
|
1680
1697
|
);
|
|
1681
1698
|
}
|
|
1682
|
-
|
|
1699
|
+
// Release relinquishes authority; it never extends or grants any. A
|
|
1700
|
+
// barrier that refuses it strands the lease in the worktree least able
|
|
1701
|
+
// to clear the barrier, because no other worktree can take the item over
|
|
1702
|
+
// while the claim is held. Acquire and renew do extend authority against
|
|
1703
|
+
// bytes nobody has ruled legitimate, so they keep refusing. Only this
|
|
1704
|
+
// classification is bypassed: an unresolvable identity, an unreadable
|
|
1705
|
+
// journal, and a clock floor that will not persist all throw before this
|
|
1706
|
+
// point, and the tuple compare-and-swap below still rules on the request.
|
|
1707
|
+
if (reconciled.unsafe && claimCommand !== 'release') {
|
|
1683
1708
|
return claimStoreUnavailable(claimCommand, 'publication-reconciliation-required', {
|
|
1684
1709
|
findings: reconciled.findings,
|
|
1685
1710
|
});
|
package/src/git-autocommit.js
CHANGED
|
@@ -410,6 +410,12 @@ function claimVerificationFailureDetails(verified) {
|
|
|
410
410
|
return {
|
|
411
411
|
...(error?.code ? { claim_verify_code: error.code } : {}),
|
|
412
412
|
...(error?.details?.reason ? { claim_verify_reason: error.details.reason } : {}),
|
|
413
|
+
// Why the claim store could not be read travels with the refusal: an
|
|
414
|
+
// operator told only that a store is unreadable cannot find the two
|
|
415
|
+
// worktrees answering to one identity.
|
|
416
|
+
...(error?.details?.identity_diagnostic
|
|
417
|
+
? { identity_diagnostic: error.details.identity_diagnostic }
|
|
418
|
+
: {}),
|
|
413
419
|
...(findings ? { findings } : {}),
|
|
414
420
|
};
|
|
415
421
|
}
|
|
@@ -2,6 +2,7 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
+
import { listWorktrees } from './git-worktrees.js';
|
|
5
6
|
import { recordCount, timePhase } from './instrumentation.js';
|
|
6
7
|
import { revisionFor } from './mutation.js';
|
|
7
8
|
|
|
@@ -63,48 +64,84 @@ export async function readGitTreeFile(ledgerDirectory, treeish, relativePath) {
|
|
|
63
64
|
return gitBuffer(root, ['show', `${treeish}:${gitPath}`]);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// Which
|
|
67
|
-
//
|
|
68
|
-
//
|
|
67
|
+
// Which active worktree carries the revision the journal expects. Reachability
|
|
68
|
+
// alone proves nothing about ownership: a tag, a remote-tracking ref, or a
|
|
69
|
+
// branch nobody has checked out names no worktree that could publish anything.
|
|
70
|
+
// So the evidence is the worktree roster, this worktree first, and a revision
|
|
71
|
+
// reachable only outside it stays unowned rather than attributed to a ref.
|
|
72
|
+
//
|
|
73
|
+
// Returns `{ kind, ref?, commit? }`, where `kind` is one of:
|
|
74
|
+
// `current` this worktree's own history reaches the revision;
|
|
75
|
+
// `named-sibling` a live sibling worktree on a branch reaches it;
|
|
76
|
+
// `reachable-unowned` Git reaches it, but no live named worktree does;
|
|
77
|
+
// `unreachable` no reachable commit carries the expected bytes.
|
|
69
78
|
export async function findRevisionOwner(ledgerDirectory, itemPath, expectedRevision) {
|
|
70
79
|
const { root, relativeLedger } = await resolveWorktreeLedger(ledgerDirectory);
|
|
71
80
|
const gitPath = toGitPath(path.join(relativeLedger, itemPath));
|
|
72
|
-
|
|
81
|
+
const carrierIn = revisionCarrier(root, gitPath, expectedRevision);
|
|
82
|
+
const current = await carrierIn(await pathHistory(root, 'HEAD', gitPath));
|
|
83
|
+
if (current) return { kind: 'current', commit: current };
|
|
84
|
+
const live = (await listWorktrees(root)).filter((record) => (
|
|
85
|
+
!record.bare && !record.prunable && record.head !== null
|
|
86
|
+
));
|
|
87
|
+
// One expected revision can sit in several live worktrees at once. Branch ref
|
|
88
|
+
// then path is the only ordering both stable across runs and independent of
|
|
89
|
+
// the order Git happened to register the worktrees in.
|
|
90
|
+
for (const record of live.filter((entry) => entry.branch !== null).sort(byBranchThenPath)) {
|
|
91
|
+
const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
|
|
92
|
+
if (commit) return { kind: 'named-sibling', ref: record.branch, commit };
|
|
93
|
+
}
|
|
94
|
+
// A detached worktree is a real writer with no ref to wait on, so it proves
|
|
95
|
+
// reachability and nothing else. So does any remaining ref: a tag, a
|
|
96
|
+
// remote-tracking ref, or a branch no worktree has checked out.
|
|
97
|
+
for (const record of live.filter((entry) => entry.branch === null)) {
|
|
98
|
+
const commit = await carrierIn(await pathHistory(root, record.head, gitPath));
|
|
99
|
+
if (commit) return { kind: 'reachable-unowned', commit };
|
|
100
|
+
}
|
|
101
|
+
const reachable = await carrierIn(await pathHistory(root, '--all', gitPath));
|
|
102
|
+
return reachable ? { kind: 'reachable-unowned', commit: reachable } : { kind: 'unreachable' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The first commit of a history whose item bytes are the expected revision, or
|
|
106
|
+
// null. Sibling histories overlap almost entirely and every answer costs a
|
|
107
|
+
// `git show`, so one commit is read at most once per lookup.
|
|
108
|
+
function revisionCarrier(root, gitPath, expectedRevision) {
|
|
109
|
+
const answered = new Map();
|
|
110
|
+
return async (commits) => {
|
|
111
|
+
for (const commit of commits) {
|
|
112
|
+
if (!answered.has(commit)) {
|
|
113
|
+
answered.set(commit, await carriesRevision(root, commit, gitPath, expectedRevision));
|
|
114
|
+
}
|
|
115
|
+
if (answered.get(commit)) return commit;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function carriesRevision(root, commit, gitPath, expectedRevision) {
|
|
73
122
|
try {
|
|
74
|
-
|
|
123
|
+
const bytes = await gitBuffer(root, ['show', `${commit}:${gitPath}`]);
|
|
124
|
+
return revisionFor(bytes) === expectedRevision;
|
|
75
125
|
} catch {
|
|
76
|
-
//
|
|
126
|
+
// The path does not exist in this commit, so it cannot carry the revision.
|
|
127
|
+
return false;
|
|
77
128
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// The path does not exist in this commit, so it cannot carry the revision.
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (revisionFor(bytes) !== expectedRevision) continue;
|
|
92
|
-
if (detachedHeadCommits.has(commit)) {
|
|
93
|
-
return { owner_unavailable: true, owner_current_ref: true, owner_commit: commit };
|
|
94
|
-
}
|
|
95
|
-
const refs = gitLines(await gitText(root, [
|
|
96
|
-
'for-each-ref',
|
|
97
|
-
'--contains',
|
|
98
|
-
commit,
|
|
99
|
-
'--format=%(refname)',
|
|
100
|
-
])).sort();
|
|
101
|
-
if (currentRef !== null && refs.includes(currentRef)) {
|
|
102
|
-
return { owner_unavailable: true, owner_current_ref: true, owner_commit: commit };
|
|
103
|
-
}
|
|
104
|
-
const [ownerRef] = refs;
|
|
105
|
-
return ownerRef ? { owner_ref: ownerRef, owner_commit: commit } : { owner_unavailable: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The commits one revision range reaches that touch the item path, newest
|
|
132
|
+
// first. An unborn `HEAD` names no commit, so a worktree Git cannot resolve
|
|
133
|
+
// reaches nothing.
|
|
134
|
+
async function pathHistory(root, revisionRange, gitPath) {
|
|
135
|
+
try {
|
|
136
|
+
return gitLines(await gitText(root, ['rev-list', revisionRange, '--', gitPath]));
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
106
139
|
}
|
|
107
|
-
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function byBranchThenPath(a, b) {
|
|
143
|
+
if (a.branch !== b.branch) return a.branch < b.branch ? -1 : 1;
|
|
144
|
+
return a.path < b.path ? -1 : 1;
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
async function gitText(cwd, argumentsList) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
// Git answers `GIT_DIR` and its siblings before it answers `cwd`, so an
|
|
7
|
+
// inherited value would report a repository the caller never named.
|
|
8
|
+
const GIT_ENVIRONMENT = Object.fromEntries(
|
|
9
|
+
Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
// Every worktree of one repository owns a private Git directory: the common
|
|
13
|
+
// directory itself for the main worktree, `<common>/worktrees/<name>` for a
|
|
14
|
+
// linked one. Anything a single worktree must not share with its siblings
|
|
15
|
+
// belongs there. Git resolves it; nothing here parses a `.git` file.
|
|
16
|
+
export async function resolvePrivateGitDir(directory) {
|
|
17
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
18
|
+
cwd: path.resolve(directory),
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
env: GIT_ENVIRONMENT,
|
|
21
|
+
maxBuffer: 1024 * 1024,
|
|
22
|
+
});
|
|
23
|
+
return stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The registered worktrees of one repository, as Git itself reports them.
|
|
27
|
+
// `--porcelain -z` terminates every attribute with NUL and separates records
|
|
28
|
+
// with one extra NUL, so a worktree path holding a newline still parses; the
|
|
29
|
+
// newline-delimited spelling cannot promise that. Git emits `worktree <path>`
|
|
30
|
+
// first, then `HEAD <oid>` and either `branch <ref>` or `detached`, plus the
|
|
31
|
+
// bare, locked, and prunable markers where they apply. An attribute before the
|
|
32
|
+
// first `worktree` line cannot exist, and one that does belongs to no record.
|
|
33
|
+
export async function listWorktrees(directory) {
|
|
34
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain', '-z'], {
|
|
35
|
+
cwd: path.resolve(directory),
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
env: GIT_ENVIRONMENT,
|
|
38
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
const worktrees = [];
|
|
41
|
+
let record = null;
|
|
42
|
+
for (const field of stdout.split('\0')) {
|
|
43
|
+
if (field === '') {
|
|
44
|
+
if (record) worktrees.push(record);
|
|
45
|
+
record = null;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const boundary = field.indexOf(' ');
|
|
49
|
+
const name = boundary === -1 ? field : field.slice(0, boundary);
|
|
50
|
+
const value = boundary === -1 ? '' : field.slice(boundary + 1);
|
|
51
|
+
if (name === 'worktree') {
|
|
52
|
+
record = {
|
|
53
|
+
path: value,
|
|
54
|
+
head: null,
|
|
55
|
+
branch: null,
|
|
56
|
+
detached: false,
|
|
57
|
+
bare: false,
|
|
58
|
+
locked: false,
|
|
59
|
+
prunable: false,
|
|
60
|
+
};
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!record) continue;
|
|
64
|
+
if (name === 'HEAD') record.head = value;
|
|
65
|
+
else if (name === 'branch') record.branch = value;
|
|
66
|
+
else if (name === 'detached') record.detached = true;
|
|
67
|
+
else if (name === 'bare') record.bare = true;
|
|
68
|
+
else if (name === 'locked') record.locked = true;
|
|
69
|
+
else if (name === 'prunable') record.prunable = true;
|
|
70
|
+
}
|
|
71
|
+
if (record) worktrees.push(record);
|
|
72
|
+
return worktrees;
|
|
73
|
+
}
|
package/src/instrumentation.js
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, readFile, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { withClaimLock } from './claim-store.js';
|
|
6
|
+
import { listWorktrees, resolvePrivateGitDir } from './git-worktrees.js';
|
|
7
|
+
|
|
8
|
+
const IDENTITY_FILE = 'wowbagger-worktree-id';
|
|
9
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
10
|
+
|
|
11
|
+
function invalidIdentity(reason) {
|
|
12
|
+
const error = new Error('worktree identity is invalid');
|
|
13
|
+
error.code = 'CLAIM_WORKTREE_IDENTITY_INVALID';
|
|
14
|
+
error.reason = reason;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
|
|
19
|
+
return readIdentityFile(await resolveIdentityPath(ledgerDirectory, gitCommonDir));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Two live worktrees answering to one UUID make every writer attribution in
|
|
23
|
+
// the shared journal ambiguous, and an ambiguous writer is exactly what
|
|
24
|
+
// reconciliation reasons from. The caller runs this before it classifies or
|
|
25
|
+
// publishes anything, so an ambiguous domain refuses rather than guesses.
|
|
26
|
+
//
|
|
27
|
+
// Nothing here creates an identity file: a worktree that has never written one
|
|
28
|
+
// stays anonymous and holds no UUID to collide with.
|
|
29
|
+
export async function assertUniqueWorktreeIdentity({ ledgerDirectory }) {
|
|
30
|
+
const identities = await enumerateWorktreeIdentities(ledgerDirectory);
|
|
31
|
+
const holders = new Map();
|
|
32
|
+
for (const identity of identities) {
|
|
33
|
+
if (identity !== null) holders.set(identity, (holders.get(identity) ?? 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
// Sorted so a domain holding more than one collision always names the same
|
|
36
|
+
// one, and the diagnostic an operator reads twice does not move.
|
|
37
|
+
const duplicate = [...holders]
|
|
38
|
+
.filter(([, count]) => count > 1)
|
|
39
|
+
.sort(([left], [right]) => (left < right ? -1 : 1))
|
|
40
|
+
.at(0);
|
|
41
|
+
if (!duplicate) return;
|
|
42
|
+
const error = invalidIdentity('duplicate-worktree-identity');
|
|
43
|
+
error.identityDiagnostic = {
|
|
44
|
+
code: 'duplicate-worktree-identity',
|
|
45
|
+
worktree_id: duplicate[0],
|
|
46
|
+
live_worktree_count: duplicate[1],
|
|
47
|
+
};
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One entry per live registered worktree: its recorded UUID, or null when it
|
|
52
|
+
// has never written one.
|
|
53
|
+
//
|
|
54
|
+
// Evidence gathering fails closed, because a roster this function could not
|
|
55
|
+
// finish reading is not a roster without duplicates. An enumeration that never
|
|
56
|
+
// ran, a private Git directory that will not resolve, a path that vanished
|
|
57
|
+
// between the listing and the read, and identity bytes that are present but
|
|
58
|
+
// malformed all leave the roster incomplete, so all of them refuse the caller
|
|
59
|
+
// rather than shrink the evidence. Silently dropping any of them could hide
|
|
60
|
+
// the very duplicate this check exists to find.
|
|
61
|
+
//
|
|
62
|
+
// Only what Git itself has already disowned is excluded: a record Git marks
|
|
63
|
+
// prunable, and a bare repository, which has no checkout to hold an identity.
|
|
64
|
+
async function enumerateWorktreeIdentities(ledgerDirectory) {
|
|
65
|
+
try {
|
|
66
|
+
const roster = await listWorktrees(ledgerDirectory);
|
|
67
|
+
return await Promise.all(roster
|
|
68
|
+
.filter((worktree) => !(worktree.bare || worktree.prunable))
|
|
69
|
+
.map(async (worktree) => readIdentityFile(path.join(
|
|
70
|
+
await resolvePrivateGitDir(worktree.path), IDENTITY_FILE,
|
|
71
|
+
))));
|
|
72
|
+
} catch {
|
|
73
|
+
const error = invalidIdentity('worktree-enumeration-failed');
|
|
74
|
+
// The roster is unknown, so the diagnostic names no UUID and no count.
|
|
75
|
+
error.identityDiagnostic = { code: 'worktree-enumeration-failed' };
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A duplicate refusal keeps its caller's existing outer code and reason, and
|
|
81
|
+
// adds only this diagnostic to the details a client already tolerates extra
|
|
82
|
+
// members in.
|
|
83
|
+
export function identityDiagnosticDetails(error) {
|
|
84
|
+
return error?.identityDiagnostic ? { identity_diagnostic: error.identityDiagnostic } : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The identity file is created once and never rewritten: a worktree that
|
|
88
|
+
// already answers to an ID keeps it, because journal entries elsewhere already
|
|
89
|
+
// name it.
|
|
90
|
+
//
|
|
91
|
+
// Read and create run under a lock keyed on the identity path, which is the
|
|
92
|
+
// worktree itself. The namespace write lock cannot stand in for it: two ledger
|
|
93
|
+
// namespaces can share one worktree, so they hold different namespace locks
|
|
94
|
+
// while contending for one identity file. Without this lock both would observe
|
|
95
|
+
// no identity, both would create one, and the later rename would leave the
|
|
96
|
+
// earlier writer holding an ID the file no longer contains. The lock is a
|
|
97
|
+
// try-lock, so a losing writer is refused with `CLAIM_LOCK_HELD` and retries
|
|
98
|
+
// rather than publishing a second identity.
|
|
99
|
+
//
|
|
100
|
+
// Creation still writes a fresh file in the same directory, fsyncs it, and
|
|
101
|
+
// renames it over the final path, so a reader taking no lock at all sees
|
|
102
|
+
// either no file or one whole ID. The final path is never opened for writing,
|
|
103
|
+
// so a crash can never leave a truncated identity behind.
|
|
104
|
+
export async function ensureWorktreeIdentity({ ledgerDirectory, gitCommonDir }) {
|
|
105
|
+
const identityPath = await resolveIdentityPath(ledgerDirectory, gitCommonDir);
|
|
106
|
+
return withClaimLock(identityPath, async () => {
|
|
107
|
+
const existing = await readIdentityFile(identityPath);
|
|
108
|
+
if (existing !== null) return existing;
|
|
109
|
+
|
|
110
|
+
const temporaryPath = path.join(
|
|
111
|
+
path.dirname(identityPath),
|
|
112
|
+
`.${IDENTITY_FILE}.${randomUUID()}`,
|
|
113
|
+
);
|
|
114
|
+
try {
|
|
115
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
116
|
+
try {
|
|
117
|
+
await handle.writeFile(`${randomUUID()}\n`, 'utf8');
|
|
118
|
+
await handle.sync();
|
|
119
|
+
} finally {
|
|
120
|
+
await handle.close();
|
|
121
|
+
}
|
|
122
|
+
await rename(temporaryPath, identityPath);
|
|
123
|
+
} finally {
|
|
124
|
+
await rm(temporaryPath, { force: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read back rather than trust the value just written: the caller must act
|
|
128
|
+
// on the ID the next reader will see, not the one this process intended.
|
|
129
|
+
const written = await readIdentityFile(identityPath);
|
|
130
|
+
if (written === null) throw invalidIdentity('missing-after-write');
|
|
131
|
+
return written;
|
|
132
|
+
}, { counter: 'worktree_identity_lock_acquisitions' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The verified common directory is the caller's proof of which repository it
|
|
136
|
+
// is fencing. A private directory that is neither that directory nor one of
|
|
137
|
+
// its linked worktrees belongs to some other repository, and an identity
|
|
138
|
+
// written there would name the wrong writer.
|
|
139
|
+
async function resolveIdentityPath(ledgerDirectory, gitCommonDir) {
|
|
140
|
+
const privateGitDir = await resolvePrivateGitDir(ledgerDirectory);
|
|
141
|
+
const [privateReal, commonReal] = await Promise.all([
|
|
142
|
+
realpath(privateGitDir),
|
|
143
|
+
realpath(gitCommonDir),
|
|
144
|
+
]);
|
|
145
|
+
const linked = path.join(commonReal, 'worktrees');
|
|
146
|
+
if (privateReal !== commonReal && path.dirname(privateReal) !== linked) {
|
|
147
|
+
throw invalidIdentity('private-git-dir-outside-common-dir');
|
|
148
|
+
}
|
|
149
|
+
return path.join(privateGitDir, IDENTITY_FILE);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readIdentityFile(identityPath) {
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = await readFile(identityPath, 'utf8');
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error?.code === 'ENOENT') return null;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (!text.endsWith('\n')) throw invalidIdentity('missing-terminator');
|
|
161
|
+
const identity = text.slice(0, -1);
|
|
162
|
+
// The anchored pattern rejects a second line as well as a malformed one.
|
|
163
|
+
if (!UUID_V4.test(identity)) throw invalidIdentity('malformed-uuid');
|
|
164
|
+
return identity;
|
|
165
|
+
}
|