nawabari 0.8.0 → 0.9.2
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 +35 -0
- package/dist/cli-command-registry.d.ts +6 -0
- package/dist/cli-command-registry.js +26 -5
- package/dist/cli-command-registry.js.map +1 -1
- package/dist/cli.js +70 -8
- package/dist/cli.js.map +1 -1
- package/dist/contract.js +35 -0
- package/dist/contract.js.map +1 -1
- package/dist/domain/doctor.js +44 -0
- package/dist/domain/doctor.js.map +1 -1
- package/dist/domain/session-backend.d.ts +2 -1
- package/dist/domain/session-backend.js +119 -18
- package/dist/domain/session-backend.js.map +1 -1
- package/dist/domain/session.d.ts +100 -19
- package/dist/domain/session.js.map +1 -1
- package/dist/product-state-manifest.d.ts +152 -0
- package/dist/product-state-manifest.js +240 -0
- package/dist/product-state-manifest.js.map +1 -0
- package/dist/public-contract.d.ts +25 -0
- package/dist/public-contract.js +29 -0
- package/dist/public-contract.js.map +1 -0
- package/dist/public-state.d.ts +75 -0
- package/dist/public-state.js +60 -0
- package/dist/public-state.js.map +1 -0
- package/dist/session-lifecycle-actions.d.ts +4 -3
- package/dist/session-lifecycle-actions.js +14 -5
- package/dist/session-lifecycle-actions.js.map +1 -1
- package/dist/session-lifecycle-classification.d.ts +55 -2
- package/dist/session-lifecycle-classification.js +39 -306
- package/dist/session-lifecycle-classification.js.map +1 -1
- package/dist/session-registry.d.ts +83 -1
- package/dist/session-registry.js +440 -21
- package/dist/session-registry.js.map +1 -1
- package/dist/state/index.d.ts +5 -0
- package/dist/state/index.js +4 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/session/actors.d.ts +71 -0
- package/dist/state/session/actors.js +259 -0
- package/dist/state/session/actors.js.map +1 -0
- package/dist/state/session/guards.d.ts +25 -0
- package/dist/state/session/guards.js +108 -0
- package/dist/state/session/guards.js.map +1 -0
- package/dist/state/session/index.d.ts +6 -0
- package/dist/state/session/index.js +5 -0
- package/dist/state/session/index.js.map +1 -0
- package/dist/state/session/machine.d.ts +185 -0
- package/dist/state/session/machine.js +408 -0
- package/dist/state/session/machine.js.map +1 -0
- package/dist/state/session/types.d.ts +94 -0
- package/dist/state/session/types.js +14 -0
- package/dist/state/session/types.js.map +1 -0
- package/package.json +26 -6
package/dist/session-registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
|
|
3
|
+
import { defaultGit, captureGitCheckpoint, listGitWorktrees, normalizeBranchId, observeGitCheckpoint, observeGitMutationPaths, readBoundedGitDiff, readCanonicalCommitChangedPaths, readChangedPathNames, canonicalizeGitObservedPaths, readGitPathStats, readCurrentHead, readTreePathStates, resolveRepositoryContext, treeEntriesEqual, verifyPhysicalExecutionContext, } from "./git.js";
|
|
4
4
|
import { SessionRegistryError, } from "./errors.js";
|
|
5
5
|
import { generateSessionId, isSessionId } from "./session-id.js";
|
|
6
6
|
import { isPostRenameFailure, writeJsonAtomicallySync } from "./registry/atomic.js";
|
|
@@ -9,9 +9,10 @@ import { assertCanonicalClaimResource, canonicalClaimId, canonicalizeClaimInput,
|
|
|
9
9
|
import { CHECKPOINT_EVIDENCE_SCHEMA_VERSION, CHECKPOINT_MAX_PATHS, OPERATION_AUTHORIZATION_SCHEMA_VERSION, claimModeGrantsAccess, isOperationName, requiredAccessForOperation, } from "./operation-authorization.js";
|
|
10
10
|
import { EVIDENCE_MAX_DIFF_BYTES, EVIDENCE_MAX_DIFF_HUNKS, EVIDENCE_MAX_DIFF_PATHS, evidenceHash, } from "./repository-evidence.js";
|
|
11
11
|
import { classifySessionLifecycle, } from "./session-lifecycle-classification.js";
|
|
12
|
+
import { coordinateSessionLifecycle, } from "./state/session/actors.js";
|
|
12
13
|
import { projectSessionLifecycleActions, } from "./session-lifecycle-actions.js";
|
|
13
14
|
export { primarySessionLifecycleAction, primarySessionLifecycleNextAction, projectSessionLifecycleActions, projectSessionLifecycleNextActions, SESSION_LIFECYCLE_ACTION_SCHEMA_VERSION, } from "./session-lifecycle-actions.js";
|
|
14
|
-
export { classifySessionLifecycle, lifecycleTransition, SESSION_LIFECYCLE_CLASSIFICATION_SCHEMA_VERSION, SESSION_LIFECYCLE_STATES, SESSION_LIFECYCLE_TRANSITION_TABLE, } from "./session-lifecycle-classification.js";
|
|
15
|
+
export { availableLifecycleOperations, classifySessionLifecycle, lifecycleTransition, SESSION_LIFECYCLE_CLASSIFICATION_SCHEMA_VERSION, SESSION_LIFECYCLE_STATES, SESSION_LIFECYCLE_TRANSITION_TABLE, } from "./session-lifecycle-classification.js";
|
|
15
16
|
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";
|
|
16
17
|
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
17
18
|
export { RESOURCE_CLAIM_SCHEMA_VERSION };
|
|
@@ -23,6 +24,7 @@ export const CLEANUP_DECISION_SCHEMA_VERSION = 1;
|
|
|
23
24
|
export const RECONCILIATION_SCHEMA_VERSION = 1;
|
|
24
25
|
export const SESSION_DIAGNOSTIC_SCHEMA_VERSION = 1;
|
|
25
26
|
export const DISCARD_RESULT_SCHEMA_VERSION = 1;
|
|
27
|
+
export const DISCARD_PREVIEW_SCHEMA_VERSION = 1;
|
|
26
28
|
const DEFAULT_LOCK_METADATA_GRACE_MS = 1_000;
|
|
27
29
|
/** Bounds a caller-declared commit-message pattern before it is compiled. */
|
|
28
30
|
export const MAX_MESSAGE_PATTERN_LENGTH = 512;
|
|
@@ -944,7 +946,17 @@ export class SessionRegistry {
|
|
|
944
946
|
assertFinalCommitMessage(options.message, messagePattern);
|
|
945
947
|
return this.withLock(() => {
|
|
946
948
|
const sessionId = operationSessionId(options.sessionId, options.session_id);
|
|
947
|
-
const
|
|
949
|
+
const allClaimed = options.allClaimed === true || options.all_claimed === true;
|
|
950
|
+
const explicitResources = options.resources ?? options.paths;
|
|
951
|
+
if (allClaimed && explicitResources !== undefined && explicitResources.length > 0) {
|
|
952
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "--all-claimed cannot be combined with explicit resources", {
|
|
953
|
+
operation: "commit",
|
|
954
|
+
selectors: ["--resource", "--all-claimed"],
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
const requestedResources = allClaimed
|
|
958
|
+
? this.resolveAllClaimedResourcesUnsafe("commit", sessionId)
|
|
959
|
+
: operationResources(explicitResources);
|
|
948
960
|
const authorization = this.requireMutationAuthorization("commit", requestedResources, sessionId);
|
|
949
961
|
const initial = this.verifyGovernedExecutionContext(sessionId);
|
|
950
962
|
const before = observeGitMutationPaths(this.git, initial.worktreePath);
|
|
@@ -1065,7 +1077,16 @@ export class SessionRegistry {
|
|
|
1065
1077
|
push(options) {
|
|
1066
1078
|
return this.withLock(() => {
|
|
1067
1079
|
const sessionId = operationSessionId(options.sessionId, options.session_id);
|
|
1068
|
-
const
|
|
1080
|
+
const allClaimed = options.allClaimed === true || options.all_claimed === true;
|
|
1081
|
+
if (allClaimed && options.resources !== undefined && options.resources.length > 0) {
|
|
1082
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "--all-claimed cannot be combined with explicit resources", {
|
|
1083
|
+
operation: "push",
|
|
1084
|
+
selectors: ["--resource", "--all-claimed"],
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
const requestedResources = allClaimed
|
|
1088
|
+
? this.resolveAllClaimedResourcesUnsafe("push", sessionId)
|
|
1089
|
+
: operationResources(options.resources);
|
|
1069
1090
|
this.requireMutationAuthorization("push", requestedResources, sessionId);
|
|
1070
1091
|
const initial = this.verifyGovernedExecutionContext(sessionId);
|
|
1071
1092
|
const remote = explicitRemote(options.remote);
|
|
@@ -1155,6 +1176,69 @@ export class SessionRegistry {
|
|
|
1155
1176
|
pushSession(options) {
|
|
1156
1177
|
return this.push(options);
|
|
1157
1178
|
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Resolve the opt-in all-claimed selector while the mutation lock is held.
|
|
1181
|
+
* Claims remain the authority, but only concrete Git/repository evidence is
|
|
1182
|
+
* ever handed to the existing concrete authorization path.
|
|
1183
|
+
*/
|
|
1184
|
+
resolveAllClaimedResourcesUnsafe(operation, sessionId) {
|
|
1185
|
+
const execution = this.verifyGovernedExecutionContext(sessionId);
|
|
1186
|
+
const requiredAccess = requiredAccessForOperation(operation);
|
|
1187
|
+
const state = this.readStateUnsafe();
|
|
1188
|
+
const claims = sortResourceClaims(state.claims.filter((claim) => claim.sessionId === execution.session.sessionId &&
|
|
1189
|
+
claim.repositoryId === execution.repositoryId &&
|
|
1190
|
+
claim.worktreePath === execution.worktreePath));
|
|
1191
|
+
const globClaims = claims.filter((claim) => claim.resource.includes("*") || claim.resource.includes("?"));
|
|
1192
|
+
let candidates;
|
|
1193
|
+
if (operation === "commit") {
|
|
1194
|
+
candidates = observeGitMutationPaths(this.git, execution.worktreePath).changed;
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
const exactClaims = claims.filter((claim) => !claim.resource.includes("*") && !claim.resource.includes("?"));
|
|
1198
|
+
const selected = new Set(exactClaims.map((claim) => claim.resource));
|
|
1199
|
+
if (globClaims.length === 0)
|
|
1200
|
+
return Object.freeze(sortStrings(selected));
|
|
1201
|
+
const baseRevision = execution.session.baseRevision;
|
|
1202
|
+
if (baseRevision === undefined) {
|
|
1203
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "A glob claim cannot be projected to concrete push resources without a persisted base revision", { operation, reason: "non-enumerable-claim", claims: globClaims.map((claim) => claim.resource) });
|
|
1204
|
+
}
|
|
1205
|
+
const changed = readChangedPathNames(this.git, execution.worktreePath, baseRevision, execution.headId);
|
|
1206
|
+
candidates = canonicalizeGitObservedPaths(changed, execution.worktreePath);
|
|
1207
|
+
const ambiguousResource = candidates.find((resource) => resource.includes("*") || resource.includes("?"));
|
|
1208
|
+
if (ambiguousResource !== undefined) {
|
|
1209
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "Git reported a concrete resource whose wildcard spelling is ambiguous", { operation, reason: "ambiguous-concrete-resource", resource: ambiguousResource });
|
|
1210
|
+
}
|
|
1211
|
+
for (const claim of globClaims) {
|
|
1212
|
+
const matches = candidates.filter((resource) => resourceMatchesClaim(claim, resource));
|
|
1213
|
+
if (matches.length === 0) {
|
|
1214
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "A glob claim could not be deterministically projected to a concrete push resource", { operation, reason: "non-enumerable-claim", claim: claim.resource });
|
|
1215
|
+
}
|
|
1216
|
+
for (const match of matches)
|
|
1217
|
+
selected.add(match);
|
|
1218
|
+
}
|
|
1219
|
+
return Object.freeze(sortStrings(selected));
|
|
1220
|
+
}
|
|
1221
|
+
const selected = new Set();
|
|
1222
|
+
for (const resource of candidates) {
|
|
1223
|
+
if (resource.includes("*") || resource.includes("?")) {
|
|
1224
|
+
throw new SessionRegistryError("INVALID_RESOURCE", "Git reported a concrete resource whose wildcard spelling is ambiguous", { operation, reason: "ambiguous-concrete-resource", resource });
|
|
1225
|
+
}
|
|
1226
|
+
const matchingClaims = claims.filter((claim) => resourceMatchesClaim(claim, resource));
|
|
1227
|
+
if (matchingClaims.length === 0)
|
|
1228
|
+
continue;
|
|
1229
|
+
const qualifyingClaims = matchingClaims.filter((claim) => claimModeGrantsAccess(claim.mode, requiredAccess));
|
|
1230
|
+
// Include mode-insufficient matches as concrete candidates so the
|
|
1231
|
+
// existing authorization authority emits INSUFFICIENT_CLAIM_MODE.
|
|
1232
|
+
// Unclaimed paths remain outside the selection and are still rejected by
|
|
1233
|
+
// commit's existing UNEXPECTED_CHANGED_PATHS protection.
|
|
1234
|
+
if (qualifyingClaims.length > 0 || matchingClaims.length > 0)
|
|
1235
|
+
selected.add(resource);
|
|
1236
|
+
}
|
|
1237
|
+
// Let the existing authorization authority produce its normal missing-claim
|
|
1238
|
+
// denial when every observed path is unclaimed; do not invent a selector
|
|
1239
|
+
// denial with different semantics.
|
|
1240
|
+
return Object.freeze(sortStrings(selected.size === 0 ? candidates : selected));
|
|
1241
|
+
}
|
|
1158
1242
|
requireMutationAuthorization(operation, resources, sessionId) {
|
|
1159
1243
|
const decision = this.authorizeOperation({ operation, resources, sessionId });
|
|
1160
1244
|
if (decision.allowed)
|
|
@@ -1492,7 +1576,22 @@ export class SessionRegistry {
|
|
|
1492
1576
|
if (record === undefined) {
|
|
1493
1577
|
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
1494
1578
|
}
|
|
1495
|
-
|
|
1579
|
+
const cleanupOperation = operation ?? (record.terminalOperation === "discard" ? "discard" : "close");
|
|
1580
|
+
let observed;
|
|
1581
|
+
const result = coordinateSessionLifecycle({
|
|
1582
|
+
operation: "reconcile",
|
|
1583
|
+
adapter: {
|
|
1584
|
+
observe: (stage) => this.lifecycleActorInputUnsafe(record, this.readStateUnsafe(), undefined, false, stage === "after" ? "current" : "termination"),
|
|
1585
|
+
effect: () => {
|
|
1586
|
+
observed = this.observeCleanupReconciliation(record, cleanupOperation);
|
|
1587
|
+
return observed;
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
});
|
|
1591
|
+
if (result.phase !== "completed" || observed === undefined) {
|
|
1592
|
+
throw this.lifecycleActorRejection(record, result.decision);
|
|
1593
|
+
}
|
|
1594
|
+
return observed;
|
|
1496
1595
|
});
|
|
1497
1596
|
}
|
|
1498
1597
|
reconcileLifecycleCleanup(sessionIdOrOptions, operation) {
|
|
@@ -1507,7 +1606,7 @@ export class SessionRegistry {
|
|
|
1507
1606
|
const selectedSessionId = sessionId ?? this.resolveCurrentSession().sessionId;
|
|
1508
1607
|
assertSessionId(selectedSessionId);
|
|
1509
1608
|
const evidence = closeIntegrationEvidence(sessionIdOrOptions ?? null);
|
|
1510
|
-
return this.
|
|
1609
|
+
return this.coordinateCleanupUnsafe("close", selectedSessionId, evidence);
|
|
1511
1610
|
});
|
|
1512
1611
|
}
|
|
1513
1612
|
closeSession(sessionIdOrOptions) {
|
|
@@ -1523,12 +1622,95 @@ export class SessionRegistry {
|
|
|
1523
1622
|
throw new SessionRegistryError("SESSION_NOT_FOUND", "Explicit session identity is required for discard; the current session is never inferred");
|
|
1524
1623
|
}
|
|
1525
1624
|
assertSessionId(requestedSessionId);
|
|
1526
|
-
return this.
|
|
1625
|
+
return this.coordinateCleanupUnsafe("discard", requestedSessionId);
|
|
1527
1626
|
});
|
|
1528
1627
|
}
|
|
1529
1628
|
discardSession(sessionIdOrOptions) {
|
|
1530
1629
|
return this.discard(sessionIdOrOptions);
|
|
1531
1630
|
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Produce a bounded, side-effect-free summary of the same session/Git/claim
|
|
1633
|
+
* authorities that an explicit discard observes. The preview never invokes
|
|
1634
|
+
* the cleanup actor or writes registry, worktree, branch, or claim state.
|
|
1635
|
+
*/
|
|
1636
|
+
previewDiscard(sessionIdOrOptions) {
|
|
1637
|
+
return this.withLock(() => {
|
|
1638
|
+
const requestedSessionId = typeof sessionIdOrOptions === "string"
|
|
1639
|
+
? sessionIdOrOptions
|
|
1640
|
+
: (sessionIdOrOptions.sessionId ?? sessionIdOrOptions.session_id ?? null);
|
|
1641
|
+
if (requestedSessionId === null) {
|
|
1642
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", "Explicit session identity is required for discard preview; the current session is never inferred");
|
|
1643
|
+
}
|
|
1644
|
+
assertSessionId(requestedSessionId);
|
|
1645
|
+
const state = this.readStateUnsafe();
|
|
1646
|
+
const record = state.sessions.find((candidate) => candidate.sessionId === requestedSessionId);
|
|
1647
|
+
if (record === undefined) {
|
|
1648
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${requestedSessionId}`, {
|
|
1649
|
+
sessionId: requestedSessionId,
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
const diagnostic = this.diagnoseUnsafe(record, state);
|
|
1653
|
+
const reconciliation = this.observeCleanupReconciliation(record, "discard");
|
|
1654
|
+
const blockers = diagnostic.blockers.map((blocker) => ({
|
|
1655
|
+
code: blocker.code,
|
|
1656
|
+
message: blocker.message,
|
|
1657
|
+
details: { ...blocker.details },
|
|
1658
|
+
}));
|
|
1659
|
+
const recoverableEvidence = blockers.filter((blocker) => blocker.code === "RECOVERABLE_COMMITS");
|
|
1660
|
+
const dirtyEvidence = blockers.filter((blocker) => blocker.code === "DIRTY_WORKTREE");
|
|
1661
|
+
const branchEvidenceObservable = recoverableEvidence.length > 0 || blockers.length === 0;
|
|
1662
|
+
const worktreeEvidenceObservable = dirtyEvidence.length > 0 || recoverableEvidence.length > 0 || blockers.length === 0;
|
|
1663
|
+
const recoverableCommits = {
|
|
1664
|
+
observable: branchEvidenceObservable,
|
|
1665
|
+
present: recoverableEvidence.length > 0 ? true : branchEvidenceObservable ? false : null,
|
|
1666
|
+
evidence: Object.freeze(recoverableEvidence.slice(0, 8)),
|
|
1667
|
+
};
|
|
1668
|
+
const uncommittedWork = {
|
|
1669
|
+
observable: worktreeEvidenceObservable,
|
|
1670
|
+
present: dirtyEvidence.length > 0 ? true : worktreeEvidenceObservable ? false : null,
|
|
1671
|
+
evidence: Object.freeze(dirtyEvidence.slice(0, 8)),
|
|
1672
|
+
};
|
|
1673
|
+
const ownedClaims = sortResourceClaims(state.claims.filter((claim) => claim.sessionId === record.sessionId));
|
|
1674
|
+
const boundedClaims = ownedClaims.slice(0, MAX_DISCARD_CLAIMS).map(cloneResourceClaim);
|
|
1675
|
+
const worktreeHead = reconciliation.observedWorktreeHead;
|
|
1676
|
+
const branchHead = reconciliation.observedBranchHead;
|
|
1677
|
+
return Object.freeze({
|
|
1678
|
+
schemaVersion: DISCARD_PREVIEW_SCHEMA_VERSION,
|
|
1679
|
+
operation: "discard-preview",
|
|
1680
|
+
destructive: true,
|
|
1681
|
+
warning: "Actual session discard is destructive: it may remove the worktree and branch and discard recoverable work.",
|
|
1682
|
+
session: cloneSessionRecord(record),
|
|
1683
|
+
currentState: record.state,
|
|
1684
|
+
persistedState: record.state,
|
|
1685
|
+
physicalState: diagnostic.physicalState,
|
|
1686
|
+
worktreePresent: reconciliation.worktreePresent,
|
|
1687
|
+
branchPresent: reconciliation.branchPresent,
|
|
1688
|
+
worktreeHead,
|
|
1689
|
+
branchHead,
|
|
1690
|
+
head: worktreeHead ?? branchHead ?? record.discardedHead ?? null,
|
|
1691
|
+
expectedHead: reconciliation.expectedHead,
|
|
1692
|
+
recoverableCommits,
|
|
1693
|
+
uncommittedWork,
|
|
1694
|
+
claims: Object.freeze(boundedClaims),
|
|
1695
|
+
claimCount: ownedClaims.length,
|
|
1696
|
+
claimsTruncated: ownedClaims.length > boundedClaims.length,
|
|
1697
|
+
destructiveScope: Object.freeze({
|
|
1698
|
+
worktree: reconciliation.worktreePresent,
|
|
1699
|
+
branch: reconciliation.branchPresent,
|
|
1700
|
+
unintegratedCommits: recoverableCommits.present,
|
|
1701
|
+
uncommittedWork: uncommittedWork.present,
|
|
1702
|
+
claims: ownedClaims.length,
|
|
1703
|
+
}),
|
|
1704
|
+
diagnostic: Object.freeze({
|
|
1705
|
+
closeReadiness: diagnostic.closeReadiness,
|
|
1706
|
+
cleanupReadiness: diagnostic.cleanupReadiness,
|
|
1707
|
+
resultState: diagnostic.resultState,
|
|
1708
|
+
blockers: Object.freeze(blockers.slice(0, 8)),
|
|
1709
|
+
...(diagnostic.lifecycle === undefined ? {} : { lifecycleState: diagnostic.lifecycle.state }),
|
|
1710
|
+
}),
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1532
1714
|
/** Detect stale sessions, optionally applying only cleanup that passes close preflight. */
|
|
1533
1715
|
garbageCollect(options = {}) {
|
|
1534
1716
|
const apply = options.apply ?? false;
|
|
@@ -1543,20 +1725,39 @@ export class SessionRegistry {
|
|
|
1543
1725
|
const worktrees = listGitWorktrees(this.git, this.repository.worktreePath);
|
|
1544
1726
|
const assessments = records
|
|
1545
1727
|
.filter((record) => record.state !== "closed")
|
|
1546
|
-
.map((record) =>
|
|
1728
|
+
.map((record) => {
|
|
1729
|
+
const assessment = assessGarbageCollection(record, now, staleAfterMs, worktrees);
|
|
1730
|
+
const decision = this.cleanupDecisionUnsafe(record, state);
|
|
1731
|
+
const lifecycle = classifySessionLifecycle({
|
|
1732
|
+
sessionState: record.state,
|
|
1733
|
+
physicalState: decision.physicalState,
|
|
1734
|
+
closeReadiness: decision.lifecycle?.closeReadiness ?? (decision.allowed ? "ready" : "blocked"),
|
|
1735
|
+
blockers: decision.blockers.map((blocker) => ({ code: blocker.code })),
|
|
1736
|
+
terminalOperation: record.terminalOperation,
|
|
1737
|
+
ageSuspicious: assessment.suspicion === "age",
|
|
1738
|
+
// GC's existing candidate assessment is the independent positive
|
|
1739
|
+
// authority; the machine still evaluates the complete observed
|
|
1740
|
+
// cleanup evidence and may reject the event.
|
|
1741
|
+
gcAuthorized: assessment.destructiveEligibility === "eligible",
|
|
1742
|
+
phase: "termination",
|
|
1743
|
+
});
|
|
1744
|
+
const nextActions = projectSessionLifecycleActions({
|
|
1745
|
+
classification: lifecycle,
|
|
1746
|
+
sessionId: record.sessionId,
|
|
1747
|
+
blockers: decision.blockers.map((blocker) => ({ code: blocker.code, details: blocker.details })),
|
|
1748
|
+
});
|
|
1749
|
+
return Object.freeze({ ...assessment, physicalState: decision.physicalState, lifecycle, nextActions });
|
|
1750
|
+
});
|
|
1547
1751
|
const candidates = assessments.filter((assessment) => assessment.suspicion !== "none");
|
|
1548
1752
|
const eligible = assessments.filter((assessment) => {
|
|
1549
1753
|
if (assessment.terminalOperation === "discard")
|
|
1550
1754
|
return false;
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
gcAuthorized: true,
|
|
1556
|
-
phase: "termination",
|
|
1557
|
-
});
|
|
1755
|
+
// Eligibility is evaluated from the GC authority's own bounded
|
|
1756
|
+
// evidence. Full cleanup blockers remain on `assessment.lifecycle`
|
|
1757
|
+
// and are applied by the existing preflight below.
|
|
1758
|
+
const gcLifecycle = projectGarbageCollectAuthorization(assessment);
|
|
1558
1759
|
if (assessment.destructiveEligibility === "eligible" &&
|
|
1559
|
-
(
|
|
1760
|
+
(gcLifecycle.destructiveCleanupEligible || assessment.physicalState === "prunable-missing")) {
|
|
1560
1761
|
return true;
|
|
1561
1762
|
}
|
|
1562
1763
|
// A close that already removed one physical resource is represented
|
|
@@ -1601,7 +1802,7 @@ export class SessionRegistry {
|
|
|
1601
1802
|
this.writeUnsafe(records, claims, claimSetGeneration);
|
|
1602
1803
|
}
|
|
1603
1804
|
try {
|
|
1604
|
-
const result = this.
|
|
1805
|
+
const result = this.coordinateCleanupUnsafe("gc", candidate.sessionId);
|
|
1605
1806
|
cleaned.push(result.session);
|
|
1606
1807
|
}
|
|
1607
1808
|
catch (error) {
|
|
@@ -1624,6 +1825,173 @@ export class SessionRegistry {
|
|
|
1624
1825
|
gc(options = {}) {
|
|
1625
1826
|
return this.garbageCollect(options);
|
|
1626
1827
|
}
|
|
1828
|
+
coordinateCleanupUnsafe(operation, sessionId, evidence, retryOverride) {
|
|
1829
|
+
const state = this.readStateUnsafe();
|
|
1830
|
+
const record = state.sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1831
|
+
if (record === undefined) {
|
|
1832
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
1833
|
+
}
|
|
1834
|
+
const retry = record.state === "closing" &&
|
|
1835
|
+
(operation === "gc" ||
|
|
1836
|
+
(operation === "close" && record.terminalOperation === undefined) ||
|
|
1837
|
+
(operation === "discard" && record.terminalOperation === "discard"));
|
|
1838
|
+
const actorRetry = retryOverride ?? retry;
|
|
1839
|
+
const reconciliationOperation = operation === "close" ? "close" : operation === "discard" ? "discard" : "gc";
|
|
1840
|
+
let observedDiagnostic;
|
|
1841
|
+
let observedResources;
|
|
1842
|
+
const result = coordinateSessionLifecycle({
|
|
1843
|
+
operation,
|
|
1844
|
+
retry: actorRetry,
|
|
1845
|
+
adapter: {
|
|
1846
|
+
retryAuthorized: actorRetry
|
|
1847
|
+
? () => {
|
|
1848
|
+
const reconciliation = this.observeCleanupReconciliation(record, reconciliationOperation);
|
|
1849
|
+
if (reconciliation.retrySafe && reconciliation.outcome !== "unresolved")
|
|
1850
|
+
return true;
|
|
1851
|
+
// A cleanup that was not yet durably marked `closing` has no
|
|
1852
|
+
// captured head to reconcile. The existing cleanup preflight is
|
|
1853
|
+
// still the positive identity authority for prunable-missing
|
|
1854
|
+
// worktrees; do not infer permission from age or state alone.
|
|
1855
|
+
if (record.cleanupHead === undefined && record.discardedHead === undefined) {
|
|
1856
|
+
try {
|
|
1857
|
+
this.inspectCleanupResources(record, undefined, operation === "close" ? evidence : undefined, operation === "discard" ? "discard" : "close");
|
|
1858
|
+
return true;
|
|
1859
|
+
}
|
|
1860
|
+
catch {
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
: undefined,
|
|
1867
|
+
observe: (stage) => {
|
|
1868
|
+
const observedState = this.readStateUnsafe();
|
|
1869
|
+
const observedRecord = observedState.sessions.find((candidate) => candidate.sessionId === sessionId);
|
|
1870
|
+
if (observedRecord === undefined) {
|
|
1871
|
+
throw new SessionRegistryError("SESSION_NOT_FOUND", `Session was not found: ${sessionId}`, { sessionId });
|
|
1872
|
+
}
|
|
1873
|
+
return this.lifecycleActorInputUnsafe(observedRecord, observedState, evidence, operation === "gc", stage === "after" ? "current" : "termination", (diagnostic) => {
|
|
1874
|
+
observedDiagnostic = diagnostic;
|
|
1875
|
+
}, operation === "discard"
|
|
1876
|
+
? undefined
|
|
1877
|
+
: (resources) => {
|
|
1878
|
+
if (stage === "before")
|
|
1879
|
+
observedResources = resources;
|
|
1880
|
+
});
|
|
1881
|
+
},
|
|
1882
|
+
effect: () => {
|
|
1883
|
+
if (operation === "discard")
|
|
1884
|
+
return this.discardUnsafe(sessionId);
|
|
1885
|
+
return this.closeUnsafe(sessionId, operation === "close" ? evidence : undefined, operation, observedResources);
|
|
1886
|
+
},
|
|
1887
|
+
reconcile: (_error, _after, _decision) => {
|
|
1888
|
+
const reconciliation = this.observeCleanupReconciliation(record, reconciliationOperation);
|
|
1889
|
+
return {
|
|
1890
|
+
outcome: reconciliation.outcome,
|
|
1891
|
+
retrySafe: reconciliation.retrySafe,
|
|
1892
|
+
...(reconciliation.reason === undefined ? {} : { reason: reconciliation.reason }),
|
|
1893
|
+
};
|
|
1894
|
+
},
|
|
1895
|
+
},
|
|
1896
|
+
});
|
|
1897
|
+
if (result.phase === "rejected" &&
|
|
1898
|
+
!actorRetry &&
|
|
1899
|
+
record.state !== "closed" &&
|
|
1900
|
+
(operation === "close" || operation === "gc") &&
|
|
1901
|
+
observedDiagnostic?.blockers.length === 0 &&
|
|
1902
|
+
(observedDiagnostic.physicalState === "prunable-missing" ||
|
|
1903
|
+
(operation === "close" && observedDiagnostic.physicalState === "unregistered-missing"))) {
|
|
1904
|
+
if (operation === "close")
|
|
1905
|
+
return this.coordinateCleanupUnsafe("close", sessionId, evidence, true);
|
|
1906
|
+
return this.coordinateCleanupUnsafe("gc", sessionId, undefined, true);
|
|
1907
|
+
}
|
|
1908
|
+
if (result.phase === "rejected") {
|
|
1909
|
+
// Preserve the durable terminal-intent guards that the existing effect
|
|
1910
|
+
// adapters enforce before any physical observation can reinterpret the
|
|
1911
|
+
// request. These are persisted intent contracts, not a new actor
|
|
1912
|
+
// authority: normal close never acquires discard authority, and an
|
|
1913
|
+
// ordinary close already in progress cannot be converted to discard.
|
|
1914
|
+
if (operation === "close" && record.terminalOperation === "discard") {
|
|
1915
|
+
throw new SessionRegistryError("OPERATION_REJECTED", "An explicit discard is already in progress; retry session discard to complete it", { sessionId, state: record.state, terminalOperation: "discard", safeActions: ["retry-discard"] });
|
|
1916
|
+
}
|
|
1917
|
+
if (operation === "discard" && record.state === "closed" && record.terminalOperation !== "discard") {
|
|
1918
|
+
throw new SessionRegistryError("OPERATION_REJECTED", "The selected session was already closed normally; discard cannot be retroactively applied", { sessionId, state: record.state, terminalOperation: "close" });
|
|
1919
|
+
}
|
|
1920
|
+
if (operation === "discard" && record.state === "closing" && record.terminalOperation !== "discard") {
|
|
1921
|
+
throw new SessionRegistryError("OPERATION_REJECTED", "The selected session has an ordinary close in progress; retry close before choosing discard", { sessionId, state: record.state, safeActions: ["retry-close", "retain-session"] });
|
|
1922
|
+
}
|
|
1923
|
+
const blocker = observedDiagnostic?.blockers[0];
|
|
1924
|
+
if (blocker !== undefined) {
|
|
1925
|
+
throw enrichCloseBlockerError(new SessionRegistryError(blocker.code, blocker.message, {
|
|
1926
|
+
...blocker.details,
|
|
1927
|
+
recoveryHints: [...blocker.recoveryHints],
|
|
1928
|
+
safeActions: [...blocker.safeActions],
|
|
1929
|
+
}));
|
|
1930
|
+
}
|
|
1931
|
+
throw this.lifecycleActorRejection(record, result.decision, evidence);
|
|
1932
|
+
}
|
|
1933
|
+
if (result.error !== undefined)
|
|
1934
|
+
throw result.error;
|
|
1935
|
+
if (result.phase !== "completed" || result.value === undefined) {
|
|
1936
|
+
throw new SessionRegistryError("OPERATION_REJECTED", "Lifecycle cleanup did not finalize", {
|
|
1937
|
+
sessionId,
|
|
1938
|
+
operation,
|
|
1939
|
+
phase: result.phase,
|
|
1940
|
+
reconciliation: result.reconciliation,
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
return result.value;
|
|
1944
|
+
}
|
|
1945
|
+
/** Build an actor input from one authoritative registry/physical diagnostic. */
|
|
1946
|
+
lifecycleActorInputUnsafe(record, state, evidence, gcAuthorized, phase, diagnosticSink, resourcesSink) {
|
|
1947
|
+
const diagnostic = this.diagnoseUnsafe(record, state, evidence, resourcesSink);
|
|
1948
|
+
diagnosticSink?.(diagnostic);
|
|
1949
|
+
return {
|
|
1950
|
+
persisted: {
|
|
1951
|
+
sessionId: record.sessionId,
|
|
1952
|
+
state: record.state,
|
|
1953
|
+
...(record.terminalOperation === undefined ? {} : { terminalOperation: record.terminalOperation }),
|
|
1954
|
+
...(record.cleanupHead === undefined ? {} : { cleanupHead: record.cleanupHead }),
|
|
1955
|
+
...(record.discardedHead === undefined ? {} : { discardedHead: record.discardedHead }),
|
|
1956
|
+
},
|
|
1957
|
+
observation: {
|
|
1958
|
+
sessionState: record.state,
|
|
1959
|
+
physicalState: diagnostic.physicalState,
|
|
1960
|
+
closeReadiness: diagnostic.closeReadiness === "not_due" ? "not-evaluated" : diagnostic.closeReadiness,
|
|
1961
|
+
blockers: diagnostic.blockers.map((blocker) => ({ code: blocker.code })),
|
|
1962
|
+
terminalOperation: record.terminalOperation,
|
|
1963
|
+
ageSuspicious: diagnostic.garbageCollection.suspicion === "age",
|
|
1964
|
+
gcAuthorized,
|
|
1965
|
+
phase,
|
|
1966
|
+
},
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
/** Preserve existing blocker/error contracts when the actor rejects a request. */
|
|
1970
|
+
lifecycleActorRejection(record, decision, evidence) {
|
|
1971
|
+
if (decision.operation === "close" || decision.operation === "discard" || decision.operation === "gc") {
|
|
1972
|
+
try {
|
|
1973
|
+
const resources = this.inspectCleanupResources(record, undefined, decision.operation === "close" ? evidence : undefined, decision.operation === "discard" ? "discard" : "close");
|
|
1974
|
+
if (decision.operation === "discard" &&
|
|
1975
|
+
(resources.physicalState !== "healthy" || !resources.worktreePresent || !resources.branchPresent)) {
|
|
1976
|
+
throw ownershipMismatch(record, "Discard requires an unambiguous, currently owned worktree and branch", {
|
|
1977
|
+
physicalState: resources.physicalState,
|
|
1978
|
+
worktreePresent: resources.worktreePresent,
|
|
1979
|
+
branchPresent: resources.branchPresent,
|
|
1980
|
+
phase: "discard-preflight",
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
catch (error) {
|
|
1985
|
+
return enrichCloseBlockerError(error);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return new SessionRegistryError("OPERATION_REJECTED", "Lifecycle transition was rejected by the Session actor", {
|
|
1989
|
+
sessionId: record.sessionId,
|
|
1990
|
+
operation: decision.operation,
|
|
1991
|
+
reason: decision.reason,
|
|
1992
|
+
target: decision.target,
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1627
1995
|
resolveOwnerSession(records) {
|
|
1628
1996
|
const matches = records.filter((record) => CURRENT_SESSION_STATES.has(record.state) && record.worktreeId === this.repository.worktreePath);
|
|
1629
1997
|
if (matches.length === 1)
|
|
@@ -1916,7 +2284,7 @@ export class SessionRegistry {
|
|
|
1916
2284
|
}
|
|
1917
2285
|
return candidates;
|
|
1918
2286
|
}
|
|
1919
|
-
closeUnsafe(sessionId, evidence, cleanupOperation = "close") {
|
|
2287
|
+
closeUnsafe(sessionId, evidence, cleanupOperation = "close", initialResources) {
|
|
1920
2288
|
const state = this.readStateUnsafe();
|
|
1921
2289
|
const records = state.sessions;
|
|
1922
2290
|
const record = records.find((candidate) => candidate.sessionId === sessionId);
|
|
@@ -1939,7 +2307,7 @@ export class SessionRegistry {
|
|
|
1939
2307
|
}
|
|
1940
2308
|
let resources;
|
|
1941
2309
|
try {
|
|
1942
|
-
resources = this.inspectCleanupResources(record, undefined, evidence);
|
|
2310
|
+
resources = initialResources ?? this.inspectCleanupResources(record, undefined, evidence);
|
|
1943
2311
|
}
|
|
1944
2312
|
catch (error) {
|
|
1945
2313
|
throw enrichCloseBlockerError(error);
|
|
@@ -2237,7 +2605,7 @@ export class SessionRegistry {
|
|
|
2237
2605
|
lifecycle,
|
|
2238
2606
|
});
|
|
2239
2607
|
}
|
|
2240
|
-
diagnoseUnsafe(record, state, evidence) {
|
|
2608
|
+
diagnoseUnsafe(record, state, evidence, resourcesSink) {
|
|
2241
2609
|
const claims = state.claims.filter((claim) => claim.sessionId === record.sessionId).map(cloneResourceClaim);
|
|
2242
2610
|
// Used only to evaluate staleness below; deliberately not part of the
|
|
2243
2611
|
// returned diagnostic so repeated inspection of unchanged state is
|
|
@@ -2257,6 +2625,7 @@ export class SessionRegistry {
|
|
|
2257
2625
|
});
|
|
2258
2626
|
const nextActions = projectSessionLifecycleActions({ classification: lifecycle, sessionId: record.sessionId });
|
|
2259
2627
|
const garbageCollection = closedGarbageCollectionAssessment(record, now);
|
|
2628
|
+
const projectedGarbageCollection = projectGarbageCollectCandidateLifecycle(garbageCollection, lifecycle, []);
|
|
2260
2629
|
return Object.freeze({
|
|
2261
2630
|
schemaVersion: SESSION_DIAGNOSTIC_SCHEMA_VERSION,
|
|
2262
2631
|
operation: "diagnostic",
|
|
@@ -2275,7 +2644,7 @@ export class SessionRegistry {
|
|
|
2275
2644
|
...(nextActions.length === 0 ? {} : { nextAction: nextActions[0] }),
|
|
2276
2645
|
nextActions,
|
|
2277
2646
|
integrationEvidence: Object.freeze(integrationEvidence),
|
|
2278
|
-
garbageCollection,
|
|
2647
|
+
garbageCollection: projectedGarbageCollection,
|
|
2279
2648
|
lifecycle,
|
|
2280
2649
|
});
|
|
2281
2650
|
}
|
|
@@ -2290,6 +2659,7 @@ export class SessionRegistry {
|
|
|
2290
2659
|
garbageCollection = assessGarbageCollection(record, now, this.staleAfterMs, worktrees);
|
|
2291
2660
|
staleCandidate = garbageCollection.destructiveEligibility === "eligible";
|
|
2292
2661
|
const resources = this.inspectCleanupResources(record, worktrees, evidence);
|
|
2662
|
+
resourcesSink?.(resources);
|
|
2293
2663
|
integrationProof = resources.integrationProof;
|
|
2294
2664
|
}
|
|
2295
2665
|
catch (error) {
|
|
@@ -2307,6 +2677,7 @@ export class SessionRegistry {
|
|
|
2307
2677
|
ageSuspicious: Date.parse(now) - Date.parse(record.updatedAt) >= this.staleAfterMs,
|
|
2308
2678
|
phase: "termination",
|
|
2309
2679
|
});
|
|
2680
|
+
garbageCollection = projectGarbageCollectCandidateLifecycle(garbageCollection, lifecycle, blockers);
|
|
2310
2681
|
const safeActions = blockers.length > 0
|
|
2311
2682
|
? sortStrings(Array.from(new Set(blockers.flatMap((blocker) => blocker.safeActions))))
|
|
2312
2683
|
: closeReadiness === "ready"
|
|
@@ -2988,6 +3359,17 @@ function assessGarbageCollection(record, now, staleAfterMs, worktrees) {
|
|
|
2988
3359
|
destructiveEligibility = "ineligible";
|
|
2989
3360
|
destructiveEligibilityReason = "age-only";
|
|
2990
3361
|
}
|
|
3362
|
+
const lifecycle = projectGarbageCollectAuthorization({
|
|
3363
|
+
state: record.state,
|
|
3364
|
+
physicalState: worktreeState.kind,
|
|
3365
|
+
terminalOperation: record.terminalOperation,
|
|
3366
|
+
suspicion,
|
|
3367
|
+
destructiveEligibility,
|
|
3368
|
+
});
|
|
3369
|
+
const nextActions = projectSessionLifecycleActions({
|
|
3370
|
+
classification: lifecycle,
|
|
3371
|
+
sessionId: record.sessionId,
|
|
3372
|
+
});
|
|
2991
3373
|
return Object.freeze({
|
|
2992
3374
|
...cloneSessionRecord(record),
|
|
2993
3375
|
physicalState: worktreeState.kind,
|
|
@@ -2995,9 +3377,35 @@ function assessGarbageCollection(record, now, staleAfterMs, worktrees) {
|
|
|
2995
3377
|
suspicionReason,
|
|
2996
3378
|
destructiveEligibility,
|
|
2997
3379
|
destructiveEligibilityReason,
|
|
3380
|
+
lifecycle,
|
|
3381
|
+
nextActions,
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
/** Evaluate GC's independent authorization through the canonical machine. */
|
|
3385
|
+
function projectGarbageCollectAuthorization(candidate) {
|
|
3386
|
+
return classifySessionLifecycle({
|
|
3387
|
+
sessionState: candidate.state,
|
|
3388
|
+
physicalState: candidate.physicalState,
|
|
3389
|
+
closeReadiness: "ready",
|
|
3390
|
+
blockers: [],
|
|
3391
|
+
terminalOperation: candidate.terminalOperation,
|
|
3392
|
+
ageSuspicious: candidate.suspicion === "age",
|
|
3393
|
+
gcAuthorized: candidate.destructiveEligibility === "eligible",
|
|
3394
|
+
phase: "termination",
|
|
2998
3395
|
});
|
|
2999
3396
|
}
|
|
3000
3397
|
function closedGarbageCollectionAssessment(record, now) {
|
|
3398
|
+
const lifecycle = classifySessionLifecycle({
|
|
3399
|
+
sessionState: record.state,
|
|
3400
|
+
physicalState: "closed",
|
|
3401
|
+
closeReadiness: "ready",
|
|
3402
|
+
terminalOperation: record.terminalOperation,
|
|
3403
|
+
phase: "termination",
|
|
3404
|
+
});
|
|
3405
|
+
const nextActions = projectSessionLifecycleActions({
|
|
3406
|
+
classification: lifecycle,
|
|
3407
|
+
sessionId: record.sessionId,
|
|
3408
|
+
});
|
|
3001
3409
|
return Object.freeze({
|
|
3002
3410
|
...cloneSessionRecord(record),
|
|
3003
3411
|
physicalState: "closed",
|
|
@@ -3005,6 +3413,8 @@ function closedGarbageCollectionAssessment(record, now) {
|
|
|
3005
3413
|
suspicionReason: "none",
|
|
3006
3414
|
destructiveEligibility: "ineligible",
|
|
3007
3415
|
destructiveEligibilityReason: "not-suspected",
|
|
3416
|
+
lifecycle,
|
|
3417
|
+
nextActions,
|
|
3008
3418
|
});
|
|
3009
3419
|
}
|
|
3010
3420
|
function isDestructiveGcEligible(record, worktrees) {
|
|
@@ -3166,6 +3576,15 @@ function toCleanupBlocker(error) {
|
|
|
3166
3576
|
recoveryHints: Object.freeze(recoveryHints),
|
|
3167
3577
|
});
|
|
3168
3578
|
}
|
|
3579
|
+
/** Keep the nested GC assessment on the same observed lifecycle snapshot. */
|
|
3580
|
+
function projectGarbageCollectCandidateLifecycle(candidate, lifecycle, blockers) {
|
|
3581
|
+
const nextActions = projectSessionLifecycleActions({
|
|
3582
|
+
classification: lifecycle,
|
|
3583
|
+
sessionId: candidate.sessionId,
|
|
3584
|
+
blockers,
|
|
3585
|
+
});
|
|
3586
|
+
return Object.freeze({ ...candidate, lifecycle, nextActions });
|
|
3587
|
+
}
|
|
3169
3588
|
function toDiagnosticBlocker(error) {
|
|
3170
3589
|
const blocker = toCleanupBlocker(error);
|
|
3171
3590
|
return Object.freeze({
|