nawabari 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -11
- package/dist/cli.js +497 -92
- package/dist/cli.js.map +1 -1
- package/dist/contract.js +121 -2
- package/dist/contract.js.map +1 -1
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/session-backend.d.ts +5 -3
- package/dist/domain/session-backend.js +101 -4
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +116 -2
- package/dist/domain/session.js +36 -0
- package/dist/domain/session.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/git.d.ts +62 -0
- package/dist/git.js +299 -0
- package/dist/git.js.map +1 -1
- package/dist/operation-authorization.d.ts +5 -2
- package/dist/operation-authorization.js +6 -7
- package/dist/operation-authorization.js.map +1 -1
- package/dist/output-budget.d.ts +16 -0
- package/dist/output-budget.js +58 -0
- package/dist/output-budget.js.map +1 -0
- package/dist/presentation.js +28 -4
- package/dist/presentation.js.map +1 -1
- package/dist/repository-evidence.d.ts +74 -0
- package/dist/repository-evidence.js +7 -0
- package/dist/repository-evidence.js.map +1 -0
- package/dist/resource-claims.d.ts +25 -3
- package/dist/resource-claims.js +57 -5
- package/dist/resource-claims.js.map +1 -1
- package/dist/session-registry.d.ts +43 -7
- package/dist/session-registry.js +375 -102
- package/dist/session-registry.js.map +1 -1
- package/package.json +2 -1
package/dist/session-registry.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, readCurrentHead, resolveRepositoryContext, verifyPhysicalExecutionContext, } from "./git.js";
|
|
3
|
+
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readGitPathStats, readCurrentHead, resolveRepositoryContext, verifyPhysicalExecutionContext, } from "./git.js";
|
|
4
4
|
import { SessionRegistryError } from "./errors.js";
|
|
5
5
|
import { generateSessionId, isSessionId } from "./session-id.js";
|
|
6
6
|
import { RegistryLockError, RepositoryLock } from "./registry/lock.js";
|
|
7
|
-
import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput,
|
|
7
|
+
import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput, canonicalizeConcretePath, claimError, claimsConflict, claimsOverlap, cloneResourceClaim, compareCodePointStrings, createResourceClaim, isResourceClaimMode, LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION, resourceClaimConflictsWithAccess, resourceMatchesClaim, RESOURCE_CLAIM_SCHEMA_VERSION, sortResourceClaims, } from "./resource-claims.js";
|
|
8
8
|
import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
|
|
9
|
+
import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
|
|
10
|
+
export { DIFF_EVIDENCE_SCHEMA_VERSION, EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, REPOSITORY_EVIDENCE_SCHEMA_VERSION, } from "./repository-evidence.js";
|
|
9
11
|
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
10
12
|
export { RESOURCE_CLAIM_SCHEMA_VERSION };
|
|
11
13
|
export const REGISTRY_DIRECTORY_NAME = "nawabari";
|
|
@@ -15,6 +17,8 @@ export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
|
|
|
15
17
|
export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
|
|
16
18
|
export const RECONCILIATION_SCHEMA_VERSION = 1;
|
|
17
19
|
const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
|
|
20
|
+
/** Bounds a caller-declared commit-message pattern before it is compiled. */
|
|
21
|
+
export const MAX_MESSAGE_PATTERN_LENGTH = 512;
|
|
18
22
|
export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
|
|
19
23
|
export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
|
|
20
24
|
const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
|
|
@@ -75,6 +79,10 @@ export class SessionRegistry {
|
|
|
75
79
|
metadataGraceMs: this.lockMetadataGraceMs,
|
|
76
80
|
});
|
|
77
81
|
}
|
|
82
|
+
/** Resolved repository-local root used for default managed session worktrees. */
|
|
83
|
+
get managedWorktreeRoot() {
|
|
84
|
+
return this.worktreeRoot;
|
|
85
|
+
}
|
|
78
86
|
read() {
|
|
79
87
|
return this.readUnsafe().map(cloneSessionRecord);
|
|
80
88
|
}
|
|
@@ -105,16 +113,18 @@ export class SessionRegistry {
|
|
|
105
113
|
return claim === undefined ? undefined : cloneResourceClaim(claim);
|
|
106
114
|
}
|
|
107
115
|
/**
|
|
108
|
-
* Explicitly materialize
|
|
109
|
-
*
|
|
116
|
+
* Explicitly materialize a claim section or upgrade its semantics. A v1
|
|
117
|
+
* claim registry is intentionally unreadable through ordinary operations;
|
|
118
|
+
* only this locked, explicit migration rewrites it as v2.
|
|
110
119
|
*/
|
|
111
120
|
migrate() {
|
|
112
121
|
return this.withLock(() => {
|
|
113
|
-
const state = this.readStateUnsafe();
|
|
114
|
-
|
|
122
|
+
const state = this.readStateUnsafe(true);
|
|
123
|
+
const migrated = state.legacyClaimsAbsent || state.legacyClaimsSchemaVersion !== undefined;
|
|
124
|
+
if (migrated)
|
|
115
125
|
this.writeUnsafe(state.sessions, state.claims);
|
|
116
126
|
return {
|
|
117
|
-
migrated
|
|
127
|
+
migrated,
|
|
118
128
|
registrySchemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
119
129
|
claimSchemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
120
130
|
};
|
|
@@ -248,6 +258,7 @@ export class SessionRegistry {
|
|
|
248
258
|
state: "active",
|
|
249
259
|
createdAt: timestamp,
|
|
250
260
|
updatedAt: timestamp,
|
|
261
|
+
baseRevision: resources.baseRevision,
|
|
251
262
|
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
252
263
|
});
|
|
253
264
|
assertNoOwnershipConflict(records, record);
|
|
@@ -282,13 +293,14 @@ export class SessionRegistry {
|
|
|
282
293
|
state: "active",
|
|
283
294
|
createdAt: timestamp,
|
|
284
295
|
updatedAt: timestamp,
|
|
296
|
+
baseRevision: resources.baseRevision,
|
|
285
297
|
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
286
298
|
});
|
|
287
299
|
assertNoOwnershipConflict(state.sessions, record);
|
|
288
300
|
assertGitResourcesAvailable(this.git, this.repository.worktreePath, resources);
|
|
289
301
|
let gitProvisioned = false;
|
|
290
302
|
try {
|
|
291
|
-
this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.
|
|
303
|
+
this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRevision], this.repository.worktreePath);
|
|
292
304
|
gitProvisioned = true;
|
|
293
305
|
verifyPhysicalExecutionContext({
|
|
294
306
|
repository: this.repository,
|
|
@@ -359,6 +371,36 @@ export class SessionRegistry {
|
|
|
359
371
|
const session = this.resolveSessionOwnership(physical, options.sessionId ?? null);
|
|
360
372
|
return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
|
|
361
373
|
}
|
|
374
|
+
/** Resolve and physically verify an explicitly addressed active session. */
|
|
375
|
+
verifyEvidenceSession(sessionId) {
|
|
376
|
+
assertSessionId(sessionId);
|
|
377
|
+
const record = this.readUnsafe().find((candidate) => candidate.sessionId === sessionId);
|
|
378
|
+
if (record === undefined || record.state === "closed") {
|
|
379
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
380
|
+
}
|
|
381
|
+
if (record.state !== "active") {
|
|
382
|
+
throw new SessionRegistryError("STALE_REGISTRY", `Session is not active: ${sessionId}`, {
|
|
383
|
+
sessionId,
|
|
384
|
+
state: record.state,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const physical = verifyPhysicalExecutionContext({
|
|
388
|
+
repository: this.repository,
|
|
389
|
+
worktreePath: record.worktreePath,
|
|
390
|
+
branchName: record.branchName,
|
|
391
|
+
git: this.git,
|
|
392
|
+
});
|
|
393
|
+
if (physical.worktreeId !== record.worktreeId || physical.branchId !== record.branchId) {
|
|
394
|
+
throw new SessionRegistryError("OWNERSHIP_MISMATCH", "The selected session does not own the observed worktree", {
|
|
395
|
+
sessionId,
|
|
396
|
+
expectedWorktree: record.worktreePath,
|
|
397
|
+
actualWorktree: physical.worktreePath,
|
|
398
|
+
expectedBranch: record.branchName,
|
|
399
|
+
actualBranch: physical.branchName,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return Object.freeze({ session: cloneSessionRecord(record), physical });
|
|
403
|
+
}
|
|
362
404
|
/** Shared physical/protected/ownership path for every governed decision. */
|
|
363
405
|
verifyGovernedExecutionContext(requestedSessionId, observedPhysical) {
|
|
364
406
|
const physical = observedPhysical ??
|
|
@@ -594,6 +636,14 @@ export class SessionRegistry {
|
|
|
594
636
|
ownerMode: conflictingClaim.mode,
|
|
595
637
|
});
|
|
596
638
|
}
|
|
639
|
+
if (ownClaims.length > 0) {
|
|
640
|
+
return deniedOperation("INSUFFICIENT_CLAIM_MODE", verified, {
|
|
641
|
+
resource,
|
|
642
|
+
requiredAccess,
|
|
643
|
+
grantedModes: sortStrings(new Set(ownClaims.map((claim) => claim.mode))),
|
|
644
|
+
sessionId: currentRecord.sessionId,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
597
647
|
return deniedOperation("MISSING_RESOURCE_CLAIM", verified, {
|
|
598
648
|
resource,
|
|
599
649
|
requiredAccess,
|
|
@@ -634,13 +684,17 @@ export class SessionRegistry {
|
|
|
634
684
|
* ownership or mutation authority.
|
|
635
685
|
*/
|
|
636
686
|
commit(options) {
|
|
687
|
+
// Validated before the repository lock is acquired: a pathological
|
|
688
|
+
// caller-declared pattern must not hold the cross-session repository
|
|
689
|
+
// lock hostage while it backtracks.
|
|
690
|
+
const messagePattern = options.messagePattern ?? options.message_pattern ?? null;
|
|
691
|
+
assertFinalCommitMessage(options.message, messagePattern);
|
|
637
692
|
return this.withLock(() => {
|
|
638
693
|
const sessionId = operationSessionId(options.sessionId, options.session_id);
|
|
639
694
|
const requestedResources = operationResources(options.resources ?? options.paths);
|
|
640
695
|
const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
|
|
641
696
|
const initial = this.verifyGovernedExecutionContext(sessionId);
|
|
642
|
-
|
|
643
|
-
const before = observeMutationPaths(this.git, initial.worktreePath);
|
|
697
|
+
const before = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
644
698
|
assertNoUnexpectedMutationPaths(before.changed, authorization.resources.map((resource) => resource.resource));
|
|
645
699
|
const stageResources = authorization.resources
|
|
646
700
|
.map((resource) => resource.resource)
|
|
@@ -652,7 +706,7 @@ export class SessionRegistry {
|
|
|
652
706
|
}
|
|
653
707
|
reverifyMutationContext(this, initial, sessionId, "stage");
|
|
654
708
|
runMutationGit(this.git, ["add", "--", ...stageResources], initial.worktreePath, "stage", "COMMIT_STAGING_FAILED");
|
|
655
|
-
const afterStage =
|
|
709
|
+
const afterStage = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
656
710
|
assertNoUnexpectedMutationPaths(afterStage.changed, stageResources);
|
|
657
711
|
if (!afterStage.staged.some((resource) => stageResources.includes(resource))) {
|
|
658
712
|
throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Git staging produced no authorized staged paths", {
|
|
@@ -660,7 +714,7 @@ export class SessionRegistry {
|
|
|
660
714
|
});
|
|
661
715
|
}
|
|
662
716
|
reverifyMutationContext(this, initial, sessionId, "commit");
|
|
663
|
-
const beforeCommit =
|
|
717
|
+
const beforeCommit = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
664
718
|
assertNoUnexpectedMutationPaths(beforeCommit.changed, stageResources);
|
|
665
719
|
if (!beforeCommit.staged.some((resource) => stageResources.includes(resource))) {
|
|
666
720
|
throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Authorized staged paths disappeared before commit", {
|
|
@@ -677,11 +731,34 @@ export class SessionRegistry {
|
|
|
677
731
|
throw error;
|
|
678
732
|
throw new SessionRegistryError("COMMIT_RESULT_UNAVAILABLE", "Git committed the operation but the resulting commit SHA could not be resolved", { phase: "resolve-commit-result" }, error);
|
|
679
733
|
}
|
|
734
|
+
// The Git commit succeeded and its SHA is known; every failure from here
|
|
735
|
+
// must retain commitSha so the caller can recover/reconcile the result
|
|
736
|
+
// instead of losing track of a commit that actually happened.
|
|
737
|
+
let committedPaths;
|
|
738
|
+
try {
|
|
739
|
+
committedPaths = readCanonicalCommitChangedPaths(this.git, initial.worktreePath, commitSha);
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
if (error instanceof SessionRegistryError && isBoundedGitFailure(error.code)) {
|
|
743
|
+
throw new SessionRegistryError(error.code, error.message, { ...error.details, commitSha }, error);
|
|
744
|
+
}
|
|
745
|
+
throw new SessionRegistryError("COMMIT_RESULT_UNAVAILABLE", "Git committed the operation but its actual changed paths could not be resolved", { commitSha, phase: "resolve-commit-paths" }, error);
|
|
746
|
+
}
|
|
747
|
+
const authorizedSet = new Set(stageResources);
|
|
748
|
+
const divergentResources = committedPaths.filter((resource) => !authorizedSet.has(resource));
|
|
749
|
+
if (divergentResources.length > 0) {
|
|
750
|
+
throw new SessionRegistryError("COMMIT_RESULT_DIVERGED", "The resulting commit contains paths outside the authorized/staged resource set", {
|
|
751
|
+
commitSha,
|
|
752
|
+
authorizedResources: [...stageResources],
|
|
753
|
+
committedResources: [...committedPaths],
|
|
754
|
+
divergentResources,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
680
757
|
return Object.freeze({
|
|
681
758
|
schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
|
|
682
759
|
commitSha,
|
|
683
760
|
message: options.message,
|
|
684
|
-
resources:
|
|
761
|
+
resources: committedPaths,
|
|
685
762
|
});
|
|
686
763
|
});
|
|
687
764
|
}
|
|
@@ -705,7 +782,7 @@ export class SessionRegistry {
|
|
|
705
782
|
pushBranch: branch,
|
|
706
783
|
});
|
|
707
784
|
}
|
|
708
|
-
const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch);
|
|
785
|
+
const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch, initial.headId);
|
|
709
786
|
if (inspection.upstream !== undefined && inspection.upstream !== `${remote}/${branch}`) {
|
|
710
787
|
throw new SessionRegistryError("PUSH_TARGET_MISMATCH", "The explicit push target does not match the currently configured upstream", { upstream: inspection.upstream, remote, branch });
|
|
711
788
|
}
|
|
@@ -719,32 +796,44 @@ export class SessionRegistry {
|
|
|
719
796
|
const code = inspection.relation === "behind" ? "PUSH_BEHIND" : "PUSH_DIVERGED";
|
|
720
797
|
throw new SessionRegistryError(code, `The local and remote branches are ${inspection.relation}; explicit force intent is required`, { remote, branch, relation: inspection.relation, forceRequired: true });
|
|
721
798
|
}
|
|
722
|
-
const dirtyBefore =
|
|
799
|
+
const dirtyBefore = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
723
800
|
if (dirtyBefore.changed.length > 0) {
|
|
724
801
|
throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "Push requires a clean Git worktree", {
|
|
725
802
|
paths: [...dirtyBefore.changed],
|
|
726
803
|
});
|
|
727
804
|
}
|
|
728
|
-
reverifyMutationContext(this, initial, sessionId, "push");
|
|
729
|
-
const dirtyBeforePush =
|
|
805
|
+
const finalContext = reverifyMutationContext(this, initial, sessionId, "push");
|
|
806
|
+
const dirtyBeforePush = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
730
807
|
if (dirtyBeforePush.changed.length > 0) {
|
|
731
808
|
throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "The worktree changed before push mutation", {
|
|
732
809
|
paths: [...dirtyBeforePush.changed],
|
|
733
810
|
});
|
|
734
811
|
}
|
|
812
|
+
const targetRef = `refs/heads/${branch}`;
|
|
813
|
+
const leaseValue = inspection.observedRemoteSha ?? "";
|
|
735
814
|
const pushArguments = [
|
|
736
815
|
"push",
|
|
737
|
-
...(force ? [
|
|
816
|
+
...(force ? [`--force-with-lease=${targetRef}:${leaseValue}`] : ["--no-force"]),
|
|
738
817
|
...(createUpstream ? ["--set-upstream"] : []),
|
|
739
818
|
remote,
|
|
740
|
-
|
|
819
|
+
`${finalContext.headId}:${targetRef}`,
|
|
741
820
|
];
|
|
742
821
|
runMutationGit(this.git, pushArguments, initial.worktreePath, "push", "PUSH_FAILED");
|
|
822
|
+
if (createUpstream) {
|
|
823
|
+
// A SHA:ref refspec is intentional: it cannot be redirected by a
|
|
824
|
+
// mutable symbolic HEAD. Git does not consistently infer the current
|
|
825
|
+
// branch's tracking configuration from a raw SHA source, so complete
|
|
826
|
+
// the explicitly requested local upstream setup after the push.
|
|
827
|
+
runMutationGit(this.git, ["branch", "--set-upstream-to", `${remote}/${branch}`, branch], initial.worktreePath, "set-upstream", "PUSH_FAILED");
|
|
828
|
+
}
|
|
743
829
|
return Object.freeze({
|
|
744
830
|
schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
|
|
831
|
+
sourceSha: finalContext.headId,
|
|
745
832
|
remote,
|
|
746
833
|
branch,
|
|
747
834
|
target: `${remote}/${branch}`,
|
|
835
|
+
targetRef,
|
|
836
|
+
observedRemoteSha: inspection.observedRemoteSha,
|
|
748
837
|
relation: inspection.relation,
|
|
749
838
|
force,
|
|
750
839
|
upstreamCreated: createUpstream,
|
|
@@ -776,13 +865,7 @@ export class SessionRegistry {
|
|
|
776
865
|
const execution = this.verifyGovernedExecutionContext(requestedSessionId);
|
|
777
866
|
const physical = execution;
|
|
778
867
|
const session = execution.session;
|
|
779
|
-
const
|
|
780
|
-
const paths = {
|
|
781
|
-
changed: canonicalObservedPaths(observed.changed, physical.worktreePath),
|
|
782
|
-
staged: canonicalObservedPaths(observed.staged, physical.worktreePath),
|
|
783
|
-
unstaged: canonicalObservedPaths(observed.unstaged, physical.worktreePath),
|
|
784
|
-
untracked: canonicalObservedPaths(observed.untracked, physical.worktreePath),
|
|
785
|
-
};
|
|
868
|
+
const paths = observeGitCheckpoint(this.git, physical.worktreePath);
|
|
786
869
|
const state = this.readStateUnsafe();
|
|
787
870
|
const activeClaims = state.claims.filter((claim) => claim.sessionId === session.sessionId &&
|
|
788
871
|
state.sessions.some((record) => record.sessionId === claim.sessionId && record.state === "active"));
|
|
@@ -810,6 +893,122 @@ export class SessionRegistry {
|
|
|
810
893
|
captureCheckpoint(options = {}) {
|
|
811
894
|
return this.checkpoint(options);
|
|
812
895
|
}
|
|
896
|
+
/**
|
|
897
|
+
* Capture one deterministic, read-only physical repository generation for
|
|
898
|
+
* an explicitly addressed owned session.
|
|
899
|
+
*/
|
|
900
|
+
repositoryEvidence(options) {
|
|
901
|
+
return this.withLock(() => {
|
|
902
|
+
const initial = this.verifyEvidenceSession(options.sessionId);
|
|
903
|
+
const paths = observeGitCheckpoint(this.git, initial.physical.worktreePath);
|
|
904
|
+
const stats = paths.changed.length === 0
|
|
905
|
+
? []
|
|
906
|
+
: readGitPathStats(this.git, initial.physical.worktreePath, paths.changed, initial.physical.headId);
|
|
907
|
+
const final = this.verifyEvidenceSession(options.sessionId);
|
|
908
|
+
if (final.physical.branchName !== initial.physical.branchName ||
|
|
909
|
+
final.physical.headId !== initial.physical.headId ||
|
|
910
|
+
!sameCheckpointPaths(paths, observeGitCheckpoint(this.git, final.physical.worktreePath))) {
|
|
911
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository evidence changed during physical observation", {
|
|
912
|
+
sessionId: options.sessionId,
|
|
913
|
+
worktree: initial.physical.worktreePath,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
const baseRevision = initial.session.baseRevision ?? null;
|
|
917
|
+
const incompleteReasons = stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : [];
|
|
918
|
+
const pathsEvidence = Object.freeze({
|
|
919
|
+
changed: paths.changed,
|
|
920
|
+
staged: paths.staged,
|
|
921
|
+
unstaged: paths.unstaged,
|
|
922
|
+
untracked: paths.untracked,
|
|
923
|
+
stats: Object.freeze([...stats]),
|
|
924
|
+
});
|
|
925
|
+
const snapshotWithoutHash = {
|
|
926
|
+
schemaVersion: 1,
|
|
927
|
+
source: "git",
|
|
928
|
+
guarantee: "git-observable-only",
|
|
929
|
+
repositoryId: initial.physical.repositoryId,
|
|
930
|
+
worktreePath: initial.physical.worktreePath,
|
|
931
|
+
branchId: initial.physical.branchId,
|
|
932
|
+
branchName: initial.physical.branchName,
|
|
933
|
+
sessionId: initial.session.sessionId,
|
|
934
|
+
sessionState: initial.session.state,
|
|
935
|
+
sessionCreatedAt: initial.session.createdAt,
|
|
936
|
+
sessionUpdatedAt: initial.session.updatedAt,
|
|
937
|
+
baseRevision,
|
|
938
|
+
baseRevisionProven: baseRevision !== null,
|
|
939
|
+
headId: initial.physical.headId,
|
|
940
|
+
clean: paths.changed.length === 0,
|
|
941
|
+
complete: incompleteReasons.length === 0,
|
|
942
|
+
incompleteReasons: Object.freeze(incompleteReasons),
|
|
943
|
+
paths: pathsEvidence,
|
|
944
|
+
bounds: Object.freeze({
|
|
945
|
+
maxPaths: CHECKPOINT_MAX_PATHS,
|
|
946
|
+
maxDiffPaths: EVIDENCE_MAX_DIFF_PATHS,
|
|
947
|
+
maxDiffBytes: EVIDENCE_MAX_DIFF_BYTES,
|
|
948
|
+
maxDiffHunks: EVIDENCE_MAX_DIFF_HUNKS,
|
|
949
|
+
}),
|
|
950
|
+
};
|
|
951
|
+
return Object.freeze({
|
|
952
|
+
...snapshotWithoutHash,
|
|
953
|
+
evidenceHash: evidenceHash(snapshotWithoutHash),
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
evidenceSnapshot(options) {
|
|
958
|
+
return this.repositoryEvidence(options);
|
|
959
|
+
}
|
|
960
|
+
/** Inspect only explicitly selected paths with bounded stat/patch output. */
|
|
961
|
+
repositoryDiff(options) {
|
|
962
|
+
return this.withLock(() => {
|
|
963
|
+
const initial = this.verifyEvidenceSession(options.sessionId);
|
|
964
|
+
const diff = readBoundedGitDiff(this.git, initial.physical.worktreePath, {
|
|
965
|
+
paths: options.paths,
|
|
966
|
+
from: options.from,
|
|
967
|
+
to: options.to,
|
|
968
|
+
includePatch: options.includePatch,
|
|
969
|
+
maxBytes: options.maxBytes,
|
|
970
|
+
maxHunks: options.maxHunks,
|
|
971
|
+
});
|
|
972
|
+
const final = this.verifyEvidenceSession(options.sessionId);
|
|
973
|
+
if (final.physical.branchName !== initial.physical.branchName ||
|
|
974
|
+
final.physical.headId !== initial.physical.headId) {
|
|
975
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository diff changed during physical observation", {
|
|
976
|
+
sessionId: options.sessionId,
|
|
977
|
+
worktree: initial.physical.worktreePath,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
const diffWithoutHash = {
|
|
981
|
+
schemaVersion: diff.schemaVersion,
|
|
982
|
+
source: "git",
|
|
983
|
+
guarantee: "git-observable-only",
|
|
984
|
+
repositoryId: initial.physical.repositoryId,
|
|
985
|
+
worktreePath: initial.physical.worktreePath,
|
|
986
|
+
branchId: initial.physical.branchId,
|
|
987
|
+
branchName: initial.physical.branchName,
|
|
988
|
+
sessionId: initial.session.sessionId,
|
|
989
|
+
sessionState: initial.session.state,
|
|
990
|
+
headId: initial.physical.headId,
|
|
991
|
+
fromRevision: diff.fromRevision,
|
|
992
|
+
toRevision: diff.toRevision,
|
|
993
|
+
paths: diff.paths,
|
|
994
|
+
stats: diff.stats,
|
|
995
|
+
complete: diff.stats.every((stat) => stat.available),
|
|
996
|
+
incompleteReasons: Object.freeze(diff.stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : []),
|
|
997
|
+
patch: diff.patch,
|
|
998
|
+
patchBytes: diff.patchBytes,
|
|
999
|
+
hunkCount: diff.hunkCount,
|
|
1000
|
+
maxBytes: options.maxBytes ?? EVIDENCE_MAX_DIFF_BYTES,
|
|
1001
|
+
maxHunks: options.maxHunks ?? EVIDENCE_MAX_DIFF_HUNKS,
|
|
1002
|
+
};
|
|
1003
|
+
return Object.freeze({
|
|
1004
|
+
...diffWithoutHash,
|
|
1005
|
+
evidenceHash: evidenceHash(diffWithoutHash),
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
diff(options) {
|
|
1010
|
+
return this.repositoryDiff(options);
|
|
1011
|
+
}
|
|
813
1012
|
/**
|
|
814
1013
|
* Produce the authoritative, non-mutating cleanup decision for one session.
|
|
815
1014
|
* The decision observes registry claims and current Git/filesystem state in
|
|
@@ -1393,12 +1592,13 @@ export class SessionRegistry {
|
|
|
1393
1592
|
branch: shortBranchName,
|
|
1394
1593
|
});
|
|
1395
1594
|
}
|
|
1396
|
-
const
|
|
1595
|
+
const base = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
|
|
1397
1596
|
return {
|
|
1398
1597
|
worktreePath: requestedWorktreePath,
|
|
1399
1598
|
branchId,
|
|
1400
1599
|
branchName: shortBranchName,
|
|
1401
|
-
baseRef,
|
|
1600
|
+
baseRef: base.ref,
|
|
1601
|
+
baseRevision: base.revision,
|
|
1402
1602
|
};
|
|
1403
1603
|
}
|
|
1404
1604
|
resolveCreationResources(options) {
|
|
@@ -1413,9 +1613,10 @@ export class SessionRegistry {
|
|
|
1413
1613
|
worktreePath: physical.worktreePath,
|
|
1414
1614
|
branchId: physical.branchId,
|
|
1415
1615
|
branchName: physical.branchName,
|
|
1616
|
+
baseRevision: physical.headId,
|
|
1416
1617
|
};
|
|
1417
1618
|
}
|
|
1418
|
-
readStateUnsafe() {
|
|
1619
|
+
readStateUnsafe(allowLegacyClaimSchema = false) {
|
|
1419
1620
|
let contents;
|
|
1420
1621
|
try {
|
|
1421
1622
|
contents = fs.readFileSync(this.paths.registry, "utf8");
|
|
@@ -1437,7 +1638,7 @@ export class SessionRegistry {
|
|
|
1437
1638
|
path: this.paths.registry,
|
|
1438
1639
|
}, error);
|
|
1439
1640
|
}
|
|
1440
|
-
return parseRegistry(parsed, this.repository.repositoryId);
|
|
1641
|
+
return parseRegistry(parsed, this.repository.repositoryId, allowLegacyClaimSchema);
|
|
1441
1642
|
}
|
|
1442
1643
|
readUnsafe() {
|
|
1443
1644
|
return this.readStateUnsafe().sessions;
|
|
@@ -1676,7 +1877,7 @@ function canonicalOperationResources(resources, worktreePath) {
|
|
|
1676
1877
|
});
|
|
1677
1878
|
}
|
|
1678
1879
|
try {
|
|
1679
|
-
canonical.add(
|
|
1880
|
+
canonical.add(canonicalizeConcretePath(resource, worktreePath));
|
|
1680
1881
|
}
|
|
1681
1882
|
catch (error) {
|
|
1682
1883
|
if (error instanceof SessionRegistryError &&
|
|
@@ -1702,13 +1903,28 @@ function operationSessionId(first, second) {
|
|
|
1702
1903
|
function operationResources(resources) {
|
|
1703
1904
|
return resources === undefined ? [] : [...resources];
|
|
1704
1905
|
}
|
|
1705
|
-
function assertFinalCommitMessage(message) {
|
|
1906
|
+
function assertFinalCommitMessage(message, messagePattern) {
|
|
1706
1907
|
if (typeof message !== "string" ||
|
|
1707
1908
|
message.length === 0 ||
|
|
1708
1909
|
message.trim().length === 0 ||
|
|
1709
1910
|
message.includes("\u0000")) {
|
|
1710
1911
|
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message must be a non-empty final message without NUL bytes");
|
|
1711
1912
|
}
|
|
1913
|
+
if (messagePattern === null)
|
|
1914
|
+
return;
|
|
1915
|
+
if (messagePattern.length > MAX_MESSAGE_PATTERN_LENGTH) {
|
|
1916
|
+
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern exceeds the maximum bounded length", { reason: "message-pattern-too-long", maxMessagePatternLength: MAX_MESSAGE_PATTERN_LENGTH });
|
|
1917
|
+
}
|
|
1918
|
+
let pattern;
|
|
1919
|
+
try {
|
|
1920
|
+
pattern = new RegExp(messagePattern, "u");
|
|
1921
|
+
}
|
|
1922
|
+
catch (error) {
|
|
1923
|
+
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern is not a valid regular expression", { reason: "invalid-message-pattern", messagePattern }, error);
|
|
1924
|
+
}
|
|
1925
|
+
if (!pattern.test(message)) {
|
|
1926
|
+
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message does not match the caller-declared commit-message pattern", { reason: "message-pattern-mismatch", messagePattern, message });
|
|
1927
|
+
}
|
|
1712
1928
|
}
|
|
1713
1929
|
function explicitRemote(remote) {
|
|
1714
1930
|
if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) {
|
|
@@ -1740,25 +1956,6 @@ function explicitRemoteBranch(branch, alias) {
|
|
|
1740
1956
|
}, error);
|
|
1741
1957
|
}
|
|
1742
1958
|
}
|
|
1743
|
-
function observeMutationPaths(git, worktreePath) {
|
|
1744
|
-
const observed = captureGitCheckpoint(git, worktreePath);
|
|
1745
|
-
return Object.freeze({
|
|
1746
|
-
changed: Object.freeze(canonicalMutationPaths(observed.changed, worktreePath)),
|
|
1747
|
-
staged: Object.freeze(canonicalMutationPaths(observed.staged, worktreePath)),
|
|
1748
|
-
});
|
|
1749
|
-
}
|
|
1750
|
-
function canonicalMutationPaths(paths, worktreePath) {
|
|
1751
|
-
const canonical = new Set();
|
|
1752
|
-
for (const resource of paths) {
|
|
1753
|
-
try {
|
|
1754
|
-
canonical.add(canonicalizeClaimResource(resource, worktreePath));
|
|
1755
|
-
}
|
|
1756
|
-
catch (error) {
|
|
1757
|
-
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git reported a path that cannot be represented as a canonical repository resource", { path: resource }, error);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
return sortStrings(canonical);
|
|
1761
|
-
}
|
|
1762
1959
|
function assertNoUnexpectedMutationPaths(paths, authorized) {
|
|
1763
1960
|
const authorizedSet = new Set(authorized);
|
|
1764
1961
|
const unexpected = paths.filter((resource) => !authorizedSet.has(resource));
|
|
@@ -1812,7 +2009,8 @@ function runMutationGit(git, args, cwd, phase, failureCode) {
|
|
|
1812
2009
|
throw new SessionRegistryError(failureCode, `${phase} Git operation failed`, details, error);
|
|
1813
2010
|
}
|
|
1814
2011
|
}
|
|
1815
|
-
function inspectPushTarget(git, cwd, remote, branch) {
|
|
2012
|
+
function inspectPushTarget(git, cwd, remote, branch, sourceSha) {
|
|
2013
|
+
const targetRef = `refs/heads/${branch}`;
|
|
1816
2014
|
try {
|
|
1817
2015
|
git.run(["remote", "get-url", remote], cwd);
|
|
1818
2016
|
}
|
|
@@ -1821,7 +2019,7 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1821
2019
|
}
|
|
1822
2020
|
let remoteTargetOutput;
|
|
1823
2021
|
try {
|
|
1824
|
-
remoteTargetOutput = git.run(["ls-remote", "--heads", remote,
|
|
2022
|
+
remoteTargetOutput = git.run(["ls-remote", "--heads", remote, targetRef], cwd);
|
|
1825
2023
|
}
|
|
1826
2024
|
catch (error) {
|
|
1827
2025
|
throwRemoteInspectionFailure(error, "ls-remote");
|
|
@@ -1847,7 +2045,13 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1847
2045
|
if (upstream === undefined) {
|
|
1848
2046
|
// The absence of an upstream is itself a first-class relation. A remote
|
|
1849
2047
|
// branch may exist, but it must not silently become tracking state.
|
|
1850
|
-
return {
|
|
2048
|
+
return {
|
|
2049
|
+
sourceSha,
|
|
2050
|
+
relation: "no-upstream",
|
|
2051
|
+
upstream: undefined,
|
|
2052
|
+
targetRef,
|
|
2053
|
+
observedRemoteSha: remoteTargetExists ? extractRemoteSha(remoteTargetOutput, remote, branch) : null,
|
|
2054
|
+
};
|
|
1851
2055
|
}
|
|
1852
2056
|
const separator = upstream.indexOf("/");
|
|
1853
2057
|
if (separator <= 0 || separator === upstream.length - 1) {
|
|
@@ -1870,19 +2074,57 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1870
2074
|
});
|
|
1871
2075
|
}
|
|
1872
2076
|
const remoteSha = remoteShaMatch[1];
|
|
1873
|
-
|
|
2077
|
+
const temporaryRef = `refs/nawabari/push-inspection/${remoteSha}`;
|
|
2078
|
+
let relationOutput;
|
|
2079
|
+
let inspectionError;
|
|
1874
2080
|
try {
|
|
1875
|
-
|
|
2081
|
+
try {
|
|
2082
|
+
// Fetch only the explicitly inspected remote branch into a disposable
|
|
2083
|
+
// ref. This obtains missing ancestry without mutating tracking refs or
|
|
2084
|
+
// fetching unrelated branches/tags.
|
|
2085
|
+
// Requires Git 2.29 or later for --no-write-fetch-head support.
|
|
2086
|
+
// Unsupported Git versions will fail here and surface as
|
|
2087
|
+
// PUSH_REMOTE_INSPECTION_FAILED via throwRemoteInspectionFailure.
|
|
2088
|
+
git.run(["fetch", "--no-tags", "--no-write-fetch-head", "--refmap=", remote, `+${targetRef}:${temporaryRef}`], cwd);
|
|
2089
|
+
const fetchedSha = git.run(["rev-parse", "--verify", temporaryRef], cwd).trim();
|
|
2090
|
+
if (fetchedSha !== remoteSha) {
|
|
2091
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "The remote branch changed while its observed generation was being fetched", {
|
|
2092
|
+
remote,
|
|
2093
|
+
branch,
|
|
2094
|
+
observedRemoteSha: remoteSha,
|
|
2095
|
+
fetchedRemoteSha: fetchedSha,
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
catch (error) {
|
|
2100
|
+
if (error instanceof SessionRegistryError && error.code === "PUSH_REMOTE_INSPECTION_FAILED")
|
|
2101
|
+
throw error;
|
|
2102
|
+
throwRemoteInspectionFailure(error, "remote-generation");
|
|
2103
|
+
}
|
|
2104
|
+
try {
|
|
2105
|
+
relationOutput = git.run(["rev-list", "--left-right", "--count", `${sourceSha}...${temporaryRef}`], cwd);
|
|
2106
|
+
}
|
|
2107
|
+
catch (error) {
|
|
2108
|
+
throwRemoteInspectionFailure(error, "relation");
|
|
2109
|
+
}
|
|
1876
2110
|
}
|
|
1877
2111
|
catch (error) {
|
|
1878
|
-
|
|
2112
|
+
inspectionError = error;
|
|
1879
2113
|
}
|
|
1880
|
-
let relationOutput;
|
|
1881
2114
|
try {
|
|
1882
|
-
|
|
2115
|
+
git.run(["update-ref", "-d", temporaryRef], cwd);
|
|
1883
2116
|
}
|
|
1884
2117
|
catch (error) {
|
|
1885
|
-
|
|
2118
|
+
if (inspectionError === undefined)
|
|
2119
|
+
throwRemoteInspectionFailure(error, "remote-generation-cleanup");
|
|
2120
|
+
}
|
|
2121
|
+
if (inspectionError !== undefined)
|
|
2122
|
+
throw inspectionError;
|
|
2123
|
+
if (relationOutput === undefined) {
|
|
2124
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Remote ancestry relation was unavailable", {
|
|
2125
|
+
remote,
|
|
2126
|
+
branch,
|
|
2127
|
+
});
|
|
1886
2128
|
}
|
|
1887
2129
|
const counts = relationOutput
|
|
1888
2130
|
.trim()
|
|
@@ -1896,7 +2138,14 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1896
2138
|
}
|
|
1897
2139
|
const [ahead, behind] = counts;
|
|
1898
2140
|
const relation = ahead > 0 && behind > 0 ? "diverged" : behind > 0 ? "behind" : ahead > 0 ? "ahead" : "up-to-date";
|
|
1899
|
-
return { relation, upstream };
|
|
2141
|
+
return { sourceSha, relation, upstream, targetRef, observedRemoteSha: remoteSha };
|
|
2142
|
+
}
|
|
2143
|
+
function extractRemoteSha(remoteTargetOutput, remote, branch) {
|
|
2144
|
+
const remoteShaMatch = remoteTargetOutput.trim().match(/^([0-9a-f]{40,64})\s+/u);
|
|
2145
|
+
if (remoteShaMatch === null) {
|
|
2146
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Could not extract remote SHA from ls-remote output", { remote, branch });
|
|
2147
|
+
}
|
|
2148
|
+
return remoteShaMatch[1];
|
|
1900
2149
|
}
|
|
1901
2150
|
function isExpectedMissingRef(error) {
|
|
1902
2151
|
return (error instanceof SessionRegistryError &&
|
|
@@ -1913,35 +2162,18 @@ function throwRemoteInspectionFailure(error, phase) {
|
|
|
1913
2162
|
...(error instanceof SessionRegistryError ? { gitCode: error.code } : {}),
|
|
1914
2163
|
}, error);
|
|
1915
2164
|
}
|
|
1916
|
-
function canonicalObservedPaths(paths, worktreePath) {
|
|
1917
|
-
const canonical = new Set();
|
|
1918
|
-
for (const resource of paths) {
|
|
1919
|
-
if (typeof resource !== "string" || resource.length === 0) {
|
|
1920
|
-
continue;
|
|
1921
|
-
}
|
|
1922
|
-
try {
|
|
1923
|
-
// Git-observed paths are concrete repository-relative paths reported by Git.
|
|
1924
|
-
// They may contain literal * or ? characters in filenames (not wildcards).
|
|
1925
|
-
// Canonicalize and bounds-check them without rejecting metacharacters.
|
|
1926
|
-
// If canonicalization encounters traversal, symlink escape, or invalid
|
|
1927
|
-
// syntax, skip the path so it is reported as outOfClaim rather than throwing.
|
|
1928
|
-
canonical.add(canonicalizeClaimResource(resource, worktreePath));
|
|
1929
|
-
}
|
|
1930
|
-
catch (error) {
|
|
1931
|
-
// Let observed paths that fail canonicalization appear in the outOfClaim
|
|
1932
|
-
// set rather than causing checkpoint to throw. This ensures Git-reported
|
|
1933
|
-
// paths with unusual names or structure are observable in evidence.
|
|
1934
|
-
if (error instanceof SessionRegistryError) {
|
|
1935
|
-
continue;
|
|
1936
|
-
}
|
|
1937
|
-
throw error;
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
return Object.freeze(sortStrings(canonical));
|
|
1941
|
-
}
|
|
1942
2165
|
function sortStrings(values) {
|
|
1943
2166
|
return [...new Set(values)].sort(compareCodePointStrings);
|
|
1944
2167
|
}
|
|
2168
|
+
function sameCheckpointPaths(left, right) {
|
|
2169
|
+
return (sameStringArray(left.changed, right.changed) &&
|
|
2170
|
+
sameStringArray(left.staged, right.staged) &&
|
|
2171
|
+
sameStringArray(left.unstaged, right.unstaged) &&
|
|
2172
|
+
sameStringArray(left.untracked, right.untracked));
|
|
2173
|
+
}
|
|
2174
|
+
function sameStringArray(left, right) {
|
|
2175
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2176
|
+
}
|
|
1945
2177
|
function assertWorktreeClean(git, worktreePath) {
|
|
1946
2178
|
// Git is authoritative for this decision. --ignored=no means ignored or
|
|
1947
2179
|
// generated artifacts alone are intentionally absent from the blocker set.
|
|
@@ -2263,21 +2495,35 @@ function resolveBaseRef(git, cwd, candidate) {
|
|
|
2263
2495
|
candidate !== candidate.trim() ||
|
|
2264
2496
|
candidate.startsWith("-") ||
|
|
2265
2497
|
candidate.includes("\u0000")) {
|
|
2266
|
-
throw
|
|
2498
|
+
throw invalidBaseRef(candidate, "invalid-format");
|
|
2267
2499
|
}
|
|
2268
2500
|
try {
|
|
2269
|
-
git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
|
|
2270
|
-
|
|
2501
|
+
const revision = git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
|
|
2502
|
+
if (!isRevision(revision)) {
|
|
2503
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid base revision", {
|
|
2504
|
+
baseRef: candidate,
|
|
2505
|
+
revision,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
return { ref: candidate, revision };
|
|
2271
2509
|
}
|
|
2272
2510
|
catch (error) {
|
|
2273
2511
|
if (isExpectedGitLookupFailure(error)) {
|
|
2274
|
-
throw
|
|
2275
|
-
baseRef: candidate,
|
|
2276
|
-
});
|
|
2512
|
+
throw invalidBaseRef(candidate, "does-not-resolve-to-commit");
|
|
2277
2513
|
}
|
|
2278
2514
|
throw error;
|
|
2279
2515
|
}
|
|
2280
2516
|
}
|
|
2517
|
+
function invalidBaseRef(candidate, reason) {
|
|
2518
|
+
return new SessionRegistryError("INVALID_BASE_REF", reason === "invalid-format"
|
|
2519
|
+
? `Invalid base ref: ${candidate}`
|
|
2520
|
+
: `Base ref does not resolve to a commit: ${candidate}`, {
|
|
2521
|
+
baseRef: candidate,
|
|
2522
|
+
reason,
|
|
2523
|
+
defaultBaseRef: "HEAD",
|
|
2524
|
+
recoveryHints: ["Omit --base to use HEAD, then retry session create."],
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2281
2527
|
function isExpectedGitLookupFailure(error) {
|
|
2282
2528
|
return (error instanceof SessionRegistryError &&
|
|
2283
2529
|
error.code === "GIT_COMMAND_FAILED" &&
|
|
@@ -2320,6 +2566,7 @@ export function toPersistedSessionRecord(record, expectedRepositoryId = record.r
|
|
|
2320
2566
|
state: validated.state,
|
|
2321
2567
|
created_at: validated.createdAt,
|
|
2322
2568
|
updated_at: validated.updatedAt,
|
|
2569
|
+
...(validated.baseRevision === undefined ? {} : { base_revision: validated.baseRevision }),
|
|
2323
2570
|
...(validated.label === undefined ? {} : { label: validated.label }),
|
|
2324
2571
|
};
|
|
2325
2572
|
}
|
|
@@ -2337,7 +2584,7 @@ export function toPersistedResourceClaim(claim, expectedRepositoryId = claim.rep
|
|
|
2337
2584
|
updated_at: validated.updatedAt,
|
|
2338
2585
|
};
|
|
2339
2586
|
}
|
|
2340
|
-
function parseRegistry(value, expectedRepositoryId) {
|
|
2587
|
+
function parseRegistry(value, expectedRepositoryId, allowLegacyClaimSchema = false) {
|
|
2341
2588
|
if (!isRecord(value)) {
|
|
2342
2589
|
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry root must be an object");
|
|
2343
2590
|
}
|
|
@@ -2368,17 +2615,27 @@ function parseRegistry(value, expectedRepositoryId) {
|
|
|
2368
2615
|
// materialized on the next locked mutation or via migrate().
|
|
2369
2616
|
return { sessions: records, claims: [], legacyClaimsAbsent: true };
|
|
2370
2617
|
}
|
|
2371
|
-
|
|
2372
|
-
|
|
2618
|
+
const claimSchemaVersion = value.claims_schema_version;
|
|
2619
|
+
const isLegacyClaimSchema = claimSchemaVersion === LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION;
|
|
2620
|
+
if (claimSchemaVersion !== RESOURCE_CLAIM_SCHEMA_VERSION && !isLegacyClaimSchema) {
|
|
2621
|
+
throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version: ${stringifyDetail(claimSchemaVersion)}`, { schemaVersion: claimSchemaVersion });
|
|
2622
|
+
}
|
|
2623
|
+
if (isLegacyClaimSchema && !allowLegacyClaimSchema) {
|
|
2624
|
+
throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", "Resource claim schema v1 requires explicit migration before use", { schemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION, migrationRequired: true });
|
|
2373
2625
|
}
|
|
2374
2626
|
if (!Array.isArray(value.claims)) {
|
|
2375
2627
|
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry claims must be an array");
|
|
2376
2628
|
}
|
|
2377
|
-
const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId));
|
|
2629
|
+
const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId, isLegacyClaimSchema ? LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION : undefined));
|
|
2378
2630
|
validateRegistryClaims(records, claims, expectedRepositoryId);
|
|
2379
|
-
return {
|
|
2631
|
+
return {
|
|
2632
|
+
sessions: records,
|
|
2633
|
+
claims: sortResourceClaims(claims),
|
|
2634
|
+
legacyClaimsAbsent: false,
|
|
2635
|
+
...(isLegacyClaimSchema ? { legacyClaimsSchemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION } : {}),
|
|
2636
|
+
};
|
|
2380
2637
|
}
|
|
2381
|
-
function parseResourceClaim(value, index, expectedRepositoryId) {
|
|
2638
|
+
function parseResourceClaim(value, index, expectedRepositoryId, expectedSchemaVersion = RESOURCE_CLAIM_SCHEMA_VERSION) {
|
|
2382
2639
|
if (!isRecord(value))
|
|
2383
2640
|
throw invalidRecord(index, "claim must be an object");
|
|
2384
2641
|
assertExactKeys(value, [
|
|
@@ -2392,8 +2649,8 @@ function parseResourceClaim(value, index, expectedRepositoryId) {
|
|
|
2392
2649
|
"created_at",
|
|
2393
2650
|
"updated_at",
|
|
2394
2651
|
], [], index);
|
|
2395
|
-
if (value.schema_version !==
|
|
2396
|
-
throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version at index ${index}: ${stringifyDetail(value.schema_version)}`, { schemaVersion: value.schema_version, index });
|
|
2652
|
+
if (value.schema_version !== expectedSchemaVersion) {
|
|
2653
|
+
throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version at index ${index}: ${stringifyDetail(value.schema_version)}`, { schemaVersion: value.schema_version, index, expectedSchemaVersion });
|
|
2397
2654
|
}
|
|
2398
2655
|
const claim = {
|
|
2399
2656
|
schemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
@@ -2505,7 +2762,7 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
|
|
|
2505
2762
|
"state",
|
|
2506
2763
|
"created_at",
|
|
2507
2764
|
"updated_at",
|
|
2508
|
-
], ["label"], index);
|
|
2765
|
+
], ["base_revision", "label"], index);
|
|
2509
2766
|
if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
|
|
2510
2767
|
if (typeof value.schema_version === "number") {
|
|
2511
2768
|
throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version: ${value.schema_version}`, {
|
|
@@ -2526,6 +2783,9 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
|
|
|
2526
2783
|
state: requireState(value.state, index),
|
|
2527
2784
|
createdAt: requireString(value.created_at, index, "created_at"),
|
|
2528
2785
|
updatedAt: requireString(value.updated_at, index, "updated_at"),
|
|
2786
|
+
...(value.base_revision === undefined
|
|
2787
|
+
? {}
|
|
2788
|
+
: { baseRevision: requireRevision(value.base_revision, index, "base_revision") }),
|
|
2529
2789
|
...(value.label === undefined ? {} : { label: requireString(value.label, index, "label") }),
|
|
2530
2790
|
};
|
|
2531
2791
|
return validateSessionRecord(record, expectedRepositoryId, index);
|
|
@@ -2571,6 +2831,9 @@ function validateSessionRecord(record, expectedRepositoryId, index) {
|
|
|
2571
2831
|
if (Date.parse(record.updatedAt) < Date.parse(record.createdAt)) {
|
|
2572
2832
|
throw invalidRecord(index, "updated_at cannot precede created_at");
|
|
2573
2833
|
}
|
|
2834
|
+
if (record.baseRevision !== undefined && !isRevision(record.baseRevision)) {
|
|
2835
|
+
throw invalidRecord(index, "base_revision must be a full hexadecimal Git revision");
|
|
2836
|
+
}
|
|
2574
2837
|
if (record.label !== undefined && (typeof record.label !== "string" || record.label.length === 0)) {
|
|
2575
2838
|
throw invalidRecord(index, "label must be a non-empty string");
|
|
2576
2839
|
}
|
|
@@ -2660,6 +2923,16 @@ function requireString(value, index, field) {
|
|
|
2660
2923
|
}
|
|
2661
2924
|
return value;
|
|
2662
2925
|
}
|
|
2926
|
+
function requireRevision(value, index, field) {
|
|
2927
|
+
const revision = requireString(value, index, field);
|
|
2928
|
+
if (!isRevision(revision)) {
|
|
2929
|
+
throw invalidRecord(index, `${field} must be a full hexadecimal Git revision`);
|
|
2930
|
+
}
|
|
2931
|
+
return revision;
|
|
2932
|
+
}
|
|
2933
|
+
function isRevision(value) {
|
|
2934
|
+
return /^[0-9a-f]{40,64}$/u.test(value);
|
|
2935
|
+
}
|
|
2663
2936
|
function requireState(value, index) {
|
|
2664
2937
|
if (typeof value !== "string" || !SESSION_STATES.has(value)) {
|
|
2665
2938
|
throw invalidRecord(index, `state is invalid: ${stringifyDetail(value)}`);
|