wowbagger 0.1.0-alpha.13 → 0.1.0-alpha.14
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 +200 -0
- package/README.md +8 -8
- package/docs/mutation-contract.md +104 -20
- package/docs/work-claim-contract.md +291 -64
- package/package.json +1 -1
- package/skills/wowbagger/SKILL.md +116 -21
- package/src/claim-coordinator.js +17 -3
- package/src/claim-journal.js +44 -4
- package/src/claim-publication.js +122 -93
- package/src/cli.js +5 -7
- package/src/git-autocommit.js +26 -21
- package/src/mutation.js +15 -2
- package/src/reconciliation-classifier.js +117 -0
package/src/claim-publication.js
CHANGED
|
@@ -18,6 +18,12 @@ import { loadLedger, parseLedgerItemSource } from './ledger.js';
|
|
|
18
18
|
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
|
+
import {
|
|
22
|
+
blocksTarget,
|
|
23
|
+
classifyReconciliation,
|
|
24
|
+
normalizeRevision,
|
|
25
|
+
requiresOwnerEvidence,
|
|
26
|
+
} from './reconciliation-classifier.js';
|
|
21
27
|
import { validateLedger } from './validate.js';
|
|
22
28
|
import {
|
|
23
29
|
assertUniqueWorktreeIdentity,
|
|
@@ -403,6 +409,14 @@ export async function reconcileClaimJournal({
|
|
|
403
409
|
));
|
|
404
410
|
const entries = [...replayed.entries];
|
|
405
411
|
const findings = [];
|
|
412
|
+
// What each finding refuses is the coordinator's own judgement, never a
|
|
413
|
+
// published member, so the scope rules on the write as the finding is
|
|
414
|
+
// recorded and never becomes a member something has to strip back out.
|
|
415
|
+
let unsafe = false;
|
|
416
|
+
const addFinding = (scope, finding) => {
|
|
417
|
+
unsafe ||= blocksTarget(scope, finding.item_id, targetItemId);
|
|
418
|
+
findings.push(finding);
|
|
419
|
+
};
|
|
406
420
|
const observedAt = advanceClockFloor(replayed.state, physicalNow);
|
|
407
421
|
try {
|
|
408
422
|
entries.push(await appendClaimEntry(journalPath, {
|
|
@@ -451,6 +465,10 @@ export async function reconcileClaimJournal({
|
|
|
451
465
|
attempt_id: intent.attempt_id,
|
|
452
466
|
ledger_namespace: namespace,
|
|
453
467
|
item_id: intent.item_id,
|
|
468
|
+
// A create abort names no predecessor revision, so its terminal must
|
|
469
|
+
// say which command aborted; patch and transition aborts keep their
|
|
470
|
+
// exact legacy shape and never carry a command.
|
|
471
|
+
...(intent.command === 'create-v1' ? { command: intent.command } : {}),
|
|
454
472
|
observed_revision: actualRevision,
|
|
455
473
|
observed_at: observedAt,
|
|
456
474
|
}));
|
|
@@ -459,7 +477,7 @@ export async function reconcileClaimJournal({
|
|
|
459
477
|
?? intent.item_path
|
|
460
478
|
?? null;
|
|
461
479
|
const pathLabel = expectedPath ?? `item ${intent.item_id}`;
|
|
462
|
-
|
|
480
|
+
addFinding('global', {
|
|
463
481
|
code: 'legacy-mutation-outcome-unknown',
|
|
464
482
|
item_id: intent.item_id,
|
|
465
483
|
attempt_id: intent.attempt_id,
|
|
@@ -541,7 +559,8 @@ export async function reconcileClaimJournal({
|
|
|
541
559
|
outcome,
|
|
542
560
|
}));
|
|
543
561
|
const unknownPath = itemPathRelativeToLedger(ledgerDirectory, item?.file);
|
|
544
|
-
|
|
562
|
+
// A resolved intent is news, not a barrier: it refuses nothing.
|
|
563
|
+
addFinding(outcome.stdout.state === 'unknown' ? 'global' : 'none', {
|
|
545
564
|
code: outcome.stdout.state === 'unknown'
|
|
546
565
|
? 'publication-outcome-unknown'
|
|
547
566
|
: 'pending-intent-resolved',
|
|
@@ -606,7 +625,12 @@ export async function reconcileClaimJournal({
|
|
|
606
625
|
const expectedRevision = authorizedRevisionOf(latestAuthorized);
|
|
607
626
|
const authorizedRevisions = new Set(authorized.map(authorizedRevisionOf));
|
|
608
627
|
for (const intent of entries) {
|
|
609
|
-
|
|
628
|
+
// A create names no predecessor revision, so its `null` is the absence of
|
|
629
|
+
// one, never a ruling that absent bytes are authorized. Reading it as a
|
|
630
|
+
// revision would make an uncommitted create look finalized in Git.
|
|
631
|
+
if (intent.type === 'legacy-mutation-intent'
|
|
632
|
+
&& intent.item_id === itemId
|
|
633
|
+
&& intent.expected_revision !== null) {
|
|
610
634
|
authorizedRevisions.add(intent.expected_revision);
|
|
611
635
|
}
|
|
612
636
|
}
|
|
@@ -631,7 +655,7 @@ export async function reconcileClaimJournal({
|
|
|
631
655
|
);
|
|
632
656
|
if (activeMismatch?.earlier) {
|
|
633
657
|
const pathLabel = expectedPath ?? `item ${itemId}`;
|
|
634
|
-
|
|
658
|
+
addFinding('global', {
|
|
635
659
|
code: 'stale-write-detected',
|
|
636
660
|
item_id: itemId,
|
|
637
661
|
actual_revision: actualRevision,
|
|
@@ -650,13 +674,10 @@ export async function reconcileClaimJournal({
|
|
|
650
674
|
});
|
|
651
675
|
continue;
|
|
652
676
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const expectedWriter = recordedWriter === null || currentWorktreeId === null
|
|
658
|
-
? 'unknown'
|
|
659
|
-
: recordedWriter === currentWorktreeId ? 'current' : 'other';
|
|
677
|
+
const expectedWriter = expectedWriterOf(
|
|
678
|
+
latestAuthorized.writer_worktree_id ?? null,
|
|
679
|
+
currentWorktreeId,
|
|
680
|
+
);
|
|
660
681
|
const diagnosis = await reconciliationDiagnosis({
|
|
661
682
|
ledgerDirectory,
|
|
662
683
|
actualRevision,
|
|
@@ -665,15 +686,14 @@ export async function reconcileClaimJournal({
|
|
|
665
686
|
expectedRevision,
|
|
666
687
|
headRevision,
|
|
667
688
|
expectedWriter,
|
|
668
|
-
workingTreeChanged,
|
|
669
689
|
});
|
|
670
|
-
|
|
690
|
+
addFinding(diagnosis.scope, {
|
|
671
691
|
code: 'stale-write-detected',
|
|
672
692
|
item_id: itemId,
|
|
673
693
|
actual_revision: workingTreeChanged ? actualRevision : headRevision,
|
|
674
694
|
expected_revision: expectedRevision,
|
|
675
695
|
observed_surface: workingTreeChanged ? 'working-tree' : 'git-head',
|
|
676
|
-
...diagnosis,
|
|
696
|
+
...diagnosis.finding,
|
|
677
697
|
...(record?.active ? {
|
|
678
698
|
active_fence: {
|
|
679
699
|
ledger_namespace: namespace,
|
|
@@ -706,7 +726,7 @@ export async function reconcileClaimJournal({
|
|
|
706
726
|
?? expected.item_path
|
|
707
727
|
?? null;
|
|
708
728
|
const pathLabel = expectedPath ?? `item ${record.item_id}`;
|
|
709
|
-
|
|
729
|
+
addFinding('global', {
|
|
710
730
|
code: earlier ? 'stale-write-detected' : 'revision-regression',
|
|
711
731
|
item_id: record.item_id,
|
|
712
732
|
actual_revision: actualRevision,
|
|
@@ -730,9 +750,6 @@ export async function reconcileClaimJournal({
|
|
|
730
750
|
});
|
|
731
751
|
}
|
|
732
752
|
|
|
733
|
-
const unsafe = findings.some((finding) => (
|
|
734
|
-
finding.code !== 'pending-intent-resolved' && blocksTarget(finding, targetItemId)
|
|
735
|
-
));
|
|
736
753
|
const logPath = claimReconcileLogPath(path.resolve(ledgerDirectory), namespace);
|
|
737
754
|
let logExists = true;
|
|
738
755
|
try {
|
|
@@ -757,6 +774,11 @@ export async function reconcileClaimJournal({
|
|
|
757
774
|
findings,
|
|
758
775
|
gitHead,
|
|
759
776
|
headItems,
|
|
777
|
+
// Every coordinated item the journal knows and this working ledger does not
|
|
778
|
+
// hold. An item's number is immutable, so a caller that allocates the next
|
|
779
|
+
// one needs to know when an allocation exists that it cannot read; a stale
|
|
780
|
+
// revision of an item that is present hides no number.
|
|
781
|
+
missingCoordinatedItems: [...coordinatedItems].filter((itemId) => !items.has(itemId)),
|
|
760
782
|
// The snapshot reconciliation judged. Reconciliation writes only the
|
|
761
783
|
// journal, the claim state, and the ledger's `.wowbagger` reconcile log,
|
|
762
784
|
// none of which a complete ledger load reads, so these are still the bytes
|
|
@@ -793,15 +815,6 @@ function itemPathRelativeToLedger(ledgerDirectory, file) {
|
|
|
793
815
|
return path.relative(path.resolve(ledgerDirectory), file).split(path.sep).join('/');
|
|
794
816
|
}
|
|
795
817
|
|
|
796
|
-
// A mutation names the item it targets. Another item's unresolved publication
|
|
797
|
-
// waits on a synchronization this mutation does not touch, so it reports as a
|
|
798
|
-
// finding without refusing the write. A caller that names no target, such as
|
|
799
|
-
// the `claim-verify` command, keeps every finding blocking.
|
|
800
|
-
function blocksTarget(finding, targetItemId) {
|
|
801
|
-
if (targetItemId === null || finding.item_id === targetItemId) return true;
|
|
802
|
-
return finding.reason !== 'worktree-synchronization-required';
|
|
803
|
-
}
|
|
804
|
-
|
|
805
818
|
// Ownership is evidence, never a guess: an unreadable history or a missing
|
|
806
819
|
// expected path leaves the revision unattributed instead of naming a ref.
|
|
807
820
|
async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision) {
|
|
@@ -813,18 +826,76 @@ async function revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevi
|
|
|
813
826
|
}
|
|
814
827
|
}
|
|
815
828
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
829
|
+
// The classifier decides which topology this is; the sentences stay here.
|
|
830
|
+
// Member order is part of the published finding, so each remedy builds its own
|
|
831
|
+
// shape rather than sharing a base object.
|
|
832
|
+
function topologyFinding(decision, expectedPath, expectedRevision) {
|
|
833
|
+
const pathLabel = expectedPath ?? 'the item path';
|
|
834
|
+
const at = expectedPath ? { expected_path: expectedPath } : {};
|
|
835
|
+
switch (decision.remediation) {
|
|
836
|
+
case 'commit-in-git':
|
|
837
|
+
return {
|
|
838
|
+
reason: decision.reason,
|
|
839
|
+
...at,
|
|
840
|
+
remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
|
|
841
|
+
};
|
|
842
|
+
case 'wait-for-named-owner':
|
|
843
|
+
return {
|
|
844
|
+
reason: decision.reason,
|
|
845
|
+
...at,
|
|
846
|
+
owner_ref: decision.owner.ref,
|
|
847
|
+
owner_commit: decision.owner.commit,
|
|
848
|
+
remediation: `WAIT for owner ${decision.owner.ref} to publish ${decision.owner.commit}, then synchronize this worktree and run claim-verify.`,
|
|
849
|
+
};
|
|
850
|
+
case 'establish-ownership':
|
|
851
|
+
return {
|
|
852
|
+
reason: decision.reason,
|
|
853
|
+
...at,
|
|
854
|
+
owner_unavailable: true,
|
|
855
|
+
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.`,
|
|
856
|
+
};
|
|
857
|
+
// Reachable but unowned: the revision is in Git already, so the only honest
|
|
858
|
+
// instruction is to go read it. Naming an owner to wait for would be a wait
|
|
859
|
+
// with no end, which is what item #178 found in the field.
|
|
860
|
+
case 'inspect-reachable-history':
|
|
861
|
+
return {
|
|
862
|
+
reason: decision.reason,
|
|
863
|
+
...at,
|
|
864
|
+
owner_unavailable: true,
|
|
865
|
+
remediation: `Revision ${expectedRevision} of ${pathLabel} is reachable in Git, but no active named worktree owner is established; inspect the reachable history, restore or explicitly adopt reviewed bytes, then run claim-verify.`,
|
|
866
|
+
};
|
|
867
|
+
case 'await-owner-commit':
|
|
868
|
+
return {
|
|
869
|
+
reason: decision.reason,
|
|
870
|
+
...at,
|
|
871
|
+
owner_unavailable: true,
|
|
872
|
+
remediation: `Ownership of ${pathLabel} revision ${expectedRevision} is not yet reachable; wait for the owning worktree to commit, then synchronize this worktree and run claim-verify.`,
|
|
873
|
+
};
|
|
874
|
+
// Two remedies, both named, in the order that makes the cost obvious. The
|
|
875
|
+
// field report behind item #113 read the single restore sentence as the
|
|
876
|
+
// only way out and discarded reviewed, merged work to obey it.
|
|
877
|
+
case 'restore-or-adopt':
|
|
878
|
+
return {
|
|
879
|
+
reason: decision.reason,
|
|
880
|
+
...at,
|
|
881
|
+
remediation: `Restore the authorized revision at ${pathLabel}, then run claim-verify; that discards the edit. Or adopt the committed revision of ${pathLabel} with claim-adopt, then run claim-verify; that keeps the edit.`,
|
|
882
|
+
};
|
|
883
|
+
default:
|
|
884
|
+
throw new Error(`The reconciliation topology named no remedy: ${decision.remediation}.`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Who the journal says wrote the authorized revision, judged against who is
|
|
889
|
+
// asking. An entry from before writer identity existed, or a caller that
|
|
890
|
+
// cannot name itself, leaves the writer unknown.
|
|
891
|
+
function expectedWriterOf(recordedWriter, currentWorktreeId) {
|
|
892
|
+
if (recordedWriter === null || currentWorktreeId === null) return 'unknown';
|
|
893
|
+
return recordedWriter === currentWorktreeId ? 'current' : 'other';
|
|
826
894
|
}
|
|
827
895
|
|
|
896
|
+
// Evidence in, scope and public finding out: the classifier rules on the
|
|
897
|
+
// topology, and this gathers exactly the evidence it rules on, then renders
|
|
898
|
+
// the sentence it prescribes.
|
|
828
899
|
async function reconciliationDiagnosis({
|
|
829
900
|
ledgerDirectory,
|
|
830
901
|
actualRevision,
|
|
@@ -833,64 +904,22 @@ async function reconciliationDiagnosis({
|
|
|
833
904
|
expectedRevision,
|
|
834
905
|
headRevision,
|
|
835
906
|
expectedWriter,
|
|
836
|
-
workingTreeChanged,
|
|
837
907
|
}) {
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
908
|
+
const revisions = {
|
|
909
|
+
workingTree: normalizeRevision(actualRevision, expectedRevision, authorizedRevisions),
|
|
910
|
+
head: normalizeRevision(headRevision, expectedRevision, authorizedRevisions),
|
|
911
|
+
};
|
|
912
|
+
const decision = classifyReconciliation({
|
|
913
|
+
...revisions,
|
|
914
|
+
expectedOwner: requiresOwnerEvidence(revisions)
|
|
915
|
+
? await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision)
|
|
916
|
+
: null,
|
|
917
|
+
expectedWriter,
|
|
918
|
+
});
|
|
919
|
+
return {
|
|
920
|
+
scope: decision.scope,
|
|
921
|
+
finding: topologyFinding(decision, expectedPath, expectedRevision),
|
|
846
922
|
};
|
|
847
|
-
const hasOutOfProtocolLocalState = (
|
|
848
|
-
(actualRevision !== null && !authorizedRevisions.has(actualRevision))
|
|
849
|
-
|| (headRevision !== null && !authorizedRevisions.has(headRevision))
|
|
850
|
-
|| (actualRevision === null && headRevision !== null)
|
|
851
|
-
);
|
|
852
|
-
if (hasOutOfProtocolLocalState) return unauthorizedRevision;
|
|
853
|
-
let expectedOwner = null;
|
|
854
|
-
if (headRevision !== null && actualRevision === headRevision
|
|
855
|
-
&& headRevision !== expectedRevision) {
|
|
856
|
-
expectedOwner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
857
|
-
if (expectedOwner.kind === 'named-sibling') return namedOwnerFinding(expectedOwner, expectedPath);
|
|
858
|
-
}
|
|
859
|
-
if (!workingTreeChanged && headRevision !== expectedRevision) {
|
|
860
|
-
return {
|
|
861
|
-
reason: 'git-finalization-required',
|
|
862
|
-
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
863
|
-
remediation: `Commit ${pathLabel} in Git, then run claim-verify.`,
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
if (actualRevision === null && headRevision !== expectedRevision) {
|
|
867
|
-
const owner = await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
868
|
-
if (owner.kind === 'named-sibling') return namedOwnerFinding(owner, expectedPath);
|
|
869
|
-
return {
|
|
870
|
-
reason: 'worktree-synchronization-required',
|
|
871
|
-
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
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.`,
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
if (authorizedRevisions.has(actualRevision)) {
|
|
877
|
-
expectedOwner ??= await revisionOwnerEvidence(ledgerDirectory, expectedPath, expectedRevision);
|
|
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') {
|
|
885
|
-
return {
|
|
886
|
-
reason: 'worktree-synchronization-required',
|
|
887
|
-
...(expectedPath ? { expected_path: expectedPath } : {}),
|
|
888
|
-
owner_unavailable: true,
|
|
889
|
-
remediation: `Ownership of ${pathLabel} revision ${expectedRevision} is not yet reachable; wait for the owning worktree to commit, then synchronize this worktree and run claim-verify.`,
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
return unauthorizedRevision;
|
|
894
923
|
}
|
|
895
924
|
|
|
896
925
|
function activePublicationMismatch(entries, record, actualRevision, observedAt) {
|
package/src/cli.js
CHANGED
|
@@ -1654,20 +1654,18 @@ async function runClaimCommand(claimCommand, argumentsList) {
|
|
|
1654
1654
|
}
|
|
1655
1655
|
try {
|
|
1656
1656
|
const envelope = await withClaimLock(storePath, async () => {
|
|
1657
|
-
let replayed;
|
|
1658
|
-
try {
|
|
1659
|
-
replayed = await replayClaimJournal(journalPath, namespace);
|
|
1660
|
-
} catch (error) {
|
|
1661
|
-
throw taggedFailure('CLAIM_STORE_UNREADABLE', error);
|
|
1662
|
-
}
|
|
1663
1657
|
// A claim lifecycle command classifies item reconciliation and refuses
|
|
1664
1658
|
// on it, so it must reason from the same writer evidence every other
|
|
1665
1659
|
// classifying surface uses; otherwise one command grants a claim on
|
|
1666
1660
|
// exactly the state another command refuses to write. It writes no item
|
|
1667
1661
|
// byte and no writer-attributed entry, so it reads the identity it
|
|
1668
|
-
// already answers to rather than creating one.
|
|
1662
|
+
// already answers to rather than creating one. Journal and identity are
|
|
1663
|
+
// both store state this command only reads, so neither reading failure
|
|
1664
|
+
// is anything but an unreadable store.
|
|
1665
|
+
let replayed;
|
|
1669
1666
|
let currentWorktreeId;
|
|
1670
1667
|
try {
|
|
1668
|
+
replayed = await replayClaimJournal(journalPath, namespace);
|
|
1671
1669
|
currentWorktreeId = await readWorktreeIdentity({
|
|
1672
1670
|
ledgerDirectory: parsedOptions.options.ledger,
|
|
1673
1671
|
gitCommonDir,
|
package/src/git-autocommit.js
CHANGED
|
@@ -119,17 +119,25 @@ async function finalize({
|
|
|
119
119
|
}) {
|
|
120
120
|
const { root, prefix } = placement;
|
|
121
121
|
const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
|
|
122
|
-
|
|
122
|
+
// Every mutation, create included, appends its own journal entries and
|
|
123
|
+
// rebuilds the derived reconciliation log, so every commit set carries that
|
|
124
|
+
// log alongside the item.
|
|
125
|
+
//
|
|
126
|
+
// Log dirt that predates the invocation is a separate question. A mutation on
|
|
127
|
+
// an existing item rebuilds residue its own prior claim operations left in
|
|
128
|
+
// this checkout; a create allocates a fresh identity and has no standing to
|
|
129
|
+
// absorb bytes that were already there, so it refuses instead.
|
|
130
|
+
const absorbsPriorLogDirt = command !== 'create';
|
|
123
131
|
|
|
124
132
|
// Preflight, before any Wowbagger write. Any staged path and every dirty
|
|
125
|
-
// ledger path except
|
|
126
|
-
//
|
|
133
|
+
// ledger path except an absorbable derived log refuse: a broad commit would
|
|
134
|
+
// otherwise take in foreign work.
|
|
127
135
|
const before = await inspectWorktree(root, prefix);
|
|
128
136
|
if (before.staged.length > 0) {
|
|
129
137
|
return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(before.staged) });
|
|
130
138
|
}
|
|
131
139
|
const foreignDirtyBefore = before.dirtyLedger.filter((entry) => (
|
|
132
|
-
!(
|
|
140
|
+
!(absorbsPriorLogDirt && entry === logPath)
|
|
133
141
|
));
|
|
134
142
|
if (foreignDirtyBefore.length > 0) {
|
|
135
143
|
return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(foreignDirtyBefore) });
|
|
@@ -165,13 +173,13 @@ async function finalize({
|
|
|
165
173
|
});
|
|
166
174
|
}
|
|
167
175
|
|
|
168
|
-
// Reconciliation rewrites the tracked log.
|
|
169
|
-
// that log
|
|
176
|
+
// Reconciliation rewrites the tracked log. Every command here will commit
|
|
177
|
+
// that log, so its own rewrite is never stray; anything else is.
|
|
170
178
|
const afterVerify = await inspectWorktree(root, prefix);
|
|
171
179
|
if (afterVerify.staged.length > 0) {
|
|
172
180
|
return shape.preflightFailed('staged-paths-present', { staged_paths: bounded(afterVerify.staged) });
|
|
173
181
|
}
|
|
174
|
-
const strayBefore = afterVerify.dirtyLedger.filter((entry) =>
|
|
182
|
+
const strayBefore = afterVerify.dirtyLedger.filter((entry) => entry !== logPath);
|
|
175
183
|
if (strayBefore.length > 0) {
|
|
176
184
|
return shape.preflightFailed('ledger-not-clean', { dirty_paths: bounded(strayBefore) });
|
|
177
185
|
}
|
|
@@ -210,10 +218,10 @@ async function finalize({
|
|
|
210
218
|
const itemChanged = await itemDiffersFromHead(root, prefix, published.path, published.revision);
|
|
211
219
|
const commitSet = [
|
|
212
220
|
...(itemChanged ? [published.path] : []),
|
|
213
|
-
|
|
221
|
+
logPath,
|
|
214
222
|
].sort(compareText);
|
|
215
223
|
const subject = commitSubject(command, published.number, published.id);
|
|
216
|
-
const witness =
|
|
224
|
+
const witness = shape.terminalWitness(outcome, published);
|
|
217
225
|
const context = {
|
|
218
226
|
command,
|
|
219
227
|
head,
|
|
@@ -232,7 +240,6 @@ async function finalize({
|
|
|
232
240
|
context,
|
|
233
241
|
commitSet,
|
|
234
242
|
digests,
|
|
235
|
-
journalOwned,
|
|
236
243
|
ledgerDirectory,
|
|
237
244
|
logPath,
|
|
238
245
|
ownArtifacts: shape.recoveryArtifactPaths(outcome),
|
|
@@ -287,7 +294,7 @@ async function finalize({
|
|
|
287
294
|
gitCommonDir,
|
|
288
295
|
namespace,
|
|
289
296
|
targetItemId: published.id,
|
|
290
|
-
writeLogWhenEmpty:
|
|
297
|
+
writeLogWhenEmpty: true,
|
|
291
298
|
});
|
|
292
299
|
const reconciliation = reconciliationFailureReason(reconciled, commit.commit, context.operationId, published.id);
|
|
293
300
|
if (reconciliation) {
|
|
@@ -305,7 +312,7 @@ async function finalize({
|
|
|
305
312
|
// Every check that must hold between publication and staging. Returning a
|
|
306
313
|
// reason here means the item is published and no Git write has happened yet.
|
|
307
314
|
async function prepareCommitSet({
|
|
308
|
-
context, commitSet, digests,
|
|
315
|
+
context, commitSet, digests, ledgerDirectory, logPath, ownArtifacts, prefix, root, witness,
|
|
309
316
|
}) {
|
|
310
317
|
let itemBytes;
|
|
311
318
|
try {
|
|
@@ -330,7 +337,6 @@ async function prepareCommitSet({
|
|
|
330
337
|
));
|
|
331
338
|
if (stray.length > 0) return { stage: 'prepare-commit-set', reason: 'tree-changed' };
|
|
332
339
|
|
|
333
|
-
if (!journalOwned) return null;
|
|
334
340
|
let logBytes;
|
|
335
341
|
try {
|
|
336
342
|
logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
|
|
@@ -852,7 +858,6 @@ export async function finalizeFromRecoveryToken({ ledgerDirectory, token }) {
|
|
|
852
858
|
|
|
853
859
|
async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload, placement }) {
|
|
854
860
|
const { root, prefix } = placement;
|
|
855
|
-
const journalOwned = payload.command !== 'create';
|
|
856
861
|
const logPath = ledgerRelative(ledgerDirectory, claimReconcileLogPath(path.resolve(ledgerDirectory), namespace));
|
|
857
862
|
const witness = payload.terminal_witness ?? null;
|
|
858
863
|
|
|
@@ -877,14 +882,15 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
|
|
|
877
882
|
const itemChanged = await itemDiffersFromHead(root, prefix, derived.path, payload.published_revision);
|
|
878
883
|
commitSet = [
|
|
879
884
|
...(itemChanged ? [derived.path] : []),
|
|
880
|
-
|
|
885
|
+
logPath,
|
|
881
886
|
].sort(compareText);
|
|
882
887
|
} else {
|
|
883
888
|
commitSet = [...tokenPaths].sort(compareText);
|
|
884
889
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
890
|
+
// A token is only ever issued by the auto-commit path above, where every
|
|
891
|
+
// command owns its reconciliation log, so recovery accepts the same two
|
|
892
|
+
// shapes for every command.
|
|
893
|
+
const allowedCommitSets = [[logPath], [derived.path, logPath]];
|
|
888
894
|
const tokenMatchesMutation = allowedCommitSets.some((allowed) => sameSet(tokenPaths, allowed));
|
|
889
895
|
if (payload.item_path !== derived.path
|
|
890
896
|
|| !tokenMatchesMutation
|
|
@@ -910,7 +916,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
|
|
|
910
916
|
}
|
|
911
917
|
|
|
912
918
|
const refusal = await finalizePreconditions({
|
|
913
|
-
commitSet, derived, digests, gitPaths,
|
|
919
|
+
commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
|
|
914
920
|
});
|
|
915
921
|
if (refusal) return finalizeRefused(refusal.reason, refusal.details);
|
|
916
922
|
|
|
@@ -952,7 +958,7 @@ async function runFinalize({ gitCommonDir, ledgerDirectory, namespace, payload,
|
|
|
952
958
|
}
|
|
953
959
|
|
|
954
960
|
async function finalizePreconditions({
|
|
955
|
-
commitSet, derived, digests, gitPaths,
|
|
961
|
+
commitSet, derived, digests, gitPaths, ledgerDirectory, logPath, payload, prefix, root, witness,
|
|
956
962
|
}) {
|
|
957
963
|
let itemBytes;
|
|
958
964
|
try {
|
|
@@ -973,7 +979,6 @@ async function finalizePreconditions({
|
|
|
973
979
|
}
|
|
974
980
|
const stray = observed.dirtyLedger.filter((entry) => !commitSet.includes(entry));
|
|
975
981
|
if (stray.length > 0) return { reason: 'ledger-not-clean', details: { dirty_paths: bounded(stray) } };
|
|
976
|
-
if (!journalOwned) return null;
|
|
977
982
|
let logBytes;
|
|
978
983
|
try {
|
|
979
984
|
logBytes = await readFile(path.join(path.resolve(ledgerDirectory), logPath));
|
package/src/mutation.js
CHANGED
|
@@ -230,11 +230,15 @@ export async function createItem(ledgerDirectory, request, scenario) {
|
|
|
230
230
|
ledgerDirectory,
|
|
231
231
|
request.id,
|
|
232
232
|
'create-v1',
|
|
233
|
-
(authorize, ledgerSnapshot) => createItemUnfenced(
|
|
233
|
+
(authorize, ledgerSnapshot) => createItemUnfenced(
|
|
234
|
+
ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
|
|
235
|
+
),
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
async function createItemUnfenced(
|
|
239
|
+
async function createItemUnfenced(
|
|
240
|
+
ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
|
|
241
|
+
) {
|
|
238
242
|
const root = path.resolve(ledgerDirectory);
|
|
239
243
|
const id = request.id;
|
|
240
244
|
const readPreLockLedger = snapshotReader(root, ledgerSnapshot);
|
|
@@ -362,6 +366,15 @@ async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnap
|
|
|
362
366
|
}));
|
|
363
367
|
}
|
|
364
368
|
|
|
369
|
+
// The allocation this create proposes becomes journal-visible before any
|
|
370
|
+
// byte reaches the ledger, so a sibling worktree that cannot see this
|
|
371
|
+
// item's publication cannot hand the same number out again. The intent is
|
|
372
|
+
// appended only once the candidate is known publishable, so a refusal
|
|
373
|
+
// this command would have returned anyway records no attempt.
|
|
374
|
+
if (authorize) {
|
|
375
|
+
await authorize(null, revisionFor(bytes), relativeFinalPath);
|
|
376
|
+
}
|
|
377
|
+
|
|
365
378
|
temporaryPath = path.join(finalDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
|
|
366
379
|
const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
|
|
367
380
|
if (temporaryFailure) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The reconciliation topology, decided once, from evidence alone.
|
|
2
|
+
//
|
|
3
|
+
// Every command that reconciles a claim journal has to answer the same
|
|
4
|
+
// question about a drifted item: which of the recognized topologies is this,
|
|
5
|
+
// and does it block the write in front of us? Answering it inside each command
|
|
6
|
+
// is how the answers drifted apart, so the decision lives here, pure: no Git,
|
|
7
|
+
// no filesystem, no prose. Callers gather the evidence, render the sentences,
|
|
8
|
+
// and keep the scope to themselves.
|
|
9
|
+
//
|
|
10
|
+
// The vocabulary:
|
|
11
|
+
//
|
|
12
|
+
// revision state where a surface's bytes stand against the journal —
|
|
13
|
+
// `expected` the authorized revision itself, `authorized`
|
|
14
|
+
// some other revision the journal once ruled legitimate,
|
|
15
|
+
// `unknown` bytes no ruling covers, `absent` no bytes at all.
|
|
16
|
+
// owner evidence `{ kind, ref?, commit? }` from `findRevisionOwner`: which
|
|
17
|
+
// live worktree, if any, carries the expected revision.
|
|
18
|
+
// expected writer `current` when the journal names this worktree as the
|
|
19
|
+
// writer of the expected revision, `other` when it names
|
|
20
|
+
// another, `unknown` when nothing can be attributed.
|
|
21
|
+
// scope who a finding blocks: `global` every write, `target` only
|
|
22
|
+
// a write against the item it names, `none` nobody.
|
|
23
|
+
// remediation which remedy the topology prescribes; the caller renders
|
|
24
|
+
// the sentence, so the kinds carry no wording.
|
|
25
|
+
|
|
26
|
+
// Where one surface's bytes stand against the journal. `expected` is a
|
|
27
|
+
// refinement of `authorized`, so it is tested first.
|
|
28
|
+
export function normalizeRevision(revision, expectedRevision, authorizedRevisions) {
|
|
29
|
+
if (revision === null) return 'absent';
|
|
30
|
+
if (revision === expectedRevision) return 'expected';
|
|
31
|
+
return authorizedRevisions.has(revision) ? 'authorized' : 'unknown';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Owner evidence costs a walk of every live worktree's history, so the two
|
|
35
|
+
// topologies that never consult it must not pay for it. The predicate answers
|
|
36
|
+
// from the same states the classifier judges, through the same helper, so
|
|
37
|
+
// neither can drift from the other.
|
|
38
|
+
export function requiresOwnerEvidence({ workingTree, head }) {
|
|
39
|
+
return !isUnattributed(workingTree, head) && workingTree !== 'expected';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Bytes no ruling covers, on either surface, and a working tree that is gone
|
|
43
|
+
// while another surface still holds bytes. Nothing here is attributable to a
|
|
44
|
+
// writer or an owner: the local state is simply out of protocol.
|
|
45
|
+
function isUnattributed(workingTree, head) {
|
|
46
|
+
return workingTree === 'unknown'
|
|
47
|
+
|| head === 'unknown'
|
|
48
|
+
|| (workingTree === 'absent' && head !== 'absent');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One topology, one member. `expectedOwner` is required exactly when
|
|
52
|
+
// `requiresOwnerEvidence` says so, and is never read otherwise.
|
|
53
|
+
export function classifyReconciliation({ workingTree, head, expectedOwner, expectedWriter }) {
|
|
54
|
+
if (isUnattributed(workingTree, head)) return UNAUTHORIZED_REVISION;
|
|
55
|
+
// The authorized bytes are here and Git has yet to record them. Nothing is
|
|
56
|
+
// in doubt but the commit.
|
|
57
|
+
if (workingTree === 'expected') {
|
|
58
|
+
return { scope: 'global', reason: 'git-finalization-required', remediation: 'commit-in-git' };
|
|
59
|
+
}
|
|
60
|
+
// An item absent from both local surfaces has never existed in this
|
|
61
|
+
// checkout. A sibling may carry the expected revision, but that does not
|
|
62
|
+
// establish ownership for a checkout with no local history or item path.
|
|
63
|
+
if (workingTree === 'absent') {
|
|
64
|
+
return {
|
|
65
|
+
scope: 'target',
|
|
66
|
+
reason: 'worktree-synchronization-required',
|
|
67
|
+
remediation: 'establish-ownership',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// A live named worktree carries the expected revision, so there is a ref to
|
|
71
|
+
// wait on and a commit to name. This outranks the remaining synchronization
|
|
72
|
+
// answers, because it is the only one that names an owner.
|
|
73
|
+
if (expectedOwner.kind === 'named-sibling') {
|
|
74
|
+
return {
|
|
75
|
+
scope: 'target',
|
|
76
|
+
reason: 'worktree-synchronization-required',
|
|
77
|
+
owner: expectedOwner,
|
|
78
|
+
remediation: 'wait-for-named-owner',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Advice to wait for an owning worktree needs an owner that could still
|
|
82
|
+
// appear. When the journal names this worktree as the writer of the expected
|
|
83
|
+
// revision, or this worktree's own history reaches it, the successor exists
|
|
84
|
+
// nowhere but in the journal: there is nothing to synchronize from, and the
|
|
85
|
+
// authorized bytes on disk are simply the wrong ones.
|
|
86
|
+
if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
|
|
87
|
+
return {
|
|
88
|
+
scope: 'target',
|
|
89
|
+
reason: 'worktree-synchronization-required',
|
|
90
|
+
// Waiting is only truthful while the commit is still missing. Git already
|
|
91
|
+
// reaches a `reachable-unowned` revision through a tag, a remote-tracking
|
|
92
|
+
// ref, an unchecked branch, or a detached HEAD, so telling a reader to
|
|
93
|
+
// wait for a commit names a wait that can never end: the bytes are there
|
|
94
|
+
// to inspect, and no named worktree will publish them.
|
|
95
|
+
remediation: expectedOwner.kind === 'reachable-unowned'
|
|
96
|
+
? 'inspect-reachable-history'
|
|
97
|
+
: 'await-owner-commit',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return UNAUTHORIZED_REVISION;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const UNAUTHORIZED_REVISION = Object.freeze({
|
|
104
|
+
scope: 'global',
|
|
105
|
+
reason: 'unauthorized-revision',
|
|
106
|
+
remediation: 'restore-or-adopt',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Scope, never reason text, decides what a finding refuses. A mutation names
|
|
110
|
+
// the item it targets, and a synchronization another item waits on is a wait
|
|
111
|
+
// this mutation does not touch. A caller that names no target, such as the
|
|
112
|
+
// `claim-verify` command, keeps every finding blocking.
|
|
113
|
+
export function blocksTarget(scope, itemId, targetItemId) {
|
|
114
|
+
if (scope === 'none') return false;
|
|
115
|
+
if (scope === 'global') return true;
|
|
116
|
+
return targetItemId === null || itemId === targetItemId;
|
|
117
|
+
}
|