Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `Timeline.draft(name)`, draft writes, `Timeline.previewJoin(draft)`,
and `Timeline.join(draft)` with join receipts for first-use speculative
workflows.
- Added the `lint:test-law` gate to reject conditional bare `return;`
statements in test bodies so skipped assertions cannot masquerade as passing
tests.

### Changed

Expand All @@ -29,10 +32,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
diagnostic compatibility exports out of the package root into explicit
`legacy`, `storage`, `advanced`, and `diagnostics` subpaths. The package root
now rejects those nouns through the v19 public API boundary audit.
- Locked the package root to the v19 facade allowlist so support ports,
infrastructure adapters, memory helpers, cancellation utilities, sync
internals, and canonical serialization helpers stay behind explicit subpaths.
- Aligned `CheckpointStorePort` with the schema:5 checkpoint envelope tree. The
CBOR adapter now owns the named checkpoint artifact encoding for runtime
checkpoint creation and loading instead of exposing a stale single
`state.cbor` result.
- Added visible-state scope helpers to the `diagnostics` subpath so
materialized-state inspection has an explicit non-legacy import path.
- Deprecated the entire graph-first legacy API. `legacy` remains migration-only
and is no longer presented as a valid first-use path.
- Moved receipt canonical JSON and ORSet/full-state wire encoding out of
domain types and storage adapters into infrastructure codec modules; `ORSet`
no longer exposes `serialize()` or `deserialize()`.
- Raised the coverage ratchet from `92.10%` to `92.56%` after adding targeted
coverage for bounded query node paging and memory-budget rejection paths.
- Upgraded `@git-stunts/git-cas` to `^6.1.0` so Git-backed state caches can use
the library's crash-safe `RootSet` retention API.

### Fixed

- Git-backed state-cache payload trees are now anchored through a graph-scoped
`git-cas` RootSet before their index record is published, then reconciled
after publication so live cache entries remain reachable across Git garbage
collection without retaining evicted entries forever.
- Existing state-cache entries are adopted into the RootSet on ordinary reads,
and `git warp doctor --repair-state-cache` can rebuild malformed or stale
retention metadata while reporting payloads that are missing or have the
wrong Git object type.

## [18.2.1] - 2026-06-30

Expand Down
151 changes: 151 additions & 0 deletions bin/cli/commands/doctor/checksStateCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type WarpStateCacheRetentionReport from '../../../../src/domain/services/state/WarpStateCacheRetentionReport.ts';
import type WarpStateCacheRepairResult from '../../../../src/domain/services/state/WarpStateCacheRepairResult.ts';
import type { DoctorFinding, FindingEvidence } from './types.ts';
import { CODES } from './codes.ts';

type StateCacheInspectionContext = {
readonly stateCache: {
inspectRetention(): Promise<WarpStateCacheRetentionReport>;
} | null;
};

function retentionEvidence(report: WarpStateCacheRetentionReport) {
return {
liveSnapshotIds: [...report.liveSnapshotIds],
anchoredSnapshotIds: [...report.anchoredSnapshotIds],
unanchoredSnapshotIds: [...report.unanchoredSnapshotIds],
missingSnapshotIds: [...report.missingSnapshotIds],
wrongTypeSnapshotIds: [...report.wrongTypeSnapshotIds],
staleRootNames: [...report.staleRootNames],
mismatchedRootNames: [...report.mismatchedRootNames],
rootSetError: report.rootSetError,
};
}

export async function checkStateCacheRetention(
ctx: StateCacheInspectionContext,
): Promise<DoctorFinding[]> {
if (ctx.stateCache === null) { return []; }
const report = await ctx.stateCache.inspectRetention();
return retentionFindings(report);
}

function retentionFindings(report: WarpStateCacheRetentionReport): DoctorFinding[] {
const findings: DoctorFinding[] = [];
const evidence = retentionEvidence(report);
const candidates = [
invalidRootSetFinding(report, evidence),
missingPayloadFinding(report, evidence),
wrongTypeFinding(report, evidence),
unanchoredPayloadFinding(report, evidence),
staleRootFinding(report, evidence),
];
for (const candidate of candidates) {
if (candidate !== null) { findings.push(candidate); }
}
if (findings.length === 0) { findings.push(healthyRetentionFinding(report, evidence)); }
return findings;
}

function invalidRootSetFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding | null {
if (report.rootSetError === null) { return null; }
return {
id: 'state-cache-root-set', status: 'fail', code: CODES.STATE_CACHE_ROOT_SET_INVALID,
impact: 'data_integrity', message: `State-cache RootSet is invalid: ${report.rootSetError}`,
fix: 'Run `git warp doctor --repair-state-cache` after confirming the state-cache index is authoritative', evidence,
};
}

function missingPayloadFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding | null {
if (report.missingSnapshotIds.length === 0) { return null; }
return {
id: 'state-cache-missing-payloads', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_MISSING,
impact: 'data_integrity', message: `${report.missingSnapshotIds.length} state-cache payload(s) no longer exist in Git`,
fix: 'Run `git warp doctor --repair-state-cache`; missing payload bytes cannot be recovered', evidence,
};
}

function wrongTypeFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding | null {
if (report.wrongTypeSnapshotIds.length === 0) { return null; }
return {
id: 'state-cache-wrong-type', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_WRONG_TYPE,
impact: 'data_integrity', message: `${report.wrongTypeSnapshotIds.length} state-cache payload ref(s) do not identify Git trees`,
fix: 'Rebuild the affected state-cache snapshots from authoritative WARP history', evidence,
};
}

function unanchoredPayloadFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding | null {
if (report.unanchoredSnapshotIds.length === 0) { return null; }
return {
id: 'state-cache-unanchored-payloads', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_UNANCHORED,
impact: 'data_integrity', message: `${report.unanchoredSnapshotIds.length} live state-cache payload(s) are not protected from Git GC`,
fix: 'Run `git warp doctor --repair-state-cache` before any repository cleanup', evidence,
};
}

function staleRootFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding | null {
if (report.staleRootNames.length === 0) { return null; }
return {
id: 'state-cache-stale-roots', status: 'warn', code: CODES.STATE_CACHE_STALE_ROOTS,
impact: 'hygiene', message: `${report.staleRootNames.length} stale state-cache RootSet entry or entries retain evicted payloads`,
fix: 'Run `git warp doctor --repair-state-cache` to release stale roots', evidence,
};
}

function healthyRetentionFinding(
report: WarpStateCacheRetentionReport,
evidence: FindingEvidence,
): DoctorFinding {
return {
id: 'state-cache-retention', status: 'ok', code: CODES.STATE_CACHE_RETENTION_OK,
impact: 'data_integrity',
message: `All ${report.liveSnapshotIds.length} live state-cache payload(s) are Git-anchored`,
evidence,
};
}

export function stateCacheRepairFinding(result: WarpStateCacheRepairResult): DoctorFinding {
const partial = result.unrecoverableSnapshotIds.length > 0;
return {
id: 'state-cache-retention-repair',
status: partial ? 'warn' : 'ok',
code: partial
? CODES.STATE_CACHE_RETENTION_PARTIAL_REPAIR
: CODES.STATE_CACHE_RETENTION_REPAIRED,
impact: 'data_integrity',
message: partial
? 'State-cache retention repair anchored every recoverable payload; some payloads were already missing'
: 'State-cache retention repair anchored the live index and released stale roots',
evidence: {
anchoredSnapshotIds: [...result.anchoredSnapshotIds],
unrecoverableSnapshotIds: [...result.unrecoverableSnapshotIds],
removedStaleRootNames: [...result.removedStaleRootNames],
},
};
}

export function stateCacheRepairFailureFinding(error: unknown): DoctorFinding {
return {
id: 'state-cache-retention-repair',
status: 'fail',
code: CODES.CHECK_INTERNAL_ERROR,
impact: 'data_integrity',
message: `State-cache retention repair failed: ${error instanceof Error ? error.message : String(error)}`,
fix: 'Resolve the repository or RootSet error, then rerun `git warp doctor --repair-state-cache`',
};
}
10 changes: 10 additions & 0 deletions bin/cli/commands/doctor/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ export const CODES = {
// memory-budget
MEMORY_BUDGET_REPORT: 'MEMORY_BUDGET_REPORT',

// state-cache-retention
STATE_CACHE_RETENTION_OK: 'STATE_CACHE_RETENTION_OK',
STATE_CACHE_PAYLOAD_UNANCHORED: 'STATE_CACHE_PAYLOAD_UNANCHORED',
STATE_CACHE_PAYLOAD_MISSING: 'STATE_CACHE_PAYLOAD_MISSING',
STATE_CACHE_PAYLOAD_WRONG_TYPE: 'STATE_CACHE_PAYLOAD_WRONG_TYPE',
STATE_CACHE_ROOT_SET_INVALID: 'STATE_CACHE_ROOT_SET_INVALID',
STATE_CACHE_STALE_ROOTS: 'STATE_CACHE_STALE_ROOTS',
STATE_CACHE_RETENTION_REPAIRED: 'STATE_CACHE_RETENTION_REPAIRED',
STATE_CACHE_RETENTION_PARTIAL_REPAIR: 'STATE_CACHE_RETENTION_PARTIAL_REPAIR',

// meta
CHECK_SKIPPED_BUDGET_EXHAUSTED: 'CHECK_SKIPPED_BUDGET_EXHAUSTED',
CHECK_INTERNAL_ERROR: 'CHECK_INTERNAL_ERROR',
Expand Down
29 changes: 16 additions & 13 deletions bin/cli/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ import { doctorSchema } from '../../schemas.ts';
import { createPersistence, resolveGraphName } from '../../shared.ts';
import { ALL_CHECKS } from './checks.ts';
import { CODES } from './codes.ts';
import {
checkStateCacheRetention,
} from './checksStateCache.ts';
import { repairStateCache, resolveStateCache } from './stateCacheCapability.ts';
import { DOCTOR_EXIT_CODES, type DoctorFinding, type DoctorPolicy, type DoctorPayload, type DoctorContext } from './types.ts';
import type { CliOptions, Persistence } from '../../types.ts';
import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts';

const DOCTOR_OPTION_MEMORY_BUDGET = 'memory-budget';
const DOCTOR_OPTION_LARGE_GRAPH = 'large-graph';
const DOCTOR_OPTION_REPAIR_STATE_CACHE = 'repair-state-cache';

const MEMORY_BUDGET_FINDING_ID = 'memory-budget';
const MEMORY_BUDGET_NOT_SPECIFIED = 'not-specified';
Expand All @@ -29,6 +33,7 @@ const DOCTOR_OPTIONS = {
strict: { type: 'boolean', default: false },
[DOCTOR_OPTION_MEMORY_BUDGET]: { type: 'string' },
[DOCTOR_OPTION_LARGE_GRAPH]: { type: 'boolean', default: false },
[DOCTOR_OPTION_REPAIR_STATE_CACHE]: { type: 'boolean', default: false },
};

const DEFAULT_POLICY: DoctorPolicy = {
Expand All @@ -51,20 +56,21 @@ type DoctorCommandValues = {
readonly strict: boolean;
readonly [DOCTOR_OPTION_MEMORY_BUDGET]: string | undefined;
readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean;
readonly [DOCTOR_OPTION_REPAIR_STATE_CACHE]: boolean;
};

type RawDoctorCommandValues = {
readonly strict: boolean;
readonly [DOCTOR_OPTION_MEMORY_BUDGET]?: string | undefined;
readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean;
readonly [DOCTOR_OPTION_REPAIR_STATE_CACHE]: boolean;
};

/** Handles the `git warp doctor` command: runs structural health checks and returns findings. */
export default async function handleDoctor({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: DoctorPayload; exitCode: number }> {
const { values } = parseCommandArgs(args, DOCTOR_OPTIONS, doctorSchema);
const commandValues = normalizeCommandValues(values);
const startMs = Date.now();

const { persistence } = await createPersistence(options.repo);
const graphName = await resolveGraphName(persistence, options.graph);
const policy = { ...DEFAULT_POLICY, strict: commandValues.strict };
Expand All @@ -74,16 +80,18 @@ export default async function handleDoctor({ options, args }: { options: CliOpti
const ctx: DoctorContext = { persistence, stateCache, graphName, writerHeads, policy, repoPath: options.repo };

const memoryFindings = memoryBudgetFindings(commandValues);
const repairFinding = await repairStateCache(commandValues[DOCTOR_OPTION_REPAIR_STATE_CACHE], stateCache);
const { findings, checksRun } = await runChecks(ctx, startMs);
findings.push(...memoryFindings);
if (repairFinding !== null) { findings.push(repairFinding); }
findings.sort(compareFinding);

const payload = assemblePayload({
repo: options.repo,
graph: graphName,
policy,
findings,
checksRun: checksRun + memoryFindings.length,
checksRun: checksRun + memoryFindings.length + (repairFinding === null ? 0 : 1),
startMs,
});
const exitCode = computeExitCode(payload.health, policy.strict);
Expand All @@ -95,18 +103,10 @@ function normalizeCommandValues(values: RawDoctorCommandValues): DoctorCommandVa
strict: values.strict,
[DOCTOR_OPTION_MEMORY_BUDGET]: values[DOCTOR_OPTION_MEMORY_BUDGET],
[DOCTOR_OPTION_LARGE_GRAPH]: values[DOCTOR_OPTION_LARGE_GRAPH],
[DOCTOR_OPTION_REPAIR_STATE_CACHE]: values[DOCTOR_OPTION_REPAIR_STATE_CACHE],
};
}

async function resolveStateCache(persistence: Persistence, graphName: string): Promise<WarpStateCachePort | null> {
const castPersistence = persistence as unknown as { createRuntimeStateCache?: (args: unknown) => Promise<WarpStateCachePort> };
if (typeof castPersistence.createRuntimeStateCache === 'function') {
const { default: defaultCodec } = await import('../../../../src/infrastructure/codecs/CborCodec.ts');
return await castPersistence.createRuntimeStateCache({ graphName, codec: defaultCodec });
}
return null;
}

function memoryBudgetFindings(values: DoctorCommandValues): DoctorFinding[] {
if (values[DOCTOR_OPTION_MEMORY_BUDGET] === undefined && !values[DOCTOR_OPTION_LARGE_GRAPH]) {
return [];
Expand Down Expand Up @@ -216,8 +216,11 @@ async function executeCheck(check: { id: string; fn: (ctx: DoctorContext) => Pro
async function runChecks(ctx: DoctorContext, startMs: number): Promise<{ findings: DoctorFinding[]; checksRun: number }> {
const findings: DoctorFinding[] = [];
let checksRun = 0;
const checks = ctx.stateCache === null
? ALL_CHECKS
: [...ALL_CHECKS, { id: 'state-cache-retention', fn: checkStateCacheRetention }];

for (const check of ALL_CHECKS) {
for (const check of checks) {
const elapsed = Date.now() - startMs;
if (elapsed >= ctx.policy.globalDeadlineMs) {
findings.push({
Expand Down
31 changes: 31 additions & 0 deletions bin/cli/commands/doctor/stateCacheCapability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts';
import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts';
import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts';
import type { Persistence } from '../../types.ts';
import type { DoctorFinding } from './types.ts';
import {
stateCacheRepairFailureFinding,
stateCacheRepairFinding,
} from './checksStateCache.ts';

export type DoctorStateCache = WarpStateCachePort & WarpStateCacheRetentionPort;

export async function resolveStateCache(
persistence: Persistence,
graphName: string,
): Promise<DoctorStateCache | null> {
if (typeof persistence.createRuntimeStateCache !== 'function') { return null; }
return await persistence.createRuntimeStateCache({ graphName, codec: defaultCodec });
}

export async function repairStateCache(
requested: boolean,
stateCache: DoctorStateCache | null,
): Promise<DoctorFinding | null> {
if (!requested || stateCache === null) { return null; }
try {
return stateCacheRepairFinding(await stateCache.repairRetention());
} catch (error) {
return stateCacheRepairFailureFinding(error);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
3 changes: 2 additions & 1 deletion bin/cli/commands/doctor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { Persistence } from '../../types.ts';
import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts';
import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts';

// ── JSON-safe recursive value type ──────────────────────────────────────────

Expand Down Expand Up @@ -64,7 +65,7 @@ export interface DoctorSummary {

export interface DoctorContext {
persistence: Persistence;
stateCache: WarpStateCachePort | null;
stateCache: (WarpStateCachePort & WarpStateCacheRetentionPort) | null;
graphName: string;
writerHeads: Array<{ writerId: string; sha: string | null; ref: string }>;
policy: DoctorPolicy;
Expand Down
1 change: 1 addition & 0 deletions bin/cli/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const doctorSchema = z.object({
strict: z.boolean().default(false),
'memory-budget': z.string().min(1).optional(),
'large-graph': z.boolean().default(false),
'repair-state-cache': z.boolean().default(false),
}).strict();

// ============================================================================
Expand Down
Loading
Loading