nawabari 0.2.0 → 0.4.1
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 +230 -15
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +704 -115
- package/dist/cli.js.map +1 -1
- package/dist/contract.js +135 -2
- package/dist/contract.js.map +1 -1
- package/dist/domain/errors.d.ts +1 -1
- package/dist/domain/errors.js +4 -1
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/sandbox-launcher.d.ts +35 -0
- package/dist/domain/sandbox-launcher.js +679 -0
- package/dist/domain/sandbox-launcher.js.map +1 -0
- package/dist/domain/sandbox.d.ts +190 -0
- package/dist/domain/sandbox.js +363 -0
- package/dist/domain/sandbox.js.map +1 -0
- package/dist/domain/session-backend.d.ts +5 -3
- package/dist/domain/session-backend.js +102 -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/registry/atomic.d.ts +24 -0
- package/dist/registry/atomic.js +35 -2
- package/dist/registry/atomic.js.map +1 -1
- package/dist/registry/errors.d.ts +1 -1
- package/dist/registry/errors.js +1 -0
- package/dist/registry/errors.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 +68 -7
- package/dist/session-registry.js +422 -149
- package/dist/session-registry.js.map +1 -1
- package/package.json +5 -3
package/dist/session-registry.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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
|
+
import { isPostRenameFailure, writeJsonAtomicallySync } from "./registry/atomic.js";
|
|
6
7
|
import { RegistryLockError, RepositoryLock } from "./registry/lock.js";
|
|
7
|
-
import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput,
|
|
8
|
+
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
9
|
import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
|
|
10
|
+
import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
|
|
11
|
+
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
12
|
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
10
13
|
export { RESOURCE_CLAIM_SCHEMA_VERSION };
|
|
11
14
|
export const REGISTRY_DIRECTORY_NAME = "nawabari";
|
|
@@ -15,6 +18,8 @@ export const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1_000;
|
|
|
15
18
|
export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
|
|
16
19
|
export const RECONCILIATION_SCHEMA_VERSION = 1;
|
|
17
20
|
const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
|
|
21
|
+
/** Bounds a caller-declared commit-message pattern before it is compiled. */
|
|
22
|
+
export const MAX_MESSAGE_PATTERN_LENGTH = 512;
|
|
18
23
|
export { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, OPERATION_REQUIRED_ACCESS, OPERATION_VOCABULARY, } from "./operation-authorization.js";
|
|
19
24
|
export const GOVERNED_GIT_OPERATION_SCHEMA_VERSION = 1;
|
|
20
25
|
const ACTIVE_STATES = new Set(["new", "active", "closing", "stale"]);
|
|
@@ -75,6 +80,10 @@ export class SessionRegistry {
|
|
|
75
80
|
metadataGraceMs: this.lockMetadataGraceMs,
|
|
76
81
|
});
|
|
77
82
|
}
|
|
83
|
+
/** Resolved repository-local root used for default managed session worktrees. */
|
|
84
|
+
get managedWorktreeRoot() {
|
|
85
|
+
return this.worktreeRoot;
|
|
86
|
+
}
|
|
78
87
|
read() {
|
|
79
88
|
return this.readUnsafe().map(cloneSessionRecord);
|
|
80
89
|
}
|
|
@@ -105,16 +114,18 @@ export class SessionRegistry {
|
|
|
105
114
|
return claim === undefined ? undefined : cloneResourceClaim(claim);
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
108
|
-
* Explicitly materialize
|
|
109
|
-
*
|
|
117
|
+
* Explicitly materialize a claim section or upgrade its semantics. A v1
|
|
118
|
+
* claim registry is intentionally unreadable through ordinary operations;
|
|
119
|
+
* only this locked, explicit migration rewrites it as v2.
|
|
110
120
|
*/
|
|
111
121
|
migrate() {
|
|
112
122
|
return this.withLock(() => {
|
|
113
|
-
const state = this.readStateUnsafe();
|
|
114
|
-
|
|
123
|
+
const state = this.readStateUnsafe(true);
|
|
124
|
+
const migrated = state.legacyClaimsAbsent || state.legacyClaimsSchemaVersion !== undefined;
|
|
125
|
+
if (migrated)
|
|
115
126
|
this.writeUnsafe(state.sessions, state.claims);
|
|
116
127
|
return {
|
|
117
|
-
migrated
|
|
128
|
+
migrated,
|
|
118
129
|
registrySchemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
119
130
|
claimSchemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
120
131
|
};
|
|
@@ -248,6 +259,7 @@ export class SessionRegistry {
|
|
|
248
259
|
state: "active",
|
|
249
260
|
createdAt: timestamp,
|
|
250
261
|
updatedAt: timestamp,
|
|
262
|
+
baseRevision: resources.baseRevision,
|
|
251
263
|
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
252
264
|
});
|
|
253
265
|
assertNoOwnershipConflict(records, record);
|
|
@@ -282,13 +294,14 @@ export class SessionRegistry {
|
|
|
282
294
|
state: "active",
|
|
283
295
|
createdAt: timestamp,
|
|
284
296
|
updatedAt: timestamp,
|
|
297
|
+
baseRevision: resources.baseRevision,
|
|
285
298
|
...(options.label === undefined ? {} : { label: validateLabel(options.label) }),
|
|
286
299
|
});
|
|
287
300
|
assertNoOwnershipConflict(state.sessions, record);
|
|
288
301
|
assertGitResourcesAvailable(this.git, this.repository.worktreePath, resources);
|
|
289
302
|
let gitProvisioned = false;
|
|
290
303
|
try {
|
|
291
|
-
this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.
|
|
304
|
+
this.git.run(["worktree", "add", "--quiet", "-b", resources.branchName, resources.worktreePath, resources.baseRevision], this.repository.worktreePath);
|
|
292
305
|
gitProvisioned = true;
|
|
293
306
|
verifyPhysicalExecutionContext({
|
|
294
307
|
repository: this.repository,
|
|
@@ -300,13 +313,40 @@ export class SessionRegistry {
|
|
|
300
313
|
return cloneSessionRecord(record);
|
|
301
314
|
}
|
|
302
315
|
catch (error) {
|
|
303
|
-
if (gitProvisioned) {
|
|
316
|
+
if (gitProvisioned && this.absenceProvenAfterProvisioningFailure(error, sessionId)) {
|
|
304
317
|
rollbackProvisionedResources(this.git, this.repository.worktreePath, resources);
|
|
305
318
|
}
|
|
306
319
|
throw classifyProvisioningFailure(error, this.git, this.repository.worktreePath, resources);
|
|
307
320
|
}
|
|
308
321
|
});
|
|
309
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Decide whether it is safe to destroy the just-created worktree/branch
|
|
325
|
+
* after a provisioning failure. Only an ordinary (non-durability-uncertain)
|
|
326
|
+
* failure, or a durability-uncertain failure whose registry re-read
|
|
327
|
+
* positively proves the session record absent, licenses a rollback.
|
|
328
|
+
*
|
|
329
|
+
* A durability-uncertain write means the rename may have already
|
|
330
|
+
* committed this session as the registry's visible ownership record:
|
|
331
|
+
* rolling back the worktree/branch would then strand a registry entry
|
|
332
|
+
* pointing at resources we just deleted. Absence must be *proven*, not
|
|
333
|
+
* merely unconfirmed — if the reconciliation read itself cannot complete
|
|
334
|
+
* (I/O failure, corrupt registry state, or any other exception), that is
|
|
335
|
+
* not proof of absence, so no rollback happens and the original
|
|
336
|
+
* durability-uncertain outcome propagates unchanged.
|
|
337
|
+
*/
|
|
338
|
+
absenceProvenAfterProvisioningFailure(error, sessionId) {
|
|
339
|
+
const durabilityUncertain = error instanceof SessionRegistryError && error.code === "REGISTRY_DURABILITY_UNCERTAIN";
|
|
340
|
+
if (!durabilityUncertain) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
return !this.readUnsafe().some((candidate) => candidate.sessionId === sessionId);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
310
350
|
provisionSession(options = {}) {
|
|
311
351
|
return this.provision(options);
|
|
312
352
|
}
|
|
@@ -359,6 +399,36 @@ export class SessionRegistry {
|
|
|
359
399
|
const session = this.resolveSessionOwnership(physical, options.sessionId ?? null);
|
|
360
400
|
return Object.freeze({ ...physical, session: cloneSessionRecord(session) });
|
|
361
401
|
}
|
|
402
|
+
/** Resolve and physically verify an explicitly addressed active session. */
|
|
403
|
+
verifyEvidenceSession(sessionId) {
|
|
404
|
+
assertSessionId(sessionId);
|
|
405
|
+
const record = this.readUnsafe().find((candidate) => candidate.sessionId === sessionId);
|
|
406
|
+
if (record === undefined || record.state === "closed") {
|
|
407
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
408
|
+
}
|
|
409
|
+
if (record.state !== "active") {
|
|
410
|
+
throw new SessionRegistryError("STALE_REGISTRY", `Session is not active: ${sessionId}`, {
|
|
411
|
+
sessionId,
|
|
412
|
+
state: record.state,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const physical = verifyPhysicalExecutionContext({
|
|
416
|
+
repository: this.repository,
|
|
417
|
+
worktreePath: record.worktreePath,
|
|
418
|
+
branchName: record.branchName,
|
|
419
|
+
git: this.git,
|
|
420
|
+
});
|
|
421
|
+
if (physical.worktreeId !== record.worktreeId || physical.branchId !== record.branchId) {
|
|
422
|
+
throw new SessionRegistryError("OWNERSHIP_MISMATCH", "The selected session does not own the observed worktree", {
|
|
423
|
+
sessionId,
|
|
424
|
+
expectedWorktree: record.worktreePath,
|
|
425
|
+
actualWorktree: physical.worktreePath,
|
|
426
|
+
expectedBranch: record.branchName,
|
|
427
|
+
actualBranch: physical.branchName,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return Object.freeze({ session: cloneSessionRecord(record), physical });
|
|
431
|
+
}
|
|
362
432
|
/** Shared physical/protected/ownership path for every governed decision. */
|
|
363
433
|
verifyGovernedExecutionContext(requestedSessionId, observedPhysical) {
|
|
364
434
|
const physical = observedPhysical ??
|
|
@@ -594,6 +664,14 @@ export class SessionRegistry {
|
|
|
594
664
|
ownerMode: conflictingClaim.mode,
|
|
595
665
|
});
|
|
596
666
|
}
|
|
667
|
+
if (ownClaims.length > 0) {
|
|
668
|
+
return deniedOperation("INSUFFICIENT_CLAIM_MODE", verified, {
|
|
669
|
+
resource,
|
|
670
|
+
requiredAccess,
|
|
671
|
+
grantedModes: sortStrings(new Set(ownClaims.map((claim) => claim.mode))),
|
|
672
|
+
sessionId: currentRecord.sessionId,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
597
675
|
return deniedOperation("MISSING_RESOURCE_CLAIM", verified, {
|
|
598
676
|
resource,
|
|
599
677
|
requiredAccess,
|
|
@@ -634,13 +712,17 @@ export class SessionRegistry {
|
|
|
634
712
|
* ownership or mutation authority.
|
|
635
713
|
*/
|
|
636
714
|
commit(options) {
|
|
715
|
+
// Validated before the repository lock is acquired: a pathological
|
|
716
|
+
// caller-declared pattern must not hold the cross-session repository
|
|
717
|
+
// lock hostage while it backtracks.
|
|
718
|
+
const messagePattern = options.messagePattern ?? options.message_pattern ?? null;
|
|
719
|
+
assertFinalCommitMessage(options.message, messagePattern);
|
|
637
720
|
return this.withLock(() => {
|
|
638
721
|
const sessionId = operationSessionId(options.sessionId, options.session_id);
|
|
639
722
|
const requestedResources = operationResources(options.resources ?? options.paths);
|
|
640
723
|
const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
|
|
641
724
|
const initial = this.verifyGovernedExecutionContext(sessionId);
|
|
642
|
-
|
|
643
|
-
const before = observeMutationPaths(this.git, initial.worktreePath);
|
|
725
|
+
const before = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
644
726
|
assertNoUnexpectedMutationPaths(before.changed, authorization.resources.map((resource) => resource.resource));
|
|
645
727
|
const stageResources = authorization.resources
|
|
646
728
|
.map((resource) => resource.resource)
|
|
@@ -652,7 +734,7 @@ export class SessionRegistry {
|
|
|
652
734
|
}
|
|
653
735
|
reverifyMutationContext(this, initial, sessionId, "stage");
|
|
654
736
|
runMutationGit(this.git, ["add", "--", ...stageResources], initial.worktreePath, "stage", "COMMIT_STAGING_FAILED");
|
|
655
|
-
const afterStage =
|
|
737
|
+
const afterStage = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
656
738
|
assertNoUnexpectedMutationPaths(afterStage.changed, stageResources);
|
|
657
739
|
if (!afterStage.staged.some((resource) => stageResources.includes(resource))) {
|
|
658
740
|
throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Git staging produced no authorized staged paths", {
|
|
@@ -660,7 +742,7 @@ export class SessionRegistry {
|
|
|
660
742
|
});
|
|
661
743
|
}
|
|
662
744
|
reverifyMutationContext(this, initial, sessionId, "commit");
|
|
663
|
-
const beforeCommit =
|
|
745
|
+
const beforeCommit = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
664
746
|
assertNoUnexpectedMutationPaths(beforeCommit.changed, stageResources);
|
|
665
747
|
if (!beforeCommit.staged.some((resource) => stageResources.includes(resource))) {
|
|
666
748
|
throw new SessionRegistryError("COMMIT_STAGING_FAILED", "Authorized staged paths disappeared before commit", {
|
|
@@ -677,11 +759,34 @@ export class SessionRegistry {
|
|
|
677
759
|
throw error;
|
|
678
760
|
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
761
|
}
|
|
762
|
+
// The Git commit succeeded and its SHA is known; every failure from here
|
|
763
|
+
// must retain commitSha so the caller can recover/reconcile the result
|
|
764
|
+
// instead of losing track of a commit that actually happened.
|
|
765
|
+
let committedPaths;
|
|
766
|
+
try {
|
|
767
|
+
committedPaths = readCanonicalCommitChangedPaths(this.git, initial.worktreePath, commitSha);
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
if (error instanceof SessionRegistryError && isBoundedGitFailure(error.code)) {
|
|
771
|
+
throw new SessionRegistryError(error.code, error.message, { ...error.details, commitSha }, error);
|
|
772
|
+
}
|
|
773
|
+
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);
|
|
774
|
+
}
|
|
775
|
+
const authorizedSet = new Set(stageResources);
|
|
776
|
+
const divergentResources = committedPaths.filter((resource) => !authorizedSet.has(resource));
|
|
777
|
+
if (divergentResources.length > 0) {
|
|
778
|
+
throw new SessionRegistryError("COMMIT_RESULT_DIVERGED", "The resulting commit contains paths outside the authorized/staged resource set", {
|
|
779
|
+
commitSha,
|
|
780
|
+
authorizedResources: [...stageResources],
|
|
781
|
+
committedResources: [...committedPaths],
|
|
782
|
+
divergentResources,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
680
785
|
return Object.freeze({
|
|
681
786
|
schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
|
|
682
787
|
commitSha,
|
|
683
788
|
message: options.message,
|
|
684
|
-
resources:
|
|
789
|
+
resources: committedPaths,
|
|
685
790
|
});
|
|
686
791
|
});
|
|
687
792
|
}
|
|
@@ -705,7 +810,7 @@ export class SessionRegistry {
|
|
|
705
810
|
pushBranch: branch,
|
|
706
811
|
});
|
|
707
812
|
}
|
|
708
|
-
const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch);
|
|
813
|
+
const inspection = inspectPushTarget(this.git, initial.worktreePath, remote, branch, initial.headId);
|
|
709
814
|
if (inspection.upstream !== undefined && inspection.upstream !== `${remote}/${branch}`) {
|
|
710
815
|
throw new SessionRegistryError("PUSH_TARGET_MISMATCH", "The explicit push target does not match the currently configured upstream", { upstream: inspection.upstream, remote, branch });
|
|
711
816
|
}
|
|
@@ -719,32 +824,44 @@ export class SessionRegistry {
|
|
|
719
824
|
const code = inspection.relation === "behind" ? "PUSH_BEHIND" : "PUSH_DIVERGED";
|
|
720
825
|
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
826
|
}
|
|
722
|
-
const dirtyBefore =
|
|
827
|
+
const dirtyBefore = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
723
828
|
if (dirtyBefore.changed.length > 0) {
|
|
724
829
|
throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "Push requires a clean Git worktree", {
|
|
725
830
|
paths: [...dirtyBefore.changed],
|
|
726
831
|
});
|
|
727
832
|
}
|
|
728
|
-
reverifyMutationContext(this, initial, sessionId, "push");
|
|
729
|
-
const dirtyBeforePush =
|
|
833
|
+
const finalContext = reverifyMutationContext(this, initial, sessionId, "push");
|
|
834
|
+
const dirtyBeforePush = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
730
835
|
if (dirtyBeforePush.changed.length > 0) {
|
|
731
836
|
throw new SessionRegistryError("PUSH_DIRTY_WORKTREE", "The worktree changed before push mutation", {
|
|
732
837
|
paths: [...dirtyBeforePush.changed],
|
|
733
838
|
});
|
|
734
839
|
}
|
|
840
|
+
const targetRef = `refs/heads/${branch}`;
|
|
841
|
+
const leaseValue = inspection.observedRemoteSha ?? "";
|
|
735
842
|
const pushArguments = [
|
|
736
843
|
"push",
|
|
737
|
-
...(force ? [
|
|
844
|
+
...(force ? [`--force-with-lease=${targetRef}:${leaseValue}`] : ["--no-force"]),
|
|
738
845
|
...(createUpstream ? ["--set-upstream"] : []),
|
|
739
846
|
remote,
|
|
740
|
-
|
|
847
|
+
`${finalContext.headId}:${targetRef}`,
|
|
741
848
|
];
|
|
742
849
|
runMutationGit(this.git, pushArguments, initial.worktreePath, "push", "PUSH_FAILED");
|
|
850
|
+
if (createUpstream) {
|
|
851
|
+
// A SHA:ref refspec is intentional: it cannot be redirected by a
|
|
852
|
+
// mutable symbolic HEAD. Git does not consistently infer the current
|
|
853
|
+
// branch's tracking configuration from a raw SHA source, so complete
|
|
854
|
+
// the explicitly requested local upstream setup after the push.
|
|
855
|
+
runMutationGit(this.git, ["branch", "--set-upstream-to", `${remote}/${branch}`, branch], initial.worktreePath, "set-upstream", "PUSH_FAILED");
|
|
856
|
+
}
|
|
743
857
|
return Object.freeze({
|
|
744
858
|
schemaVersion: GOVERNED_GIT_OPERATION_SCHEMA_VERSION,
|
|
859
|
+
sourceSha: finalContext.headId,
|
|
745
860
|
remote,
|
|
746
861
|
branch,
|
|
747
862
|
target: `${remote}/${branch}`,
|
|
863
|
+
targetRef,
|
|
864
|
+
observedRemoteSha: inspection.observedRemoteSha,
|
|
748
865
|
relation: inspection.relation,
|
|
749
866
|
force,
|
|
750
867
|
upstreamCreated: createUpstream,
|
|
@@ -776,13 +893,7 @@ export class SessionRegistry {
|
|
|
776
893
|
const execution = this.verifyGovernedExecutionContext(requestedSessionId);
|
|
777
894
|
const physical = execution;
|
|
778
895
|
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
|
-
};
|
|
896
|
+
const paths = observeGitCheckpoint(this.git, physical.worktreePath);
|
|
786
897
|
const state = this.readStateUnsafe();
|
|
787
898
|
const activeClaims = state.claims.filter((claim) => claim.sessionId === session.sessionId &&
|
|
788
899
|
state.sessions.some((record) => record.sessionId === claim.sessionId && record.state === "active"));
|
|
@@ -810,6 +921,122 @@ export class SessionRegistry {
|
|
|
810
921
|
captureCheckpoint(options = {}) {
|
|
811
922
|
return this.checkpoint(options);
|
|
812
923
|
}
|
|
924
|
+
/**
|
|
925
|
+
* Capture one deterministic, read-only physical repository generation for
|
|
926
|
+
* an explicitly addressed owned session.
|
|
927
|
+
*/
|
|
928
|
+
repositoryEvidence(options) {
|
|
929
|
+
return this.withLock(() => {
|
|
930
|
+
const initial = this.verifyEvidenceSession(options.sessionId);
|
|
931
|
+
const paths = observeGitCheckpoint(this.git, initial.physical.worktreePath);
|
|
932
|
+
const stats = paths.changed.length === 0
|
|
933
|
+
? []
|
|
934
|
+
: readGitPathStats(this.git, initial.physical.worktreePath, paths.changed, initial.physical.headId);
|
|
935
|
+
const final = this.verifyEvidenceSession(options.sessionId);
|
|
936
|
+
if (final.physical.branchName !== initial.physical.branchName ||
|
|
937
|
+
final.physical.headId !== initial.physical.headId ||
|
|
938
|
+
!sameCheckpointPaths(paths, observeGitCheckpoint(this.git, final.physical.worktreePath))) {
|
|
939
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository evidence changed during physical observation", {
|
|
940
|
+
sessionId: options.sessionId,
|
|
941
|
+
worktree: initial.physical.worktreePath,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
const baseRevision = initial.session.baseRevision ?? null;
|
|
945
|
+
const incompleteReasons = stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : [];
|
|
946
|
+
const pathsEvidence = Object.freeze({
|
|
947
|
+
changed: paths.changed,
|
|
948
|
+
staged: paths.staged,
|
|
949
|
+
unstaged: paths.unstaged,
|
|
950
|
+
untracked: paths.untracked,
|
|
951
|
+
stats: Object.freeze([...stats]),
|
|
952
|
+
});
|
|
953
|
+
const snapshotWithoutHash = {
|
|
954
|
+
schemaVersion: 1,
|
|
955
|
+
source: "git",
|
|
956
|
+
guarantee: "git-observable-only",
|
|
957
|
+
repositoryId: initial.physical.repositoryId,
|
|
958
|
+
worktreePath: initial.physical.worktreePath,
|
|
959
|
+
branchId: initial.physical.branchId,
|
|
960
|
+
branchName: initial.physical.branchName,
|
|
961
|
+
sessionId: initial.session.sessionId,
|
|
962
|
+
sessionState: initial.session.state,
|
|
963
|
+
sessionCreatedAt: initial.session.createdAt,
|
|
964
|
+
sessionUpdatedAt: initial.session.updatedAt,
|
|
965
|
+
baseRevision,
|
|
966
|
+
baseRevisionProven: baseRevision !== null,
|
|
967
|
+
headId: initial.physical.headId,
|
|
968
|
+
clean: paths.changed.length === 0,
|
|
969
|
+
complete: incompleteReasons.length === 0,
|
|
970
|
+
incompleteReasons: Object.freeze(incompleteReasons),
|
|
971
|
+
paths: pathsEvidence,
|
|
972
|
+
bounds: Object.freeze({
|
|
973
|
+
maxPaths: CHECKPOINT_MAX_PATHS,
|
|
974
|
+
maxDiffPaths: EVIDENCE_MAX_DIFF_PATHS,
|
|
975
|
+
maxDiffBytes: EVIDENCE_MAX_DIFF_BYTES,
|
|
976
|
+
maxDiffHunks: EVIDENCE_MAX_DIFF_HUNKS,
|
|
977
|
+
}),
|
|
978
|
+
};
|
|
979
|
+
return Object.freeze({
|
|
980
|
+
...snapshotWithoutHash,
|
|
981
|
+
evidenceHash: evidenceHash(snapshotWithoutHash),
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
evidenceSnapshot(options) {
|
|
986
|
+
return this.repositoryEvidence(options);
|
|
987
|
+
}
|
|
988
|
+
/** Inspect only explicitly selected paths with bounded stat/patch output. */
|
|
989
|
+
repositoryDiff(options) {
|
|
990
|
+
return this.withLock(() => {
|
|
991
|
+
const initial = this.verifyEvidenceSession(options.sessionId);
|
|
992
|
+
const diff = readBoundedGitDiff(this.git, initial.physical.worktreePath, {
|
|
993
|
+
paths: options.paths,
|
|
994
|
+
from: options.from,
|
|
995
|
+
to: options.to,
|
|
996
|
+
includePatch: options.includePatch,
|
|
997
|
+
maxBytes: options.maxBytes,
|
|
998
|
+
maxHunks: options.maxHunks,
|
|
999
|
+
});
|
|
1000
|
+
const final = this.verifyEvidenceSession(options.sessionId);
|
|
1001
|
+
if (final.physical.branchName !== initial.physical.branchName ||
|
|
1002
|
+
final.physical.headId !== initial.physical.headId) {
|
|
1003
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Repository diff changed during physical observation", {
|
|
1004
|
+
sessionId: options.sessionId,
|
|
1005
|
+
worktree: initial.physical.worktreePath,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
const diffWithoutHash = {
|
|
1009
|
+
schemaVersion: diff.schemaVersion,
|
|
1010
|
+
source: "git",
|
|
1011
|
+
guarantee: "git-observable-only",
|
|
1012
|
+
repositoryId: initial.physical.repositoryId,
|
|
1013
|
+
worktreePath: initial.physical.worktreePath,
|
|
1014
|
+
branchId: initial.physical.branchId,
|
|
1015
|
+
branchName: initial.physical.branchName,
|
|
1016
|
+
sessionId: initial.session.sessionId,
|
|
1017
|
+
sessionState: initial.session.state,
|
|
1018
|
+
headId: initial.physical.headId,
|
|
1019
|
+
fromRevision: diff.fromRevision,
|
|
1020
|
+
toRevision: diff.toRevision,
|
|
1021
|
+
paths: diff.paths,
|
|
1022
|
+
stats: diff.stats,
|
|
1023
|
+
complete: diff.stats.every((stat) => stat.available),
|
|
1024
|
+
incompleteReasons: Object.freeze(diff.stats.some((stat) => !stat.available) ? ["STAT_UNAVAILABLE"] : []),
|
|
1025
|
+
patch: diff.patch,
|
|
1026
|
+
patchBytes: diff.patchBytes,
|
|
1027
|
+
hunkCount: diff.hunkCount,
|
|
1028
|
+
maxBytes: options.maxBytes ?? EVIDENCE_MAX_DIFF_BYTES,
|
|
1029
|
+
maxHunks: options.maxHunks ?? EVIDENCE_MAX_DIFF_HUNKS,
|
|
1030
|
+
};
|
|
1031
|
+
return Object.freeze({
|
|
1032
|
+
...diffWithoutHash,
|
|
1033
|
+
evidenceHash: evidenceHash(diffWithoutHash),
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
diff(options) {
|
|
1038
|
+
return this.repositoryDiff(options);
|
|
1039
|
+
}
|
|
813
1040
|
/**
|
|
814
1041
|
* Produce the authoritative, non-mutating cleanup decision for one session.
|
|
815
1042
|
* The decision observes registry claims and current Git/filesystem state in
|
|
@@ -1393,12 +1620,13 @@ export class SessionRegistry {
|
|
|
1393
1620
|
branch: shortBranchName,
|
|
1394
1621
|
});
|
|
1395
1622
|
}
|
|
1396
|
-
const
|
|
1623
|
+
const base = resolveBaseRef(git, this.repository.worktreePath, options.baseRef ?? "HEAD");
|
|
1397
1624
|
return {
|
|
1398
1625
|
worktreePath: requestedWorktreePath,
|
|
1399
1626
|
branchId,
|
|
1400
1627
|
branchName: shortBranchName,
|
|
1401
|
-
baseRef,
|
|
1628
|
+
baseRef: base.ref,
|
|
1629
|
+
baseRevision: base.revision,
|
|
1402
1630
|
};
|
|
1403
1631
|
}
|
|
1404
1632
|
resolveCreationResources(options) {
|
|
@@ -1413,9 +1641,10 @@ export class SessionRegistry {
|
|
|
1413
1641
|
worktreePath: physical.worktreePath,
|
|
1414
1642
|
branchId: physical.branchId,
|
|
1415
1643
|
branchName: physical.branchName,
|
|
1644
|
+
baseRevision: physical.headId,
|
|
1416
1645
|
};
|
|
1417
1646
|
}
|
|
1418
|
-
readStateUnsafe() {
|
|
1647
|
+
readStateUnsafe(allowLegacyClaimSchema = false) {
|
|
1419
1648
|
let contents;
|
|
1420
1649
|
try {
|
|
1421
1650
|
contents = fs.readFileSync(this.paths.registry, "utf8");
|
|
@@ -1437,11 +1666,20 @@ export class SessionRegistry {
|
|
|
1437
1666
|
path: this.paths.registry,
|
|
1438
1667
|
}, error);
|
|
1439
1668
|
}
|
|
1440
|
-
return parseRegistry(parsed, this.repository.repositoryId);
|
|
1669
|
+
return parseRegistry(parsed, this.repository.repositoryId, allowLegacyClaimSchema);
|
|
1441
1670
|
}
|
|
1442
1671
|
readUnsafe() {
|
|
1443
1672
|
return this.readStateUnsafe().sessions;
|
|
1444
1673
|
}
|
|
1674
|
+
/**
|
|
1675
|
+
* The single authoritative persistence path for session/claim registry
|
|
1676
|
+
* state, sharing its durability policy with `registry/atomic.ts`: the
|
|
1677
|
+
* temporary file and target directory are fsynced around an atomic
|
|
1678
|
+
* rename, and only known unsupported-directory-fsync conditions are
|
|
1679
|
+
* tolerated. A failure observed after rename is reported as a distinct
|
|
1680
|
+
* durability-uncertain outcome rather than an ordinary write failure,
|
|
1681
|
+
* since the renamed document may already be the one readers observe.
|
|
1682
|
+
*/
|
|
1445
1683
|
writeUnsafe(records, claims) {
|
|
1446
1684
|
const registry = {
|
|
1447
1685
|
schema_version: REGISTRY_SCHEMA_VERSION,
|
|
@@ -1450,24 +1688,19 @@ export class SessionRegistry {
|
|
|
1450
1688
|
claims_schema_version: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
1451
1689
|
claims: sortResourceClaims(claims).map((claim) => toPersistedResourceClaim(claim, this.repository.repositoryId)),
|
|
1452
1690
|
};
|
|
1453
|
-
const contents = `${JSON.stringify(registry, null, 2)}\n`;
|
|
1454
|
-
const temporaryPath = `${this.paths.registry}.tmp-${process.pid}-${generateSessionId()}`;
|
|
1455
|
-
let descriptor;
|
|
1456
1691
|
try {
|
|
1457
|
-
|
|
1458
|
-
descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
1459
|
-
fs.writeFileSync(descriptor, contents, "utf8");
|
|
1460
|
-
fs.fsyncSync(descriptor);
|
|
1461
|
-
fs.closeSync(descriptor);
|
|
1462
|
-
descriptor = undefined;
|
|
1463
|
-
fs.renameSync(temporaryPath, this.paths.registry);
|
|
1464
|
-
syncDirectory(this.paths.directory);
|
|
1692
|
+
writeJsonAtomicallySync(this.paths.registry, registry);
|
|
1465
1693
|
}
|
|
1466
1694
|
catch (error) {
|
|
1467
|
-
if (
|
|
1468
|
-
|
|
1695
|
+
if (isPostRenameFailure(error)) {
|
|
1696
|
+
throw new SessionRegistryError("REGISTRY_DURABILITY_UNCERTAIN", `Registry rename to ${this.paths.registry} may have already committed, but durable persistence could not be proven`, {
|
|
1697
|
+
path: this.paths.registry,
|
|
1698
|
+
recoveryHints: [
|
|
1699
|
+
"Re-read the registry to check whether the mutation is already visible before retrying.",
|
|
1700
|
+
"Do not assume this operation did not happen.",
|
|
1701
|
+
],
|
|
1702
|
+
}, error);
|
|
1469
1703
|
}
|
|
1470
|
-
unlinkQuietly(temporaryPath);
|
|
1471
1704
|
throw new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not atomically write ${this.paths.registry}`, {
|
|
1472
1705
|
path: this.paths.registry,
|
|
1473
1706
|
}, error);
|
|
@@ -1676,7 +1909,7 @@ function canonicalOperationResources(resources, worktreePath) {
|
|
|
1676
1909
|
});
|
|
1677
1910
|
}
|
|
1678
1911
|
try {
|
|
1679
|
-
canonical.add(
|
|
1912
|
+
canonical.add(canonicalizeConcretePath(resource, worktreePath));
|
|
1680
1913
|
}
|
|
1681
1914
|
catch (error) {
|
|
1682
1915
|
if (error instanceof SessionRegistryError &&
|
|
@@ -1702,13 +1935,28 @@ function operationSessionId(first, second) {
|
|
|
1702
1935
|
function operationResources(resources) {
|
|
1703
1936
|
return resources === undefined ? [] : [...resources];
|
|
1704
1937
|
}
|
|
1705
|
-
function assertFinalCommitMessage(message) {
|
|
1938
|
+
function assertFinalCommitMessage(message, messagePattern) {
|
|
1706
1939
|
if (typeof message !== "string" ||
|
|
1707
1940
|
message.length === 0 ||
|
|
1708
1941
|
message.trim().length === 0 ||
|
|
1709
1942
|
message.includes("\u0000")) {
|
|
1710
1943
|
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message must be a non-empty final message without NUL bytes");
|
|
1711
1944
|
}
|
|
1945
|
+
if (messagePattern === null)
|
|
1946
|
+
return;
|
|
1947
|
+
if (messagePattern.length > MAX_MESSAGE_PATTERN_LENGTH) {
|
|
1948
|
+
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 });
|
|
1949
|
+
}
|
|
1950
|
+
let pattern;
|
|
1951
|
+
try {
|
|
1952
|
+
pattern = new RegExp(messagePattern, "u");
|
|
1953
|
+
}
|
|
1954
|
+
catch (error) {
|
|
1955
|
+
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "The caller-declared commit-message pattern is not a valid regular expression", { reason: "invalid-message-pattern", messagePattern }, error);
|
|
1956
|
+
}
|
|
1957
|
+
if (!pattern.test(message)) {
|
|
1958
|
+
throw new SessionRegistryError("INVALID_COMMIT_MESSAGE", "Commit message does not match the caller-declared commit-message pattern", { reason: "message-pattern-mismatch", messagePattern, message });
|
|
1959
|
+
}
|
|
1712
1960
|
}
|
|
1713
1961
|
function explicitRemote(remote) {
|
|
1714
1962
|
if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) {
|
|
@@ -1740,25 +1988,6 @@ function explicitRemoteBranch(branch, alias) {
|
|
|
1740
1988
|
}, error);
|
|
1741
1989
|
}
|
|
1742
1990
|
}
|
|
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
1991
|
function assertNoUnexpectedMutationPaths(paths, authorized) {
|
|
1763
1992
|
const authorizedSet = new Set(authorized);
|
|
1764
1993
|
const unexpected = paths.filter((resource) => !authorizedSet.has(resource));
|
|
@@ -1812,7 +2041,8 @@ function runMutationGit(git, args, cwd, phase, failureCode) {
|
|
|
1812
2041
|
throw new SessionRegistryError(failureCode, `${phase} Git operation failed`, details, error);
|
|
1813
2042
|
}
|
|
1814
2043
|
}
|
|
1815
|
-
function inspectPushTarget(git, cwd, remote, branch) {
|
|
2044
|
+
function inspectPushTarget(git, cwd, remote, branch, sourceSha) {
|
|
2045
|
+
const targetRef = `refs/heads/${branch}`;
|
|
1816
2046
|
try {
|
|
1817
2047
|
git.run(["remote", "get-url", remote], cwd);
|
|
1818
2048
|
}
|
|
@@ -1821,7 +2051,7 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1821
2051
|
}
|
|
1822
2052
|
let remoteTargetOutput;
|
|
1823
2053
|
try {
|
|
1824
|
-
remoteTargetOutput = git.run(["ls-remote", "--heads", remote,
|
|
2054
|
+
remoteTargetOutput = git.run(["ls-remote", "--heads", remote, targetRef], cwd);
|
|
1825
2055
|
}
|
|
1826
2056
|
catch (error) {
|
|
1827
2057
|
throwRemoteInspectionFailure(error, "ls-remote");
|
|
@@ -1847,7 +2077,13 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1847
2077
|
if (upstream === undefined) {
|
|
1848
2078
|
// The absence of an upstream is itself a first-class relation. A remote
|
|
1849
2079
|
// branch may exist, but it must not silently become tracking state.
|
|
1850
|
-
return {
|
|
2080
|
+
return {
|
|
2081
|
+
sourceSha,
|
|
2082
|
+
relation: "no-upstream",
|
|
2083
|
+
upstream: undefined,
|
|
2084
|
+
targetRef,
|
|
2085
|
+
observedRemoteSha: remoteTargetExists ? extractRemoteSha(remoteTargetOutput, remote, branch) : null,
|
|
2086
|
+
};
|
|
1851
2087
|
}
|
|
1852
2088
|
const separator = upstream.indexOf("/");
|
|
1853
2089
|
if (separator <= 0 || separator === upstream.length - 1) {
|
|
@@ -1870,19 +2106,57 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1870
2106
|
});
|
|
1871
2107
|
}
|
|
1872
2108
|
const remoteSha = remoteShaMatch[1];
|
|
1873
|
-
|
|
2109
|
+
const temporaryRef = `refs/nawabari/push-inspection/${remoteSha}`;
|
|
2110
|
+
let relationOutput;
|
|
2111
|
+
let inspectionError;
|
|
1874
2112
|
try {
|
|
1875
|
-
|
|
2113
|
+
try {
|
|
2114
|
+
// Fetch only the explicitly inspected remote branch into a disposable
|
|
2115
|
+
// ref. This obtains missing ancestry without mutating tracking refs or
|
|
2116
|
+
// fetching unrelated branches/tags.
|
|
2117
|
+
// Requires Git 2.29 or later for --no-write-fetch-head support.
|
|
2118
|
+
// Unsupported Git versions will fail here and surface as
|
|
2119
|
+
// PUSH_REMOTE_INSPECTION_FAILED via throwRemoteInspectionFailure.
|
|
2120
|
+
git.run(["fetch", "--no-tags", "--no-write-fetch-head", "--refmap=", remote, `+${targetRef}:${temporaryRef}`], cwd);
|
|
2121
|
+
const fetchedSha = git.run(["rev-parse", "--verify", temporaryRef], cwd).trim();
|
|
2122
|
+
if (fetchedSha !== remoteSha) {
|
|
2123
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "The remote branch changed while its observed generation was being fetched", {
|
|
2124
|
+
remote,
|
|
2125
|
+
branch,
|
|
2126
|
+
observedRemoteSha: remoteSha,
|
|
2127
|
+
fetchedRemoteSha: fetchedSha,
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
catch (error) {
|
|
2132
|
+
if (error instanceof SessionRegistryError && error.code === "PUSH_REMOTE_INSPECTION_FAILED")
|
|
2133
|
+
throw error;
|
|
2134
|
+
throwRemoteInspectionFailure(error, "remote-generation");
|
|
2135
|
+
}
|
|
2136
|
+
try {
|
|
2137
|
+
relationOutput = git.run(["rev-list", "--left-right", "--count", `${sourceSha}...${temporaryRef}`], cwd);
|
|
2138
|
+
}
|
|
2139
|
+
catch (error) {
|
|
2140
|
+
throwRemoteInspectionFailure(error, "relation");
|
|
2141
|
+
}
|
|
1876
2142
|
}
|
|
1877
2143
|
catch (error) {
|
|
1878
|
-
|
|
2144
|
+
inspectionError = error;
|
|
1879
2145
|
}
|
|
1880
|
-
let relationOutput;
|
|
1881
2146
|
try {
|
|
1882
|
-
|
|
2147
|
+
git.run(["update-ref", "-d", temporaryRef], cwd);
|
|
1883
2148
|
}
|
|
1884
2149
|
catch (error) {
|
|
1885
|
-
|
|
2150
|
+
if (inspectionError === undefined)
|
|
2151
|
+
throwRemoteInspectionFailure(error, "remote-generation-cleanup");
|
|
2152
|
+
}
|
|
2153
|
+
if (inspectionError !== undefined)
|
|
2154
|
+
throw inspectionError;
|
|
2155
|
+
if (relationOutput === undefined) {
|
|
2156
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Remote ancestry relation was unavailable", {
|
|
2157
|
+
remote,
|
|
2158
|
+
branch,
|
|
2159
|
+
});
|
|
1886
2160
|
}
|
|
1887
2161
|
const counts = relationOutput
|
|
1888
2162
|
.trim()
|
|
@@ -1896,7 +2170,14 @@ function inspectPushTarget(git, cwd, remote, branch) {
|
|
|
1896
2170
|
}
|
|
1897
2171
|
const [ahead, behind] = counts;
|
|
1898
2172
|
const relation = ahead > 0 && behind > 0 ? "diverged" : behind > 0 ? "behind" : ahead > 0 ? "ahead" : "up-to-date";
|
|
1899
|
-
return { relation, upstream };
|
|
2173
|
+
return { sourceSha, relation, upstream, targetRef, observedRemoteSha: remoteSha };
|
|
2174
|
+
}
|
|
2175
|
+
function extractRemoteSha(remoteTargetOutput, remote, branch) {
|
|
2176
|
+
const remoteShaMatch = remoteTargetOutput.trim().match(/^([0-9a-f]{40,64})\s+/u);
|
|
2177
|
+
if (remoteShaMatch === null) {
|
|
2178
|
+
throw new SessionRegistryError("PUSH_REMOTE_INSPECTION_FAILED", "Could not extract remote SHA from ls-remote output", { remote, branch });
|
|
2179
|
+
}
|
|
2180
|
+
return remoteShaMatch[1];
|
|
1900
2181
|
}
|
|
1901
2182
|
function isExpectedMissingRef(error) {
|
|
1902
2183
|
return (error instanceof SessionRegistryError &&
|
|
@@ -1913,35 +2194,18 @@ function throwRemoteInspectionFailure(error, phase) {
|
|
|
1913
2194
|
...(error instanceof SessionRegistryError ? { gitCode: error.code } : {}),
|
|
1914
2195
|
}, error);
|
|
1915
2196
|
}
|
|
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
2197
|
function sortStrings(values) {
|
|
1943
2198
|
return [...new Set(values)].sort(compareCodePointStrings);
|
|
1944
2199
|
}
|
|
2200
|
+
function sameCheckpointPaths(left, right) {
|
|
2201
|
+
return (sameStringArray(left.changed, right.changed) &&
|
|
2202
|
+
sameStringArray(left.staged, right.staged) &&
|
|
2203
|
+
sameStringArray(left.unstaged, right.unstaged) &&
|
|
2204
|
+
sameStringArray(left.untracked, right.untracked));
|
|
2205
|
+
}
|
|
2206
|
+
function sameStringArray(left, right) {
|
|
2207
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2208
|
+
}
|
|
1945
2209
|
function assertWorktreeClean(git, worktreePath) {
|
|
1946
2210
|
// Git is authoritative for this decision. --ignored=no means ignored or
|
|
1947
2211
|
// generated artifacts alone are intentionally absent from the blocker set.
|
|
@@ -2263,21 +2527,35 @@ function resolveBaseRef(git, cwd, candidate) {
|
|
|
2263
2527
|
candidate !== candidate.trim() ||
|
|
2264
2528
|
candidate.startsWith("-") ||
|
|
2265
2529
|
candidate.includes("\u0000")) {
|
|
2266
|
-
throw
|
|
2530
|
+
throw invalidBaseRef(candidate, "invalid-format");
|
|
2267
2531
|
}
|
|
2268
2532
|
try {
|
|
2269
|
-
git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
|
|
2270
|
-
|
|
2533
|
+
const revision = git.run(["rev-parse", "--verify", `${candidate}^{commit}`], cwd);
|
|
2534
|
+
if (!isRevision(revision)) {
|
|
2535
|
+
throw new SessionRegistryError("GIT_STATE_AMBIGUOUS", "Git returned an invalid base revision", {
|
|
2536
|
+
baseRef: candidate,
|
|
2537
|
+
revision,
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
return { ref: candidate, revision };
|
|
2271
2541
|
}
|
|
2272
2542
|
catch (error) {
|
|
2273
2543
|
if (isExpectedGitLookupFailure(error)) {
|
|
2274
|
-
throw
|
|
2275
|
-
baseRef: candidate,
|
|
2276
|
-
});
|
|
2544
|
+
throw invalidBaseRef(candidate, "does-not-resolve-to-commit");
|
|
2277
2545
|
}
|
|
2278
2546
|
throw error;
|
|
2279
2547
|
}
|
|
2280
2548
|
}
|
|
2549
|
+
function invalidBaseRef(candidate, reason) {
|
|
2550
|
+
return new SessionRegistryError("INVALID_BASE_REF", reason === "invalid-format"
|
|
2551
|
+
? `Invalid base ref: ${candidate}`
|
|
2552
|
+
: `Base ref does not resolve to a commit: ${candidate}`, {
|
|
2553
|
+
baseRef: candidate,
|
|
2554
|
+
reason,
|
|
2555
|
+
defaultBaseRef: "HEAD",
|
|
2556
|
+
recoveryHints: ["Omit --base to use HEAD, then retry session create."],
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2281
2559
|
function isExpectedGitLookupFailure(error) {
|
|
2282
2560
|
return (error instanceof SessionRegistryError &&
|
|
2283
2561
|
error.code === "GIT_COMMAND_FAILED" &&
|
|
@@ -2320,6 +2598,7 @@ export function toPersistedSessionRecord(record, expectedRepositoryId = record.r
|
|
|
2320
2598
|
state: validated.state,
|
|
2321
2599
|
created_at: validated.createdAt,
|
|
2322
2600
|
updated_at: validated.updatedAt,
|
|
2601
|
+
...(validated.baseRevision === undefined ? {} : { base_revision: validated.baseRevision }),
|
|
2323
2602
|
...(validated.label === undefined ? {} : { label: validated.label }),
|
|
2324
2603
|
};
|
|
2325
2604
|
}
|
|
@@ -2337,7 +2616,7 @@ export function toPersistedResourceClaim(claim, expectedRepositoryId = claim.rep
|
|
|
2337
2616
|
updated_at: validated.updatedAt,
|
|
2338
2617
|
};
|
|
2339
2618
|
}
|
|
2340
|
-
function parseRegistry(value, expectedRepositoryId) {
|
|
2619
|
+
function parseRegistry(value, expectedRepositoryId, allowLegacyClaimSchema = false) {
|
|
2341
2620
|
if (!isRecord(value)) {
|
|
2342
2621
|
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry root must be an object");
|
|
2343
2622
|
}
|
|
@@ -2368,17 +2647,27 @@ function parseRegistry(value, expectedRepositoryId) {
|
|
|
2368
2647
|
// materialized on the next locked mutation or via migrate().
|
|
2369
2648
|
return { sessions: records, claims: [], legacyClaimsAbsent: true };
|
|
2370
2649
|
}
|
|
2371
|
-
|
|
2372
|
-
|
|
2650
|
+
const claimSchemaVersion = value.claims_schema_version;
|
|
2651
|
+
const isLegacyClaimSchema = claimSchemaVersion === LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION;
|
|
2652
|
+
if (claimSchemaVersion !== RESOURCE_CLAIM_SCHEMA_VERSION && !isLegacyClaimSchema) {
|
|
2653
|
+
throw new SessionRegistryError("UNSUPPORTED_CLAIM_SCHEMA_VERSION", `Unsupported resource claim schema version: ${stringifyDetail(claimSchemaVersion)}`, { schemaVersion: claimSchemaVersion });
|
|
2654
|
+
}
|
|
2655
|
+
if (isLegacyClaimSchema && !allowLegacyClaimSchema) {
|
|
2656
|
+
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
2657
|
}
|
|
2374
2658
|
if (!Array.isArray(value.claims)) {
|
|
2375
2659
|
throw new SessionRegistryError("REGISTRY_CORRUPT", "Registry claims must be an array");
|
|
2376
2660
|
}
|
|
2377
|
-
const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId));
|
|
2661
|
+
const claims = value.claims.map((candidate, index) => parseResourceClaim(candidate, index, expectedRepositoryId, isLegacyClaimSchema ? LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION : undefined));
|
|
2378
2662
|
validateRegistryClaims(records, claims, expectedRepositoryId);
|
|
2379
|
-
return {
|
|
2663
|
+
return {
|
|
2664
|
+
sessions: records,
|
|
2665
|
+
claims: sortResourceClaims(claims),
|
|
2666
|
+
legacyClaimsAbsent: false,
|
|
2667
|
+
...(isLegacyClaimSchema ? { legacyClaimsSchemaVersion: LEGACY_RESOURCE_CLAIM_SCHEMA_VERSION } : {}),
|
|
2668
|
+
};
|
|
2380
2669
|
}
|
|
2381
|
-
function parseResourceClaim(value, index, expectedRepositoryId) {
|
|
2670
|
+
function parseResourceClaim(value, index, expectedRepositoryId, expectedSchemaVersion = RESOURCE_CLAIM_SCHEMA_VERSION) {
|
|
2382
2671
|
if (!isRecord(value))
|
|
2383
2672
|
throw invalidRecord(index, "claim must be an object");
|
|
2384
2673
|
assertExactKeys(value, [
|
|
@@ -2392,8 +2681,8 @@ function parseResourceClaim(value, index, expectedRepositoryId) {
|
|
|
2392
2681
|
"created_at",
|
|
2393
2682
|
"updated_at",
|
|
2394
2683
|
], [], 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 });
|
|
2684
|
+
if (value.schema_version !== expectedSchemaVersion) {
|
|
2685
|
+
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
2686
|
}
|
|
2398
2687
|
const claim = {
|
|
2399
2688
|
schemaVersion: RESOURCE_CLAIM_SCHEMA_VERSION,
|
|
@@ -2505,7 +2794,7 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
|
|
|
2505
2794
|
"state",
|
|
2506
2795
|
"created_at",
|
|
2507
2796
|
"updated_at",
|
|
2508
|
-
], ["label"], index);
|
|
2797
|
+
], ["base_revision", "label"], index);
|
|
2509
2798
|
if (value.schema_version !== REGISTRY_SCHEMA_VERSION) {
|
|
2510
2799
|
if (typeof value.schema_version === "number") {
|
|
2511
2800
|
throw new SessionRegistryError("UNSUPPORTED_SCHEMA_VERSION", `Unsupported session schema version: ${value.schema_version}`, {
|
|
@@ -2526,6 +2815,9 @@ function parseSessionRecord(value, index, expectedRepositoryId) {
|
|
|
2526
2815
|
state: requireState(value.state, index),
|
|
2527
2816
|
createdAt: requireString(value.created_at, index, "created_at"),
|
|
2528
2817
|
updatedAt: requireString(value.updated_at, index, "updated_at"),
|
|
2818
|
+
...(value.base_revision === undefined
|
|
2819
|
+
? {}
|
|
2820
|
+
: { baseRevision: requireRevision(value.base_revision, index, "base_revision") }),
|
|
2529
2821
|
...(value.label === undefined ? {} : { label: requireString(value.label, index, "label") }),
|
|
2530
2822
|
};
|
|
2531
2823
|
return validateSessionRecord(record, expectedRepositoryId, index);
|
|
@@ -2571,6 +2863,9 @@ function validateSessionRecord(record, expectedRepositoryId, index) {
|
|
|
2571
2863
|
if (Date.parse(record.updatedAt) < Date.parse(record.createdAt)) {
|
|
2572
2864
|
throw invalidRecord(index, "updated_at cannot precede created_at");
|
|
2573
2865
|
}
|
|
2866
|
+
if (record.baseRevision !== undefined && !isRevision(record.baseRevision)) {
|
|
2867
|
+
throw invalidRecord(index, "base_revision must be a full hexadecimal Git revision");
|
|
2868
|
+
}
|
|
2574
2869
|
if (record.label !== undefined && (typeof record.label !== "string" || record.label.length === 0)) {
|
|
2575
2870
|
throw invalidRecord(index, "label must be a non-empty string");
|
|
2576
2871
|
}
|
|
@@ -2660,6 +2955,16 @@ function requireString(value, index, field) {
|
|
|
2660
2955
|
}
|
|
2661
2956
|
return value;
|
|
2662
2957
|
}
|
|
2958
|
+
function requireRevision(value, index, field) {
|
|
2959
|
+
const revision = requireString(value, index, field);
|
|
2960
|
+
if (!isRevision(revision)) {
|
|
2961
|
+
throw invalidRecord(index, `${field} must be a full hexadecimal Git revision`);
|
|
2962
|
+
}
|
|
2963
|
+
return revision;
|
|
2964
|
+
}
|
|
2965
|
+
function isRevision(value) {
|
|
2966
|
+
return /^[0-9a-f]{40,64}$/u.test(value);
|
|
2967
|
+
}
|
|
2663
2968
|
function requireState(value, index) {
|
|
2664
2969
|
if (typeof value !== "string" || !SESSION_STATES.has(value)) {
|
|
2665
2970
|
throw invalidRecord(index, `state is invalid: ${stringifyDetail(value)}`);
|
|
@@ -2719,36 +3024,4 @@ function toSessionRegistryLockError(error, lockPath) {
|
|
|
2719
3024
|
}
|
|
2720
3025
|
return new SessionRegistryError("REGISTRY_IO_FAILURE", `Could not operate on ${lockPath}`, { path: lockPath }, error);
|
|
2721
3026
|
}
|
|
2722
|
-
function closeQuietly(descriptor) {
|
|
2723
|
-
try {
|
|
2724
|
-
fs.closeSync(descriptor);
|
|
2725
|
-
}
|
|
2726
|
-
catch {
|
|
2727
|
-
// The original operation's error is more useful than a best-effort close error.
|
|
2728
|
-
}
|
|
2729
|
-
}
|
|
2730
|
-
function unlinkQuietly(filePath) {
|
|
2731
|
-
try {
|
|
2732
|
-
fs.unlinkSync(filePath);
|
|
2733
|
-
}
|
|
2734
|
-
catch {
|
|
2735
|
-
// A missing temporary file is already the desired state.
|
|
2736
|
-
}
|
|
2737
|
-
}
|
|
2738
|
-
function syncDirectory(directory) {
|
|
2739
|
-
let descriptor;
|
|
2740
|
-
try {
|
|
2741
|
-
descriptor = fs.openSync(directory, "r");
|
|
2742
|
-
fs.fsyncSync(descriptor);
|
|
2743
|
-
}
|
|
2744
|
-
catch {
|
|
2745
|
-
// Directory fsync is not available on every supported filesystem. The
|
|
2746
|
-
// file itself was fsynced before rename, so continue on that limitation.
|
|
2747
|
-
}
|
|
2748
|
-
finally {
|
|
2749
|
-
if (descriptor !== undefined) {
|
|
2750
|
-
closeQuietly(descriptor);
|
|
2751
|
-
}
|
|
2752
|
-
}
|
|
2753
|
-
}
|
|
2754
3027
|
//# sourceMappingURL=session-registry.js.map
|