/** * Git-native state backends for `.squad/` state storage. * * Hardening: retry with exponential backoff for transient git errors, * circuit-breaker to prevent cascading failures, startup verification, * and observable error surfacing (no silent swallowing). * * @module state-backend */ import type { StorageProvider, StorageStats } from './storage/storage-provider.js'; /** Typed error for git command failures with stderr and command context. */ export declare class GitExecError extends Error { readonly command: string; readonly reason: string; readonly stderr: string; readonly name = "GitExecError"; constructor(command: string, reason: string, stderr: string); } export type StateBackendType = 'local' | 'external-stub' | 'orphan' | 'two-layer'; export interface StateBackend { read(relativePath: string): string | undefined; write(relativePath: string, content: string): void; exists(relativePath: string): boolean; list(relativeDir: string): string[]; delete(relativePath: string): boolean; append(relativePath: string, content: string): void; readonly name: string; } type CircuitState = 'closed' | 'open' | 'half-open'; export declare class CircuitBreaker { private readonly threshold; private readonly cooldownMs; private state; private failures; private lastFailureTime; constructor(threshold?: number, cooldownMs?: number); /** Execute an operation through the circuit breaker. */ execute(fn: () => T, operation: string): T; private onSuccess; private onFailure; get consecutiveFailures(): number; get currentState(): CircuitState; } /** * Thrown when an optimistic CAS write (update-ref expected-old) fails after * exhausting all retry attempts. Callers may surface, requeue, or retry with * application-level coordination. Distinct from GitExecError, which signals * a real git failure (corruption, permission, broken repo). */ export declare class StateBackendConcurrencyError extends Error { readonly operation: string; readonly attempts: number; readonly lastStderr: string; readonly name = "StateBackendConcurrencyError"; constructor(operation: string, attempts: number, lastStderr: string); } /** * Attempt an atomic ref update with compare-and-swap semantics. * * `expectedOldSha` of `null` means "create only if does not exist" * (passed as 40 zeros, git's canonical no-such-ref sentinel). * * Returns `{ ok: true }` on success, `{ ok: false, stderr }` on CAS conflict, * and re-throws any non-CAS git failure (corruption, permission, etc.). */ declare function tryUpdateRef(ref: string, newSha: string, expectedOldSha: string | null, cwd: string): { ok: boolean; stderr: string; }; export declare function _setCasInjectorForTesting(fn: ((ref: string) => { ok: boolean; stderr: string; } | null) | null): void; /** * Internal CAS primitive — exported for unit tests only. * @internal */ export declare const _tryUpdateRefForTesting: typeof tryUpdateRef; export declare class WorktreeBackend implements StateBackend { readonly name = "local"; private readonly root; constructor(squadDir: string); read(relativePath: string): string | undefined; write(relativePath: string, content: string): void; exists(relativePath: string): boolean; list(relativeDir: string): string[]; delete(relativePath: string): boolean; append(relativePath: string, content: string): void; } /** * Validate a state key against characters that could corrupt git plumbing * input (mktree stdin format, branch:path refs) or cause path confusion. */ export declare function validateStateKey(key: string): void; export declare class GitNotesBackend implements StateBackend { readonly name = "git-notes"; private readonly cwd; private readonly ref; private readonly breaker; private _rootCommit; constructor(repoRoot: string); /** Returns the root commit SHA — a stable anchor that never moves. Cached after first call. */ private rootCommit; /** Resolve the current SHA of refs/notes/, or null if it doesn't exist. */ private readNotesRef; /** * Load the JSON blob attached to the root commit at a SPECIFIC notes ref SHA. * Reading at a pinned SHA (not the live ref tip) is the foundation of the CAS * loop — without it, a writer could observe state at version N, build version * N+1, but race against another writer who already advanced to N+1' (losing * data). With a pinned read, the subsequent update-ref CAS catches the race. * * NOTE: this relies on the notes tree having no fanout. Git uses fanout * (ab/cdef.../) only when many notes are present; we only ever store a single * note (on the root commit), so the path is just `:`. */ private loadBlobAt; /** Convenience reader at the live ref tip (used for read-only operations). */ private loadBlob; /** * Build a new notes commit and attempt to atomically swing refs/notes/ * from `expectedOldRefSha` to it. Returns the same `{ ok, stderr }` shape as * tryUpdateRef so the caller's retry loop can act. */ private atomicSaveBlob; /** * Run a mutator under optimistic CAS. The mutator receives the current blob * (re-read on every attempt) and may mutate it; its return value is forwarded * to the caller on success. On CAS conflict, the loop retries with jittered * backoff up to CAS_MAX_ATTEMPTS times, then throws StateBackendConcurrencyError. */ private mutateBlob; read(relativePath: string): string | undefined; write(relativePath: string, content: string): void; exists(relativePath: string): boolean; list(relativeDir: string): string[]; delete(relativePath: string): boolean; append(relativePath: string, content: string): void; } export declare class OrphanBranchBackend implements StateBackend { readonly name = "orphan"; private readonly cwd; private readonly branch; private readonly breaker; constructor(repoRoot: string, branch?: string); private ensureBranch; read(relativePath: string): string | undefined; write(relativePath: string, content: string): void; exists(relativePath: string): boolean; list(relativeDir: string): string[]; delete(relativePath: string): boolean; append(relativePath: string, content: string): void; private removeFromTree; private updateTree; private getSubtreeHash; private replaceEntry; } /** * Adapter that wraps a StateBackend as a StorageProvider. * * Modules that accept `storage: StorageProvider` can use this adapter * so that git-notes and orphan backends flow through the same code paths * as the local filesystem backend. */ export declare class StateBackendStorageAdapter implements StorageProvider { private backend; private squadDir; constructor(backend: StateBackend, squadDir: string); read(filePath: string): Promise; write(filePath: string, data: string): Promise; append(filePath: string, data: string): Promise; exists(filePath: string): Promise; list(dirPath: string): Promise; delete(filePath: string): Promise; deleteDir(dirPath: string): Promise; isDirectory(targetPath: string): Promise; mkdir(_dirPath: string, _options?: { recursive?: boolean; }): Promise; rename(oldPath: string, newPath: string): Promise; copy(srcPath: string, destPath: string): Promise; stat(targetPath: string): Promise; readSync(filePath: string): string | undefined; writeSync(filePath: string, data: string): void; appendSync(filePath: string, data: string): void; existsSync(filePath: string): boolean; listSync(dirPath: string): string[]; deleteSync(filePath: string): void; deleteDirSync(dirPath: string): void; isDirectorySync(targetPath: string): boolean; /** * Delete every key under `rel`, including nested subtrees. A single-level * list+delete pass is not enough: git-notes stores flat keys whose "directory" * segments are not deletable keys themselves, so `agents/x/history/2026/log.md` * would survive a delete of `agents/x`. delete() first (removes leaf keys, and * whole subtrees on tree-based backends), then recurse into whatever remains. */ private deleteDirRecursive; mkdirSync(_dirPath: string, _options?: { recursive?: boolean; }): void; renameSync(oldPath: string, newPath: string): void; copySync(srcPath: string, destPath: string): void; statSync(targetPath: string): StorageStats | undefined; /** Convert absolute path to relative path for the backend. */ private toRelative; } /** * Result of promoteNotes — how many notes were moved, archived, or skipped. */ export interface PromoteNotesResult { /** Orphan keys written for notes flagged `promote_to_permanent`. */ promoted: string[]; /** Orphan keys written for notes flagged `archive_on_close`. */ archived: string[]; /** Count of notes that had neither flag and were left in place. */ skipped: number; } /** * Two-Layer Backend — combines git-notes (commit-scoped annotations) with orphan * branch (permanent state). Reads from orphan for bulk state, writes to both: * - Git notes for commit-scoped "why" annotations (per-agent namespace) * - Orphan branch for permanent state (decisions, histories, logs) * * The notes layer is a real, callable consumer in this backend: call * {@link TwoLayerBackend.promoteNotes} after a PR merges to move notes flagged * with `promote_to_permanent` into the orphan store, and copy notes flagged * with `archive_on_close` into `archive/`. {@link TwoLayerBackend.readNote} * returns a single note's payload. */ export declare class TwoLayerBackend implements StateBackend { readonly name = "two-layer"; readonly notes: GitNotesBackend; readonly orphan: OrphanBranchBackend; private readonly repoRoot; constructor(repoRoot: string); /** Read from orphan (the permanent store) */ read(key: string): string | undefined; /** Write to orphan (permanent state) AND git notes (commit-scoped annotation) */ write(key: string, value: string): void; list(dir: string): string[]; exists(key: string): boolean; delete(key: string): boolean; append(key: string, value: string): void; /** * Read a single git-notes payload as parsed JSON. * * Returns `null` if no note exists on the given commit for the given ref, * or if the note body is not valid JSON. */ readNote(ref: string, commitSha: string): unknown | null; /** * Walk all notes attached to commits reachable from HEAD on the given ref * and act based on their flags: * * - `promote_to_permanent: true` — write payload to the orphan layer under * `promoted//.json` and REMOVE the source note (the note has * been promoted to permanent state and is no longer needed). * - `archive_on_close: true` — copy payload to the orphan layer under * `archive//.json` and KEEP the source note (archive = copy). * - Otherwise — leave the note alone (ephemeral, not worth promoting). * * Notes that fail to parse as JSON are counted as skipped. */ promoteNotes(ref: string): PromoteNotesResult; /** True for refs that look like `squad/` — alphanumerics, dash, underscore, slash. */ private isSafeRef; /** True for SHA-1 hex (40 chars) or SHA-256 hex (64 chars). */ private isSafeCommitSha; /** Pass the ref through as path segments; normalizeKey will validate each. */ private sanitizeRefForKey; } export interface StateBackendConfig { stateBackend?: StateBackendType; } export declare function resolveStateBackend(squadDir: string, repoRoot: string, cliOverride?: StateBackendType): StateBackend; /** * Read-only health check for a state backend. * Verifies the backend is accessible without mutating state. * * For {@link TwoLayerBackend}, both layers are probed independently — the * notes layer can fail (corrupt notes ref, missing commits) even when the * orphan layer is healthy, and we surface that explicitly. */ export declare function verifyStateBackend(backend: StateBackend): { ok: boolean; error?: string; }; /** @internal Reset the one-shot git-notes migration warn flag. Only for use in tests. */ export declare function _resetGitNotesMigrationWarnForTesting(): void; /** @internal Reset the one-shot external-stub migration warn flag. Only for use in tests. */ export declare function _resetExternalStubMigrationWarnForTesting(): void; export {}; //# sourceMappingURL=state-backend.d.ts.map