From f64eeae96c799adcc7b461ffd4f98fe2a9da62cb Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 19 Jun 2026 12:46:02 -0400 Subject: [PATCH] Initial commit --- .gitignore | 3 + AGENTS.md | 80 +++ LICENSE | 21 + README.md | 87 +++ index.ts | 678 ++++++++++++++++++ package.json | 57 ++ src/config.ts | 337 +++++++++ src/diagnostics.ts | 308 +++++++++ src/edge-cases.ts | 1179 ++++++++++++++++++++++++++++++++ src/event-handlers.ts | 501 ++++++++++++++ src/lock-acquisition.ts | 872 +++++++++++++++++++++++ src/lock-manager.ts | 955 ++++++++++++++++++++++++++ src/lock-types.ts | 270 ++++++++ src/notifications.ts | 317 +++++++++ src/system-prompt.ts | 173 +++++ src/tools.ts | 428 ++++++++++++ src/user-interaction.ts | 335 +++++++++ tests/config.test.ts | 373 ++++++++++ tests/e2e.test.ts | 168 +++++ tests/edge-cases.test.ts | 1061 ++++++++++++++++++++++++++++ tests/event-handlers.test.ts | 720 +++++++++++++++++++ tests/index.test.ts | 999 +++++++++++++++++++++++++++ tests/lock-acquisition.test.ts | 324 +++++++++ tests/lock-manager.test.ts | 810 ++++++++++++++++++++++ tests/multi-session.test.ts | 191 ++++++ tests/performance.test.ts | 263 +++++++ tests/test-utils.ts | 270 ++++++++ tsconfig.json | 16 + 28 files changed, 11796 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/diagnostics.ts create mode 100644 src/edge-cases.ts create mode 100644 src/event-handlers.ts create mode 100644 src/lock-acquisition.ts create mode 100644 src/lock-manager.ts create mode 100644 src/lock-types.ts create mode 100644 src/notifications.ts create mode 100644 src/system-prompt.ts create mode 100644 src/tools.ts create mode 100644 src/user-interaction.ts create mode 100644 tests/config.test.ts create mode 100644 tests/e2e.test.ts create mode 100644 tests/edge-cases.test.ts create mode 100644 tests/event-handlers.test.ts create mode 100644 tests/index.test.ts create mode 100644 tests/lock-acquisition.test.ts create mode 100644 tests/lock-manager.test.ts create mode 100644 tests/multi-session.test.ts create mode 100644 tests/performance.test.ts create mode 100644 tests/test-utils.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61c6d98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.pi-lens/ +package-lock.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d2cdc8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +## What this is + +A Pi coding agent extension that provides a file-claiming mechanism to prevent conflicts when working with files. Not a standalone app — it runs inside Pi's extension host. + +## Type checking + +``` +npm run typecheck # tsc --noEmit +``` + +No build step needed — Pi loads extensions via [jiti](https://github.com/unjs/jiti), which compiles TypeScript at runtime. `index.ts` is the entry point directly. + +## Entry point + +`index.ts` at repo root (not `src/`). Exports a default function receiving `ExtensionAPI`. + +## External dependencies + +The extension imports from Pi SDK packages (not in `package.json` — provided by the host): + +- `@earendil-works/pi-coding-agent` — `ExtensionAPI`, `ExtensionContext`, `ExtensionCommandContext`, etc. +- `@earendil-works/pi-agent-core` — `AgentMessage`, `ToolResultMessage`, etc. +- `@earendil-works/pi-tui` — TUI components (if needed) +- `typebox` — `Type` for tool parameter schema definitions + +The extension has no external npm dependencies — all locking logic is self-contained. + +## Source structure + +- `index.ts` — extension entry, registry implementation, event bus, command registration, config command +- `src/` — all logic modules: + - `config.ts` — typed configuration with defaults, file persistence, validation + - `diagnostics.ts` — LSP-inspired diagnostic system for lock status + - `edge-cases.ts` — crash recovery, race condition prevention, path resolution, lock migration + - `event-handlers.ts` — Pi lifecycle event handlers (tool_call, turn_end, session_shutdown, etc.) + - `lock-acquisition.ts` — lock acquisition, auto-claim, blocking, conflict resolution + - `lock-manager.ts` — atomic file operations, cross-process coordination + - `lock-types.ts` — all TypeScript interfaces and types + - `notifications.ts` — notification system for lock events + - `system-prompt.ts` — system prompt injection for lock claiming protocol + - `tools.ts` — LLM-callable tools (file_claiming_claim, release, list, check) + - `user-interaction.ts` — interactive lock query UI components +- `tests/` — all test files + +## Lock types + +Three lock types with increasing exclusivity: + +- **read** — Shared read access. Multiple tools can hold read claims simultaneously. +- **write** — Exclusive write access. Only one write claim per file path. +- **exclusive** — Full exclusive access. No other claim of any type allowed. + +## Runtime state + +All runtime state lives in memory within the registry (`ClaimRegistry`). Configuration is persisted to `{lockDir}/config.json` (defaults to `~/.pi/agent/locks/config.json`). + +## Tools registered + +The extension registers four LLM-callable tools: + +- `file_claiming_claim` — claim a file with a lock +- `file_claiming_release` — release a claim by ID or path +- `file_claiming_list` — list all active claims +- `file_claiming_check` — check lock status for a specific file + +## Commands registered + +- `/file-claiming-config` — view or update configuration at runtime +- `/file-claiming-locks` — interactive lock management + +## Events emitted + +| Event | Payload | Description | +|--------------------|---------------|---------------------------------| +| `claim:acquired` | `ClaimEvent` | A claim was successfully acquired. | +| `claim:released` | `ClaimEvent` | A claim was released. | +| `claim:conflicted` | `ClaimEvent` | A claim could not be acquired. | +| `claim:expired` | `ClaimEvent` | A claim was auto-expired. | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8fa6dd9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Michael Freno + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49a79fe --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# File Claiming + +A Pi extension that provides a file-claiming mechanism to prevent conflicts when working with files. Tracks read, write, and exclusive claims on file paths. + +```bash +pi install npm:@mikefreno/file-claiming +``` + +## Features + +- **Three lock types**: `read` (shared), `write` (exclusive write), `exclusive` (full exclusive) +- **Auto-claim on edit**: Automatically claims files on first edit/write operation +- **Auto-release at turn end**: Claims are automatically released when your turn ends +- **TTL-based expiry**: Configurable auto-release timeout (default: 5 minutes) +- **Conflict detection**: Clear error messages when locks conflict +- **Diagnostics widget**: Real-time lock status in the diagnostics footer +- **LLM-callable tools**: Tools for agents to claim, release, list, and check locks +- **Interactive commands**: Slash commands for manual lock management +- **Cross-session awareness**: Detects concurrent access across Pi sessions + +## How it works + +### Lock Protocol + +1. **Before editing** a file, check if it has an active claim +2. **Claim the file** with the appropriate lock type +3. **Edit with confidence** — other tools will be blocked from conflicting operations +4. **Release the claim** when done (or let auto-release handle it) + +### Claim Types + +| Type | Behavior | +|------|----------| +| `read` | Shared read access. Multiple read claims can coexist on the same file. | +| `write` | Exclusive write access. Only one write claim per file path. | +| `exclusive` | Full exclusive access. No other claim of any type allowed. | + +### Conflict Resolution + +If you try to claim a file that is locked by another tool, you will receive a conflict notification showing the blocker's claim ID, type, and owner. You can release conflicting claims manually or wait for auto-release. + +## Usage + +### Tools (LLM-callable) + +| Tool | Description | +|------|-------------| +| `file_claiming_claim` | Claim a file with read, write, or exclusive lock | +| `file_claiming_release` | Release a file claim by ID or path | +| `file_claiming_list` | List all active file claims | +| `file_claiming_check` | Check lock status for a specific file | + +### Commands (interactive) + +| Command | Description | +|---------|-------------| +| `/file-claiming-config` | View or update configuration at runtime | +| `/file-claiming-locks` | Interactive lock management | + +## Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `autoReleaseTTL` | number | `300000` (5 min) | Milliseconds before a claim is auto-released | +| `releaseOnTurnEnd` | boolean | `true` | Release all claims at turn end | +| `lockDir` | string | `~/.pi/agent/locks` | Directory for lock persistence files | +| `blockedTools` | string[] | `["edit", "write"]` | Tools blocked while locks exist | +| `showDiagnostics` | boolean | `true` | Show lock status in diagnostics footer | + +Update config at runtime: + +``` +/file-claiming-config autoReleaseTTL=600000 +/file-claiming-config releaseOnTurnEnd=false +/file-claiming-config showDiagnostics=false +``` + +## Events + +The extension emits events on the shared extension bus: + +| Event | Description | +|-------|-------------| +| `claim:acquired` | A claim was successfully acquired | +| `claim:released` | A claim was released | +| `claim:conflicted` | A claim could not be acquired | +| `claim:expired` | A claim was auto-expired | diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..70a40af --- /dev/null +++ b/index.ts @@ -0,0 +1,678 @@ +/** + * index.ts — File Claiming Extension entry point. + * + * This extension provides a file-claiming mechanism that tracks active + * claims (read / write / exclusive) on file paths so tools and extensions + * can detect conflicts before operating on files. + * + * ## Quick start + * + * ```ts + * import type { FileClaim, ClaimOwner } from "./src/lock-types"; + * ``` + * + * ## Events + * + * The extension emits the following events on the shared extension bus + * (`pi.events`): + * + * | Event | Payload | Description | + * |--------------------|---------------|---------------------------------| + * | `claim:acquired` | `ClaimEvent` | A claim was successfully acquired. | + * | `claim:released` | `ClaimEvent` | A claim was released. | + * | `claim:conflicted` | `ClaimEvent` | A claim could not be acquired. | + * | `claim:expired` | `ClaimEvent` | A claim was auto-expired. | + * + * ## Configuration + * + * Options are managed through the `config.ts` module and can be + * persisted to `{lockDir}/config.json`. Use `/file-claiming-config` + * to inspect or update values at runtime. + * + * @module file-claiming + */ + +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { + ClaimConflict, + ClaimEvent, + ClaimEventType, + ClaimOwner, + ClaimRegistry, + ClaimResult, + FileClaim, + LockEntry, + PathLockType, +} from "./src/lock-types"; +import { + type FileClaimingConfig, + getConfig, + setConfig, + loadConfigFromFile, + saveConfigToFile, + getConfigFilePath, + createDefaultConfig, +} from "./src/config"; +import { + injectLockClaimingIntoPrompt, + buildLockClaimingGuidelines, + buildLockClaimingToolSnippets, +} from "./src/system-prompt"; +import { + buildDiagnosticCollection, + formatDiagnostics, +} from "./src/diagnostics"; +import { registerLockTools } from "./src/tools"; +import { + createLockNotificationHandler, + claimEventToNotification, +} from "./src/notifications"; +import { updateLockStatus, persistLockState } from "./src/user-interaction"; +import { + createLockCommandHandler, + createNotifyCommandHandler, +} from "./src/user-interaction"; +import { + acquireLock, + autoClaim, + isFileLocked, + getLockInfo, + isMutationTool, + shouldAutoClaim, + handleToolLock, + buildBlockingError, + checkToolBlocking, + cleanupExpiredLocks, + type AcquireLockOptions, + type LockAcquisitionResult, +} from "./src/lock-acquisition"; +import { registerEventHandlers } from "./src/event-handlers"; + +// --------------------------------------------------------------------------- +// In-memory registry implementation +// --------------------------------------------------------------------------- + +// We use plain objects as maps because ES Map does not serialize well and +// we may persist registry snapshots via pi.appendEntry later. + +/** Claims indexed by ID. */ +let claimsById: Record = {}; + +/** Lock entries indexed by path. */ +let locksByPath: Record = {}; + +/** Reference to the interval handle for the expiry sweeper. */ +let sweepTimer: ReturnType | undefined; + +/** Shared event bus (set during init). */ +let emitEvent: + | ((type: ClaimEventType, claim: FileClaim, conflict?: ClaimConflict) => void) + | undefined; + +// --------------------------------------------------------------------------- +// Conflict detection logic +// --------------------------------------------------------------------------- + +/** + * Returns whether a proposed lock type is compatible with the set of locks + * already held on a path, excluding locks owned by `owner`. + */ +function isCompatible( + existing: LockEntry[], + requested: PathLockType, + owner: ClaimOwner, +): { ok: boolean; blockers: LockEntry[] } { + const blockers: LockEntry[] = []; + + for (const entry of existing) { + // Skip locks held by the same owner — they already hold it. + if (entry.owner.type === owner.type && entry.owner.id === owner.id) { + continue; + } + + // Exclusive blocks everything. + if (entry.lockType === "exclusive" || requested === "exclusive") { + blockers.push(entry); + continue; + } + + // Write blocks write and is blocked by write. + if (entry.lockType === "write" || requested === "write") { + blockers.push(entry); + } + + // Read is always compatible with other reads. + } + + return { ok: blockers.length === 0, blockers }; +} + +// --------------------------------------------------------------------------- +// Registry API +// --------------------------------------------------------------------------- + +const registry: ClaimRegistry = { + get claims(): Record { + return claimsById; + }, + + get locks(): Record { + return locksByPath; + }, + + getActiveClaims(path: string): FileClaim[] { + return Object.values(claimsById).filter( + (c) => c.path === path && c.status === "active", + ); + }, + + getLocks(path: string): LockEntry[] { + return locksByPath[path] ?? []; + }, + + checkConflict( + path: string, + lockType: PathLockType, + owner: ClaimOwner, + ): ClaimConflict | undefined { + const existing = locksByPath[path] ?? []; + const { ok, blockers } = isCompatible(existing, lockType, owner); + if (ok) return undefined; + + const blockingClaims = blockers + .map((b) => claimsById[b.claimId]) + .filter(Boolean); + + return { + path, + severity: lockType === "read" ? "info" : "warning", + blockedClaim: { + id: "", + path, + lockType, + status: "conflicted", + owner, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + blockingClaims, + message: `Cannot acquire "${lockType}" lock on "${path}": blocked by ${blockers.length} existing lock(s)`, + }; + }, + + acquire(claim: FileClaim): ClaimResult { + const existing = locksByPath[claim.path] ?? []; + const { ok, blockers } = isCompatible( + existing, + claim.lockType, + claim.owner, + ); + + if (!ok) { + const blockingClaims = blockers + .map((b) => claimsById[b.claimId]) + .filter(Boolean); + + const conflict: ClaimConflict = { + path: claim.path, + severity: claim.lockType === "read" ? "info" : "warning", + blockedClaim: claim, + blockingClaims, + message: `Cannot acquire "${claim.lockType}" lock on "${claim.path}": blocked by ${blockers.length} existing lock(s)`, + }; + + emitEvent?.("claim:conflicted", claim, conflict); + return { success: false, claim, conflict }; + } + + // Store the claim + const now = new Date().toISOString(); + const stored: FileClaim = { + ...claim, + status: "active", + createdAt: claim.createdAt || now, + updatedAt: now, + }; + claimsById[stored.id] = stored; + + // Add lock entry + const entry: LockEntry = { + path: stored.path, + lockType: stored.lockType, + claimId: stored.id, + owner: stored.owner, + acquiredAt: now, + }; + locksByPath[stored.path] = [...(locksByPath[stored.path] ?? []), entry]; + + emitEvent?.("claim:acquired", stored); + return { success: true, claim: stored }; + }, + + release(claimId: string): boolean { + const claim = claimsById[claimId]; + if (!claim) return false; + + claim.status = "released"; + claim.updatedAt = new Date().toISOString(); + + // Remove lock entries for this claim + const pathEntries = locksByPath[claim.path] ?? []; + locksByPath[claim.path] = pathEntries.filter((e) => e.claimId !== claimId); + if ((locksByPath[claim.path] ?? []).length === 0) { + delete locksByPath[claim.path]; + } + + emitEvent?.("claim:released", claim); + return true; + }, + + releaseAllByOwner(owner: ClaimOwner): void { + const toRelease = Object.values(claimsById).filter( + (c) => + c.owner.type === owner.type && + c.owner.id === owner.id && + c.status === "active", + ); + for (const claim of toRelease) { + registry.release(claim.id); + } + }, +}; + +// --------------------------------------------------------------------------- +// Expiry sweeper +// --------------------------------------------------------------------------- + +function sweepExpiredClaims(): void { + const now = Date.now(); + const toExpire = Object.values(claimsById).filter((c) => { + if (c.status !== "active") return false; + if (!c.expiresAt) return false; + return new Date(c.expiresAt).getTime() <= now; + }); + + for (const claim of toExpire) { + claim.status = "expired"; + claim.updatedAt = new Date().toISOString(); + + // Remove lock entries + const pathEntries = locksByPath[claim.path] ?? []; + locksByPath[claim.path] = pathEntries.filter((e) => e.claimId !== claim.id); + if ((locksByPath[claim.path] ?? []).length === 0) { + delete locksByPath[claim.path]; + } + + emitEvent?.("claim:expired", claim); + } +} + +// --------------------------------------------------------------------------- +// Config-driven helpers +// --------------------------------------------------------------------------- + +/** + * Build the current agent-owner context from a turn event or tool event context. + * Used by `releaseOnTurnEnd` and `blockedTools` logic. + */ +function ownerFromContext(ctx: ExtensionContext): ClaimOwner | undefined { + // The agent is the primary owner during tool execution. + return { + type: "agent", + id: "main", + sessionId: ctx.sessionManager.getSessionFile() ?? undefined, + }; +} + +/** + * Release all agent-held claims when `releaseOnTurnEnd` is enabled. + */ +function releaseAgentClaimsOnTurnEnd(ctx: ExtensionContext): void { + const config = getConfig(); + if (!config.releaseOnTurnEnd) return; + + const owner = ownerFromContext(ctx); + if (owner) { + registry.releaseAllByOwner(owner); + } +} + +/** + * Check whether a given tool name should be blocked based on `blockedTools` + * and the current lock state. + */ +function isToolBlocked(toolName: string): boolean { + const config = getConfig(); + if (config.blockedTools.length === 0) return false; + if (!config.blockedTools.includes(toolName)) return false; + + // Check if there are any active claims + return Object.values(claimsById).some((c) => c.status === "active"); +} + +// --------------------------------------------------------------------------- +// Lock acquisition exports +// --------------------------------------------------------------------------- + +/** + * Re-export lock acquisition functions from the lock-acquisition module + * for use by other extensions and tools. + */ +export { + acquireLock, + autoClaim, + isFileLocked, + getLockInfo, + isMutationTool, + shouldAutoClaim, + handleToolLock, + buildBlockingError, + checkToolBlocking, + cleanupExpiredLocks, + type AcquireLockOptions, + type LockAcquisitionResult, +} from "./src/lock-acquisition"; + +// --------------------------------------------------------------------------- +// Reset for testability +// --------------------------------------------------------------------------- + +/** + * Reset all in-memory state. Primarily intended for testing. + */ +export function resetRegistry(): void { + claimsById = {}; + locksByPath = {}; +} + +/** + * Returns a reference to the in-memory {@link ClaimRegistry} for direct + * use by other extensions or tools. + */ +export function getClaimRegistry(): ClaimRegistry { + return registry; +} + +// --------------------------------------------------------------------------- +// Extension factory +// --------------------------------------------------------------------------- + +/** + * Pi extension factory for the File Claiming extension. + * + * Handles: + * - Configuration loading and command + * - Sweeper interval + * - Turn-end claim release + * - Tool blocking when claims are active + * - Diagnostics widget + * + * @param pi - The {@link ExtensionAPI} instance provided by Pi. + */ +export default function (pi: ExtensionAPI): void { + // ----------------------------------------------------------------------- + // Event emitter helper + // ----------------------------------------------------------------------- + + emitEvent = ( + type: ClaimEventType, + claim: FileClaim, + conflict?: ClaimConflict, + ) => { + const event: ClaimEvent = { + type, + claim, + conflict, + timestamp: new Date().toISOString(), + }; + pi.events.emit(type, event); + }; + + // ----------------------------------------------------------------------- + // Session lifecycle + // ----------------------------------------------------------------------- + + pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { + // 1. Load persisted configuration + await loadConfigFromFile(); + const config = getConfig(); + + // 2. Start the expiry sweeper (idempotent) + if (!sweepTimer) { + sweepTimer = setInterval(sweepExpiredClaims, 5_000); + } + + // 3. Set up notification handler + const notificationHandler = createLockNotificationHandler( + ctx.ui, + pi.events, + ); + + // 4. Show footer status if enabled + if (config.showDiagnostics && ctx.hasUI) { + ctx.ui.setWidget("file-claiming-diagnostics", undefined); + ctx.ui.setStatus("file-claiming", "Claims: 0 active"); + } + + // 5. Persist initial lock state + persistLockState(pi); + + // 6. Register dedicated event handlers from event-handlers module + registerEventHandlers(pi, ctx); + }); + + pi.on("session_shutdown", async () => { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = undefined; + } + + // Release all claims + registry.releaseAllByOwner({ type: "agent", id: "main" }); + cleanupExpiredLocks(); + }); + + // ----------------------------------------------------------------------- + // Turn-end: release agent claims + // ----------------------------------------------------------------------- + + pi.on("turn_end", async (_event: unknown, ctx: ExtensionContext) => { + releaseAgentClaimsOnTurnEnd(ctx); + updateDiagnosticsWidget(ctx); + updateLockStatus(ctx.ui, registry); + }); + + function updateDiagnosticsWidget(ctx: ExtensionContext): void { + const config = getConfig(); + if (!config.showDiagnostics || !ctx.hasUI) return; + + const activeCount = Object.values(registry.claims).filter( + (c) => c.status === "active", + ).length; + ctx.ui.setStatus("file-claiming", `Claims: ${activeCount} active`); + } + + // ----------------------------------------------------------------------- + // /file-claiming-config command + // ----------------------------------------------------------------------- + + pi.registerCommand("file-claiming-config", { + description: + "View or update File Claiming extension configuration. " + + "Usage: /file-claiming-config [key=value ...] — omitting arguments shows current config. " + + "Keys: autoReleaseTTL, releaseOnTurnEnd, lockDir, blockedTools, showDiagnostics.", + + handler: async (args: string, ctx: ExtensionCommandContext) => { + const trimmed = args.trim(); + + // No arguments → display current config + if (!trimmed) { + const cfg = getConfig(); + const lines = [ + "── File Claiming Configuration ──", + ` autoReleaseTTL: ${cfg.autoReleaseTTL} ms ${cfg.autoReleaseTTL === createDefaultConfig().autoReleaseTTL ? "(default)" : ""}`, + ` releaseOnTurnEnd: ${cfg.releaseOnTurnEnd} ${cfg.releaseOnTurnEnd === createDefaultConfig().releaseOnTurnEnd ? "(default)" : ""}`, + ` lockDir: ${cfg.lockDir}`, + ` blockedTools: [${cfg.blockedTools.join(", ")}] ${jsonEqual(cfg.blockedTools, createDefaultConfig().blockedTools) ? "(default)" : ""}`, + ` showDiagnostics: ${cfg.showDiagnostics} ${cfg.showDiagnostics === createDefaultConfig().showDiagnostics ? "(default)" : ""}`, + ` config file: ${getConfigFilePath(cfg.lockDir)}`, + "", + ` Active claims: ${Object.values(claimsById).filter((c) => c.status === "active").length}`, + "──────────────────────────────", + "", + " Set values: /file-claiming-config key=value [key=value ...]", + " Examples:", + " /file-claiming-config autoReleaseTTL=600000", + " /file-claiming-config releaseOnTurnEnd=false", + ' /file-claiming-config blockedTools=["edit","write","bash"]', + " /file-claiming-config showDiagnostics=false", + ]; + ctx.ui.notify(lines.join("\n"), "info"); + return; + } + + // Parse key=value pairs + const pairs = trimmed.split(/\s+/); + const updates: Record = {}; + + for (const pair of pairs) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) { + ctx.ui.notify(`Invalid syntax: "${pair}" — use key=value`, "error"); + return; + } + const key = pair.slice(0, eqIdx); + const rawValue = pair.slice(eqIdx + 1); + + // Coerce value to the expected type + updates[key] = coerceConfigValue(key, rawValue); + } + + const result = setConfig(updates as Partial); + if (!result.valid) { + ctx.ui.notify( + `Validation errors:\n${result.errors.join("\n")}`, + "error", + ); + return; + } + + // Persist to disk + try { + await saveConfigToFile(); + ctx.ui.notify("Configuration updated and saved.", "info"); + } catch (err) { + ctx.ui.notify( + `Configuration updated in memory but failed to persist: ${err}`, + "warning", + ); + } + + // Restart sweeper with new config if autoReleaseTTL changed + updateDiagnosticsWidget(ctx); + }, + }); + + // ----------------------------------------------------------------------- + // Register /file-claiming-locks command for interactive lock management + // ----------------------------------------------------------------------- + + const lockCommandHandler = createLockCommandHandler(() => registry); + pi.registerCommand("file-claiming-locks", { + description: + "Interactive lock management. Use /file-claiming-locks for interactive mode, " + + "or /file-claiming-locks [list|check|release|reset] [path] [id] for direct commands.", + handler: lockCommandHandler, + }); + + // ----------------------------------------------------------------------- + // Context event: inject diagnostic messages + // ----------------------------------------------------------------------- + + pi.on( + "context", + async ( + event: { + messages: import("@earendil-works/pi-agent-core").AgentMessage[]; + }, + ctx: ExtensionContext, + ) => { + const config = getConfig(); + if (!config.showDiagnostics) return {}; + + const activeClaims = Object.values(claimsById).filter( + (c) => c.status === "active", + ); + + if (activeClaims.length === 0) return {}; + + const collection = buildDiagnosticCollection(registry); + const diagnosticLines: string[] = [ + "## File Claiming Lock Status", + "", + `Active claims: ${collection.count}`, + "", + ]; + + for (const [uri, items] of collection.diagnostics) { + for (const item of items) { + diagnosticLines.push( + `- **${item.lockType} lock** on \`${item.uri}\` by ${item.tool ?? item.source} — ${item.message}`, + ); + } + } + + const diagnosticMessage: import("@earendil-works/pi-agent-core").AgentMessage = + { + role: "user", + content: [{ type: "text", text: diagnosticLines.join("\n") }], + id: `file-claiming-diagnostics-${Date.now()}`, + timestamp: Date.now(), + }; + + return { messages: [...event.messages, diagnosticMessage] }; + }, + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Deep-compare two values for equality (simple JSON comparison). + */ +function jsonEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Coerce a command-line string value to the expected type for a given config key. + */ +function coerceConfigValue(key: string, raw: string): unknown { + // Booleans + if (raw === "true") return true; + if (raw === "false") return false; + + // Numbers + if (/^-?\d+(\.\d+)?$/.test(raw)) { + const num = Number(raw); + if (Number.isFinite(num)) return num; + } + + // Arrays — parse as JSON + if (raw.startsWith("[") && raw.endsWith("]")) { + try { + return JSON.parse(raw); + } catch { + // Fall through to return raw string + } + } + + // Everything else is a string + return raw; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22ff898 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "@mikefreno/file-claiming", + "version": "0.1.0", + "description": "Pi extension for claiming files with read/write/exclusive locks to prevent edit conflicts", + "keywords": [ + "pi-package", + "pi-extension", + "file-locking", + "lock", + "claim", + "conflict-prevention" + ], + "author": "Michael Freno", + "license": "MIT", + "homepage": "https://github.com/mikefreno/file-claiming", + "repository": { + "type": "git", + "url": "git+https://github.com/mikefreno/file-claiming.git" + }, + "bugs": { + "url": "https://github.com/mikefreno/file-claiming/issues" + }, + "files": [ + "index.ts", + "src/", + "README.md", + "LICENSE" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "prepublishOnly": "tsc --noEmit", + "test": "bun test", + "test:all": "bun test", + "test:unit": "bun test tests/config.test.ts tests/lock-manager.test.ts tests/lock-acquisition.test.ts", + "test:integration": "bun test tests/multi-session.test.ts tests/e2e.test.ts tests/event-handlers.test.ts", + "test:edge-cases": "bun test tests/edge-cases.test.ts", + "test:performance": "bun test tests/performance.test.ts" + }, + "engines": { + "bun": ">=1.1.0" + }, + "pi": { + "extensions": ["./index.ts"] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-agent-core": "*", + "@earendil-works/pi-tui": "*" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.3.0" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..23820a2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,337 @@ +/** + * config.ts — Configuration management for the File Claiming extension. + * + * Provides typed configuration with sensible defaults, file-based persistence, + * input validation, and thread-safe runtime access. + * + * ## Quick start + * + * ```ts + * import { getConfig, setConfig, loadConfigFromFile } from "./config"; + * + * // Load persisted config + * await loadConfigFromFile(); + * + * // Read config values + * const { autoReleaseTTL, releaseOnTurnEnd, lockDir, blockedTools } = getConfig(); + * + * // Update at runtime + * const result = setConfig({ autoReleaseTTL: 600_000 }); + * if (!result.valid) console.error(result.errors); + * ``` + * + * ## Configuration options + * + * | Option | Type | Default | Description | + * |-------------------|------------|----------------------------|------------------------------------------------| + * | `autoReleaseTTL` | `number` | `300000` (5 min) | Milliseconds before a claim is auto-released. | + * | `releaseOnTurnEnd`| `boolean` | `true` | Release all claims for the agent on turn end. | + * | `lockDir` | `string` | `~/.pi/agent/locks` | Directory for lock persistence files. | + * | `blockedTools` | `string[]` | `["edit", "write"]` | Tools blocked from running while locks exist. | + * | `showDiagnostics` | `boolean` | `true` | Show lock status in diagnostics footer. | + * + * @module file-claiming/config + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { homedir } from "node:os"; + +// --------------------------------------------------------------------------- +// Configuration interface +// --------------------------------------------------------------------------- + +/** + * All configurable settings for the File Claiming extension. + * + * Every option has a safe default so the extension works immediately + * without any configuration file. + */ +export interface FileClaimingConfig { + /** + * Milliseconds before an idle claim is auto-released. + * A value of `0` disables auto-release. + * + * @default 300000 (5 minutes) + */ + autoReleaseTTL: number; + + /** + * When `true`, all claims held by the current agent turn owner are + * automatically released at the end of each turn (`turn_end` event). + * + * @default true + */ + releaseOnTurnEnd: boolean; + + /** + * Absolute path to the directory used for lock persistence files + * (future use — currently locks are in-memory only). + * + * @default ~/.pi/agent/locks + */ + lockDir: string; + + /** + * List of tool names that are blocked from execution while any file + * in the project has an active claim. Useful to prevent conflicting + * edits while the agent holds a write or exclusive lock. + * + * @default ["edit", "write"] + */ + blockedTools: string[]; + + /** + * When `true`, a lock-status widget is shown in the TUI footer or + * diagnostics area so the user can see which files are currently claimed. + * + * @default true + */ + showDiagnostics: boolean; +} + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +/** + * Factory for the default configuration object. + * + * Using a factory (rather than a frozen constant) ensures each consumer + * gets a mutable copy and that dynamic defaults like `lockDir` are + * computed every time. + */ +export function createDefaultConfig(): FileClaimingConfig { + return { + autoReleaseTTL: 300_000, + releaseOnTurnEnd: true, + lockDir: join(homedir(), ".pi", "agent", "locks"), + blockedTools: ["edit", "write"], + showDiagnostics: true, + }; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Result of a configuration validation check. + */ +export interface ConfigValidationResult { + /** `true` when no validation errors were found. */ + valid: boolean; + /** Human-readable error messages describing each invalid field. */ + errors: string[]; +} + +/** + * Validate a partial or complete configuration object. + * + * Returns all validation errors so callers can present a complete + * picture rather than failing on the first mistake. + * + * @param config - Any subset of `FileClaimingConfig` fields to validate. + */ +export function validateConfig(config: Partial): ConfigValidationResult { + const errors: string[] = []; + + if (config.autoReleaseTTL !== undefined) { + if (typeof config.autoReleaseTTL !== "number" || !Number.isFinite(config.autoReleaseTTL)) { + errors.push( + `autoReleaseTTL must be a finite number, got ${typeof config.autoReleaseTTL} ` + + `(${JSON.stringify(config.autoReleaseTTL)})`, + ); + } else if (config.autoReleaseTTL < 0) { + errors.push(`autoReleaseTTL must be >= 0, got ${config.autoReleaseTTL}`); + } + // 0 is valid (disables auto-release) + } + + if (config.releaseOnTurnEnd !== undefined && typeof config.releaseOnTurnEnd !== "boolean") { + errors.push( + `releaseOnTurnEnd must be a boolean, got ${typeof config.releaseOnTurnEnd} ` + + `(${JSON.stringify(config.releaseOnTurnEnd)})`, + ); + } + + if (config.lockDir !== undefined) { + if (typeof config.lockDir !== "string") { + errors.push( + `lockDir must be a string, got ${typeof config.lockDir} ` + + `(${JSON.stringify(config.lockDir)})`, + ); + } else if (config.lockDir.trim().length === 0) { + errors.push(`lockDir must not be empty`); + } + } + + if (config.blockedTools !== undefined) { + if (!Array.isArray(config.blockedTools)) { + errors.push( + `blockedTools must be an array of strings, got ${typeof config.blockedTools}`, + ); + } else if (!config.blockedTools.every((t) => typeof t === "string")) { + const bad = config.blockedTools.find((t) => typeof t !== "string"); + errors.push( + `blockedTools must only contain strings, got ${typeof bad} (${JSON.stringify(bad)})`, + ); + } + } + + if (config.showDiagnostics !== undefined && typeof config.showDiagnostics !== "boolean") { + errors.push( + `showDiagnostics must be a boolean, got ${typeof config.showDiagnostics} ` + + `(${JSON.stringify(config.showDiagnostics)})`, + ); + } + + return { valid: errors.length === 0, errors }; +} + +// --------------------------------------------------------------------------- +// Config file path resolution +// --------------------------------------------------------------------------- + +/** + * File name used for persisted configuration. + */ +const CONFIG_FILE_NAME = "config.json"; + +/** + * Resolve the absolute path to the config file, deriving it from the + * currently configured `lockDir` or the default. + * + * @param lockDir - Optional explicit lock directory override. + */ +export function getConfigFilePath(lockDir?: string): string { + const dir = lockDir ?? createDefaultConfig().lockDir; + return join(dir, CONFIG_FILE_NAME); +} + +// --------------------------------------------------------------------------- +// Runtime configuration store +// --------------------------------------------------------------------------- + +/** + * Internal mutable configuration state. + * + * Writes go through {@link setConfig} for validation; reads go through + * {@link getConfig} for immutability guarantees. + */ +let currentConfig: FileClaimingConfig = createDefaultConfig(); + +/** + * Return a snapshot of the current runtime configuration. + * + * Every call returns a fresh copy so callers cannot accidentally mutate + * shared state. + */ +export function getConfig(): Readonly { + return { ...currentConfig }; +} + +/** + * Apply a partial configuration update at runtime. + * + * Merges the provided fields into the current configuration after + * validation. Returns the validation result; on failure the config + * is **not** modified. + * + * @param partial - One or more fields to update. + */ +export function setConfig(partial: Partial): ConfigValidationResult { + const validation = validateConfig(partial); + if (!validation.valid) { + return validation; + } + + currentConfig = { ...currentConfig, ...partial }; + return validation; +} + +// --------------------------------------------------------------------------- +// File persistence +// --------------------------------------------------------------------------- + +/** + * Load configuration from a JSON file on disk, merging it on top of + * the current in-memory values. + * + * If the file does not exist the current config is left unchanged + * (defaults are used). If the file exists but contains invalid values + * the valid fields are still applied and warnings are printed. + * + * @param configFilePath - Optional explicit path. Defaults to + * `{lockDir}/config.json` using the **current** `lockDir`. + */ +export async function loadConfigFromFile(configFilePath?: string): Promise { + const filePath = configFilePath ?? getConfigFilePath(currentConfig.lockDir); + + if (!existsSync(filePath)) { + return { ...currentConfig }; + } + + try { + const raw = await readFile(filePath, "utf-8"); + const parsed: Record = JSON.parse(raw); + + // Validate all fields but apply even partially-valid payloads + // so that corrupted files still preserve whatever is correct. + const validation = validateConfig(parsed as Partial); + if (validation.valid) { + currentConfig = { ...currentConfig, ...(parsed as Partial) }; + } else { + // Apply only the fields that pass validation + const safe: Partial = {}; + for (const key of Object.keys(parsed) as Array) { + const value = parsed[key]; + const fieldPartial = { [key]: value } as unknown as Partial; + const fieldVal = validateConfig(fieldPartial); + if (fieldVal.valid) { + (safe as Record)[key] = value; + } + } + currentConfig = { ...currentConfig, ...safe }; + console.warn( + `[file-claiming] Config file ${filePath} has validation errors:`, + validation.errors.join("; "), + ); + } + } catch (err) { + console.warn(`[file-claiming] Failed to read config from ${filePath}:`, err); + } + + return { ...currentConfig }; +} + +/** + * Persist the current configuration to a JSON file on disk. + * + * Creates the parent directory if it does not exist. + * + * @param configFilePath - Optional explicit path. Defaults to + * `{lockDir}/config.json` using the **current** `lockDir`. + */ +export async function saveConfigToFile(configFilePath?: string): Promise { + const filePath = configFilePath ?? getConfigFilePath(currentConfig.lockDir); + const dir = resolve(dirname(filePath)); + + await mkdir(dir, { recursive: true }); + await writeFile(filePath, JSON.stringify(currentConfig, null, 2), "utf-8"); +} + +// --------------------------------------------------------------------------- +// Testing helpers +// --------------------------------------------------------------------------- + +/** + * Reset the in-memory configuration to defaults. + * + * Intended for testing — not exposed through the public API during + * normal operation. + */ +export function resetConfig(): void { + currentConfig = createDefaultConfig(); +} diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..3cde776 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,308 @@ +/** + * diagnostics.ts — Diagnostic message system for lock status. + * + * Provides a diagnostics system similar to LSP diagnostics, with diagnostic + * items that can be displayed to the user. Each diagnostic has a severity + * level, source, range, and message. + * + * @module file-claiming/diagnostics + */ + +import type { + ClaimRegistry, + ClaimOwner, + FileClaim, + LockEntry, + PathLockType, +} from "./lock-types"; +import { getConfig } from "./config"; + +// --------------------------------------------------------------------------- +// Diagnostic types (LSP-inspired) +// --------------------------------------------------------------------------- + +/** + * Severity level for diagnostic messages. + * Mirrors LSP DiagnosticSeverity for familiarity. + */ +export type DiagnosticSeverity = "info" | "warning" | "error"; + +/** + * A single diagnostic item describing a lock status. + */ +export interface DiagnosticItem { + /** The file path this diagnostic applies to. */ + uri: string; + /** Severity level. */ + severity: DiagnosticSeverity; + /** The diagnostic source (e.g. "file-claiming"). */ + source: string; + /** Machine-readable code for programmatic consumption. */ + code: string; + /** Human-readable message. */ + message: string; + /** Optional tool name that holds the lock. */ + tool?: string; + /** Optional auto-release time (ISO-8601). */ + autoReleaseAt?: string; + /** Optional lock type being diagnosed. */ + lockType?: PathLockType; + /** ISO-8601 timestamp when the diagnostic was created. */ + timestamp: string; +} + +/** + * Diagnostics grouped by file path. + */ +export interface DiagnosticCollection { + /** Diagnostics keyed by file path. */ + diagnostics: Map; + /** Total number of diagnostics. */ + count: number; + /** Number of diagnostics per severity. */ + bySeverity: Record; +} + +// --------------------------------------------------------------------------- +// Diagnostic builders +// --------------------------------------------------------------------------- + +/** + * Build a diagnostic item for a file claim. + */ +export function claimToDiagnostic( + claim: FileClaim, + registry: ClaimRegistry, +): DiagnosticItem { + const config = getConfig(); + const severity = claim.lockType === "read" ? "info" : "warning"; + const lockEntries = registry.getLocks(claim.path); + const activeLocks = lockEntries.filter((e) => e.claimId === claim.id); + + // Calculate auto-release time + const expiresAt = + claim.expiresAt ?? + new Date(Date.now() + config.autoReleaseTTL).toISOString(); + + return { + uri: claim.path, + severity, + source: "file-claiming", + code: `LOCK_${claim.lockType.toUpperCase()}`, + message: `${claim.lockType} lock on "${claim.path}" by ${claim.owner.type} "${claim.owner.id}"${claim.reason ? ` — ${claim.reason}` : ""}`, + tool: claim.owner.type === "tool" ? claim.owner.id : undefined, + autoReleaseAt: expiresAt, + lockType: claim.lockType, + timestamp: claim.createdAt, + }; +} + +/** + * Build a diagnostic item for a lock conflict. + */ +export function conflictToDiagnostic( + path: string, + lockType: PathLockType, + blockers: LockEntry[], +): DiagnosticItem { + const blockerNames = blockers + .map((b) => `${b.owner.type}(${b.owner.id})`) + .join(", "); + + return { + uri: path, + severity: "error", + source: "file-claiming", + code: "LOCK_CONFLICT", + message: `Cannot acquire "${lockType}" lock on "${path}": blocked by ${blockers.length} lock(s) — ${blockerNames}`, + timestamp: new Date().toISOString(), + lockType, + }; +} + +// --------------------------------------------------------------------------- +// Diagnostic collection +// --------------------------------------------------------------------------- + +/** + * Create a diagnostic collection from the current registry state. + */ +export function buildDiagnosticCollection( + registry: ClaimRegistry, +): DiagnosticCollection { + const claims = registry.claims; + const diagnostics = new Map(); + + for (const claim of Object.values(claims)) { + if (claim.status !== "active") continue; + + const diag = claimToDiagnostic(claim, registry); + const existing = diagnostics.get(diag.uri) ?? []; + existing.push(diag); + diagnostics.set(diag.uri, existing); + } + + return { + diagnostics, + count: Array.from(diagnostics.values()).flat().length, + bySeverity: { + info: Array.from(diagnostics.values()) + .flat() + .filter((d) => d.severity === "info").length, + warning: Array.from(diagnostics.values()) + .flat() + .filter((d) => d.severity === "warning").length, + error: Array.from(diagnostics.values()) + .flat() + .filter((d) => d.severity === "error").length, + }, + }; +} + +/** + * Format a diagnostic collection as a human-readable string. + */ +export function formatDiagnostics(diagnostics: DiagnosticCollection): string { + const lines: string[] = []; + lines.push(`🔒 File Claims (${diagnostics.count} active)`); + lines.push( + ` Info: ${diagnostics.bySeverity.info} | Warning: ${diagnostics.bySeverity.warning} | Error: ${diagnostics.bySeverity.error}`, + ); + lines.push(""); + + for (const [uri, items] of diagnostics.diagnostics) { + const first = items[0]; + const icon = + first?.severity === "error" + ? "❌" + : first?.severity === "warning" + ? "⚠️" + : "ℹ️"; + lines.push(` ${icon} ${uri}`); + + for (const item of items) { + const autoRelease = item.autoReleaseAt + ? ` (auto-release: ${formatRelativeTime(item.autoReleaseAt)})` + : ""; + lines.push(` - ${item.message}${autoRelease}`); + } + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Diagnostics widget content +// --------------------------------------------------------------------------- + +/** + * Build the widget content for the diagnostics widget. + */ +export function getDiagnosticsWidgetContent(registry: ClaimRegistry): string[] { + const config = getConfig(); + const collection = buildDiagnosticCollection(registry); + const lines: string[] = []; + + lines.push(`Claims: ${collection.count} active`); + + if (collection.count > 0) { + lines.push(""); + lines.push("Active locks:"); + for (const [uri, items] of collection.diagnostics) { + const icon = items[0]?.severity === "error" ? "❌" : "ℹ️"; + lines.push(` ${icon} ${uri} (${items[0]?.lockType})`); + } + } + + return lines; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** + * Format an ISO-8601 timestamp as a relative time string. + */ +export function formatRelativeTime(isoString: string): string { + const target = new Date(isoString).getTime(); + const now = Date.now(); + const diffMs = target - now; + + if (diffMs <= 0) return "now"; + + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +/** + * Get the currently locked files as a list of paths. + */ +export function getLockedFiles(registry: ClaimRegistry): string[] { + const locks = registry.locks; + return Object.keys(locks); +} + +/** + * Check if a specific file path has an active claim. + */ +export function hasActiveClaim(registry: ClaimRegistry, path: string): boolean { + return registry.getActiveClaims(path).length > 0; +} + +/** + * Get claim information for a specific file path. + */ +export function getClaimsForPath( + registry: ClaimRegistry, + path: string, +): FileClaim[] { + return registry.getActiveClaims(path); +} + +// --------------------------------------------------------------------------- +// Diagnostic events +// --------------------------------------------------------------------------- + +/** + * Events emitted by the diagnostic system. + */ +export type DiagnosticEventType = + | "diagnostic:added" + | "diagnostic:removed" + | "diagnostic:updated" + | "diagnostics:refreshed"; + +/** + * Payload for diagnostic events. + */ +export interface DiagnosticEvent { + type: DiagnosticEventType; + uri: string; + diagnostic?: DiagnosticItem; + count?: number; + timestamp: string; +} + +/** + * Create a diagnostic event for a diagnostic item. + */ +export function createDiagnosticEvent( + type: DiagnosticEventType, + uri: string, + diagnostic?: DiagnosticItem, +): DiagnosticEvent { + return { + type, + uri, + diagnostic, + count: diagnostic ? 1 : undefined, + timestamp: new Date().toISOString(), + }; +} diff --git a/src/edge-cases.ts b/src/edge-cases.ts new file mode 100644 index 0000000..fc88135 --- /dev/null +++ b/src/edge-cases.ts @@ -0,0 +1,1179 @@ +/** + * edge-cases.ts — Comprehensive edge case handling for the file claiming + * extension. + * + * This module handles: + * 1. **Crash recovery** — detecting and cleaning stale locks left behind + * by crashed processes + * 2. **Race condition prevention** — atomic operations to prevent concurrent + * access issues + * 3. **Path resolution** — canonical paths, symlink resolution, relative path + * normalisation + * 4. **New file locking** — locking files that don't exist yet on disk + * 5. **Lock migration** — moving a claim when a file is renamed or moved + * 6. **Comprehensive logging** — structured logging for debugging edge cases + * + * ## Usage + * + * ```ts + * import { + * recoverStaleLocks, + * preventRaceCondition, + * resolvePath, + * lockNewFile, + * migrateLock, + * } from "./edge-cases"; + * ``` + * + * @module file-claiming/edge-cases + */ + +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { + ClaimOwner, + FileClaim, + LockEntry, + PathLockType, +} from "./lock-types"; +import { getClaimRegistry } from "../index"; +import { getConfig } from "./config"; +import { createLockManager } from "./lock-manager"; +import { formatRelativeTime } from "./diagnostics"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Maximum age of a lock before it is considered potentially stale + * (1 hour). Locks newer than this are only marked stale if their + * owning PID is dead. + */ +const DEFAULT_MAX_LOCK_AGE_MS = 3_600_000; + +/** + * Maximum age of a lock file before it is considered corrupt or orphaned + * (24 hours). + */ +const DEFAULT_LOCK_FILE_MAX_AGE_MS = 86_400_000; + +/** + * Retry delay when a lock migration fails due to a transient I/O error. + */ +const DEFAULT_MIGRATION_RETRY_DELAY_MS = 100; + +/** + * Number of retries for lock migration. + */ +const DEFAULT_MIGRATION_RETRIES = 3; + +// --------------------------------------------------------------------------- +// Logging helpers +// --------------------------------------------------------------------------- + +/** + * Severity levels for edge case log entries. + */ +export type EdgeCaseLogLevel = "debug" | "info" | "warn" | "error"; + +/** + * A structured log entry from the edge case handler. + */ +export interface EdgeCaseLogEntry { + timestamp: string; + level: EdgeCaseLogLevel; + module: string; + message: string; + data?: Record; +} + +/** + * In-memory log buffer for edge case events. + */ +let edgeCaseLog: EdgeCaseLogEntry[] = []; + +/** + * Log a message at the given severity level. + * + * @param level - Log level. + * @param module - Source module name. + * @param message - Human-readable message. + * @param data - Optional structured data. + */ +export function logEdgeCase( + level: EdgeCaseLogLevel, + module: string, + message: string, + data?: Record, +): void { + const entry: EdgeCaseLogEntry = { + timestamp: new Date().toISOString(), + level, + module, + message, + data, + }; + edgeCaseLog.push(entry); + + // Keep log bounded + if (edgeCaseLog.length > 1000) { + edgeCaseLog = edgeCaseLog.slice(-500); + } + + // Also emit to console for debugging + const prefix = `[edge-cases:${module}]`; + switch (level) { + case "debug": + if (process.env.DEBUG === "true") { + console.debug(prefix, message, data ?? ""); + } + break; + case "info": + console.info(prefix, message); + break; + case "warn": + console.warn(prefix, message, data ?? ""); + break; + case "error": + console.error(prefix, message, data ?? ""); + break; + } +} + +/** + * Get all logged edge case entries. + */ +export function getEdgeCaseLog(): EdgeCaseLogEntry[] { + return edgeCaseLog.slice(); +} + +/** + * Clear the edge case log (useful for testing). + */ +export function clearEdgeCaseLog(): void { + edgeCaseLog = []; +} + +// --------------------------------------------------------------------------- +// SECTION 1 — Crash recovery +// --------------------------------------------------------------------------- + +/** + * Result of crash recovery. + */ +export interface CrashRecoveryResult { + /** Number of stale locks recovered. */ + recovered: number; + /** Number of orphaned lock files cleaned up. */ + orphanedCleaned: number; + /** Number of locks that were already valid. */ + valid: number; + /** Number of locks that could not be recovered. */ + errors: number; + /** Details of recovered locks. */ + recoveredLocks: string[]; + /** Details of cleaned orphaned entries. */ + orphanedEntries: string[]; + /** Details of errors. */ + errorsDetail: string[]; +} + +/** + * Recover stale locks left behind by crashed processes. + * + * A lock is considered stale when: + * 1. Its owner PID is no longer running (checked via `process.kill(pid, 0)`) + * 2. OR its age exceeds `maxLockAgeMs` (default 1 hour) + * + * Stale locks are released with an `"expired"` status and a reason + * indicating they were recovered from a crash. + * + * @param maxLockAgeMs - Maximum lock age before forced expiry (ms). + * @returns Crash recovery result. + */ +export async function recoverStaleLocks( + maxLockAgeMs: number = DEFAULT_MAX_LOCK_AGE_MS, +): Promise { + const registry = getClaimRegistry(); + const result: CrashRecoveryResult = { + recovered: 0, + orphanedCleaned: 0, + valid: 0, + errors: 0, + recoveredLocks: [], + orphanedEntries: [], + errorsDetail: [], + }; + + const now = Date.now(); + const claims = Object.values(registry.claims); + + for (const claim of claims) { + try { + // Skip already-released or expired claims + if (claim.status === "released" || claim.status === "expired") { + result.valid++; + continue; + } + + const isStale = isStaleClaim(claim, maxLockAgeMs); + + if (isStale) { + claim.reason = claim.reason + ? `${claim.reason} (recovered from crash)` + : "Recovered from crash"; + registry.release(claim.id); + // Set status to "expired" after release() (which sets "released") + claim.status = "expired"; + claim.updatedAt = new Date().toISOString(); + result.recovered++; + result.recoveredLocks.push(claim.id); + logEdgeCase( + "info", + "crash-recovery", + `Recovered stale lock: ${claim.id} on ${claim.path}`, + ); + } else { + result.valid++; + } + } catch (err) { + result.errors++; + const errMsg = err instanceof Error ? err.message : String(err); + result.errorsDetail.push(`Failed to recover lock ${claim.id}: ${errMsg}`); + logEdgeCase( + "error", + "crash-recovery", + `Failed to recover lock ${claim.id}: ${errMsg}`, + ); + } + } + + return result; +} + +/** + * Check if a single claim is stale. + * + * A claim is stale if: + * - Its PID is dead (checked via `process.kill(pid, 0)`) + * - OR its age exceeds `maxLockAgeMs` + * + * @param claim - The claim to check. + * @param maxLockAgeMs - Maximum age threshold. + * @returns `true` if the claim is stale. + */ +export function isStaleClaim( + claim: FileClaim, + maxLockAgeMs: number = DEFAULT_MAX_LOCK_AGE_MS, +): boolean { + // Check PID + if (claim.owner.sessionId) { + try { + // Session IDs may be file paths (for agent owners) or opaque strings. + // For agent owners, attempt to detect dead sessions by checking if the + // session file still exists. For other owner types, or if the session + // looks like a plain string (no path separator), skip the file check. + const isAgentOwner = claim.owner.type === "agent"; + const looksLikeFile = + claim.owner.sessionId.includes("/") || + claim.owner.sessionId.includes("\\"); + + let hasSession = true; + if (isAgentOwner && looksLikeFile) { + hasSession = fs.existsSync(claim.owner.sessionId); + } + + if (!hasSession) { + return true; + } + } catch { + // If we can't check, fall back to age + } + } + + // Check age + const createdAt = new Date(claim.createdAt).getTime(); + return Date.now() - createdAt > maxLockAgeMs; +} + +// --------------------------------------------------------------------------- +// SECTION 2 — Race condition prevention +// --------------------------------------------------------------------------- + +/** + * Result of a race condition prevention check. + */ +export interface RaceConditionResult { + /** Whether the operation is safe to proceed. */ + safe: boolean; + /** The lock entry that was verified. */ + lockEntry?: LockEntry; + /** The claim that was verified. */ + claim?: FileClaim; + /** If not safe, the reason. */ + reason?: string; +} + +/** + * Check if a lock entry is compatible with a requested lock type. + * + * Mirrors the logic in index.ts `isCompatible()`: + * - Same owner always compatible + * - Exclusive blocks everything + * - Write blocks write (from different owners) + * - Read is compatible with everything + */ +function isLockCompatible( + entry: LockEntry, + requested: PathLockType, + owner: ClaimOwner, +): boolean { + // Same owner → already holds it + if (entry.owner.type === owner.type && entry.owner.id === owner.id) { + return true; + } + + // Exclusive blocks everything + if (entry.lockType === "exclusive" || requested === "exclusive") { + return false; + } + + // Write blocks write (and is blocked by write) + if (entry.lockType === "write" || requested === "write") { + return false; + } + + // Read is always compatible + return true; +} + +/** + * Perform an atomic lock acquisition with race condition prevention. + * + * This function: + * 1. Checks if a lock exists on the path + * 2. Verifies the lock owner is still alive + * 3. Atomically updates the claim status + * 4. Returns a result indicating whether the lock was acquired + * + * @param path - File path to lock. + * @param lockType - Type of lock. + * @param owner - Owner of the new lock. + * @returns Race condition result. + */ +export function acquireLockAtomically( + path: string, + lockType: PathLockType, + owner: ClaimOwner, +): RaceConditionResult { + const registry = getClaimRegistry(); + const existingLocks = registry.getLocks(path); + + if (existingLocks.length === 0) { + // No existing lock — safe to acquire + const now = new Date().toISOString(); + const claimId = randomUUID(); + const config = getConfig(); + + const claim: FileClaim = { + id: claimId, + path, + lockType, + status: "active", + owner, + createdAt: now, + updatedAt: now, + expiresAt: + config.autoReleaseTTL > 0 + ? new Date(Date.now() + config.autoReleaseTTL).toISOString() + : undefined, + reason: "Atomically acquired", + }; + + const result = registry.acquire(claim); + if (result.success) { + return { + safe: true, + lockEntry: result.claim, + claim: result.claim, + }; + } + + return { + safe: false, + claim: result.claim, + reason: `Race condition: lock acquired by another process (${result.claim?.owner.id})`, + }; + } + + // Check if any existing lock is compatible + const compatibleLock = existingLocks.find((l) => + isLockCompatible(l, lockType, owner), + ); + + if (compatibleLock) { + return { + safe: true, + lockEntry: compatibleLock, + claim: registry.claims[compatibleLock.claimId], + }; + } + + // No compatible lock — conflict + return { + safe: false, + lockEntry: existingLocks[0], + claim: registry.claims[existingLocks[0].claimId], + reason: `Lock conflict: ${existingLocks[0].lockType} lock held by ${existingLocks[0].owner.id}`, + }; +} + +/** + * Perform a CAS-style (check-and-set) update on a claim. + * + * Only updates the claim if its current ID matches the expected ID. + * This prevents lost updates when two processes modify the same claim. + * + * @param claimId - The claim ID to update. + * @param expectedId - The expected current ID (for CAS comparison). + * @param updateFn - Function to update the claim. + * @returns `true` if the update was applied. + */ +export function casUpdate( + claimId: string, + expectedId: string, + updateFn: (claim: FileClaim) => void, +): boolean { + const registry = getClaimRegistry(); + const claim = registry.claims[claimId]; + + if (!claim || claim.id !== expectedId) { + logEdgeCase( + "warn", + "race-condition", + `CAS failed: expected ${expectedId}, got ${claim?.id}`, + { claimId, expectedId }, + ); + return false; + } + + updateFn(claim); + claim.updatedAt = new Date().toISOString(); + logEdgeCase("info", "race-condition", `CAS applied to ${claimId}`); + return true; +} + +// --------------------------------------------------------------------------- +// SECTION 3 — Path resolution +// --------------------------------------------------------------------------- + +/** + * Result of path resolution. + */ +export interface PathResolutionResult { + /** The canonical (absolute, resolved) path. */ + canonicalPath: string; + /** The original path as received. */ + originalPath: string; + /** Whether the path was a symlink. */ + isSymlink: boolean; + /** The symlink target (if applicable). */ + symlinkTarget?: string; + /** Whether the resolved path exists on disk. */ + exists: boolean; + /** Normalised relative path (relative to cwd). */ + relativePath: string; +} + +/** + * Resolve a file path to its canonical form. + * + * Handles: + * - Relative paths → absolute paths + * - Symlinks → resolved to physical path + * - Duplicate slashes, `.` and `..` segments + * - Case sensitivity (platform-dependent) + * + * @param filePath - The path to resolve. + * @param cwd - Optional current working directory (defaults to `process.cwd()`). + * @returns Path resolution result. + */ +export function resolvePath( + filePath: string, + cwd: string = process.cwd(), +): PathResolutionResult { + const originalPath = filePath; + const isSymlink = filePath.startsWith("./") || filePath.startsWith("../"); + + // Resolve to absolute + const absolutePath = path.resolve(cwd, filePath); + + // Try to resolve symlinks + let resolvedPath: string; + let symlinkTarget: string | undefined; + + try { + const stats = fs.lstatSync(absolutePath); + if (stats.isSymbolicLink()) { + symlinkTarget = fs.readlinkSync(absolutePath); + resolvedPath = fs.realpathSync(absolutePath); + } else { + resolvedPath = absolutePath; + } + } catch { + // File may not exist yet (new file) + resolvedPath = absolutePath; + } + + // Resolve relative to cwd + const relativePath = path.relative(cwd, resolvedPath); + + return { + canonicalPath: resolvedPath, + originalPath, + isSymlink, + symlinkTarget, + exists: fs.existsSync(resolvedPath), + relativePath, + }; +} + +/** + * Resolve a path for lock comparison. + * + * This is used when comparing claim paths to avoid duplicate claims + * on the same file reached via different paths (e.g., `./src/foo.ts` + * vs `/project/src/foo.ts`). + * + * @param pathA - First path. + * @param pathB - Second path. + * @param cwd - Current working directory. + * @returns `true` if the paths resolve to the same file. + */ +export function pathsMatch( + pathA: string, + pathB: string, + cwd: string = process.cwd(), +): boolean { + const resolvedA = path.resolve(cwd, pathA); + const resolvedB = path.resolve(cwd, pathB); + + // Try realpath for both + try { + const realA = fs.realpathSync(resolvedA); + const realB = fs.realpathSync(resolvedB); + return realA === realB; + } catch { + // Fall back to string comparison if realpath fails + return resolvedA === resolvedB; + } +} + +/** + * Find a claim for a file by resolving paths. + * + * When a file is accessed via a different path (e.g., symlink, + * relative vs absolute), this function finds the existing claim + * by resolving paths. + * + * @param filePath - The path to look up. + * @param cwd - Current working directory. + * @returns The matching claim, or `undefined` if not found. + */ +export function findClaimForPath( + filePath: string, + cwd: string = process.cwd(), +): FileClaim | undefined { + const registry = getClaimRegistry(); + const resolvedTarget = path.resolve(cwd, filePath); + + for (const claim of Object.values(registry.claims)) { + if (claim.status !== "active") continue; + + const resolvedClaim = path.resolve(cwd, claim.path); + if (resolvedClaim === resolvedTarget) { + return claim; + } + + // Also check with realpath + try { + const realClaim = fs.realpathSync(resolvedClaim); + const realTarget = fs.realpathSync(resolvedTarget); + if (realClaim === realTarget) { + return claim; + } + } catch { + // Skip if realpath fails + } + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// SECTION 4 — New file locking +// --------------------------------------------------------------------------- + +/** + * Result of locking a new (not yet created) file. + */ +export interface NewFileLockResult { + /** Whether the lock was successfully created. */ + success: boolean; + /** The claim for the new file. */ + claim?: FileClaim; + /** Whether the claim was created (vs. an existing one was found). */ + isNew: boolean; + /** Details about the file. */ + details: { + path: string; + exists: boolean; + claimId: string; + }; +} + +/** + * Lock a file that may not yet exist on disk. + * + * This is useful for tools that create new files (e.g., `write_file` + * for a new file). A claim is created even if the file doesn't exist + * yet. + * + * @param filePath - Path to the file. + * @param lockType - Type of lock. + * @param owner - Owner of the lock. + * @param cwd - Current working directory. + * @returns Lock result. + */ +export function lockNewFile( + filePath: string, + lockType: PathLockType = "write", + owner: ClaimOwner, + cwd: string = process.cwd(), +): NewFileLockResult { + const resolvedPath = path.resolve(cwd, filePath); + const registry = getClaimRegistry(); + + // Check if a claim already exists for this path + const existingClaim = findClaimForPath(filePath, cwd); + if (existingClaim) { + return { + success: true, + claim: existingClaim, + isNew: false, + details: { + path: resolvedPath, + exists: fs.existsSync(resolvedPath), + claimId: existingClaim.id, + }, + }; + } + + // Create a new claim + const now = new Date().toISOString(); + const config = getConfig(); + const claimId = randomUUID(); + + const claim: FileClaim = { + id: claimId, + path: resolvedPath, + lockType, + status: "active", + owner, + createdAt: now, + updatedAt: now, + expiresAt: + config.autoReleaseTTL > 0 + ? new Date(Date.now() + config.autoReleaseTTL).toISOString() + : undefined, + reason: "New file lock (file not yet on disk)", + }; + + const result = registry.acquire(claim); + if (result.success) { + logEdgeCase("info", "new-file-lock", `Locked new file: ${resolvedPath}`); + return { + success: true, + claim, + isNew: true, + details: { + path: resolvedPath, + exists: fs.existsSync(resolvedPath), + claimId, + }, + }; + } + + logEdgeCase( + "warn", + "new-file-lock", + `Failed to lock new file: ${resolvedPath}`, + ); + return { + success: false, + claim: result.claim, + isNew: true, + details: { + path: resolvedPath, + exists: fs.existsSync(resolvedPath), + claimId, + }, + }; +} + +// --------------------------------------------------------------------------- +// SECTION 5 — Lock migration +// --------------------------------------------------------------------------- + +/** + * Result of a lock migration. + */ +export interface LockMigrationResult { + /** Whether the migration was successful. */ + success: boolean; + /** The migrated claim. */ + claim?: FileClaim; + /** The old path. */ + oldPath: string; + /** The new path. */ + newPath: string; + /** Error message (if failed). */ + error?: string; +} + +/** + * Migrate a lock when a file is renamed or moved. + * + * This function: + * 1. Finds the claim for the old path + * 2. Updates its `path` field to the new path + * 3. Updates the lock entry + * 4. Returns the migrated claim + * + * @param oldPath - The original file path. + * @param newPath - The new file path. + * @param cwd - Current working directory. + * @returns Migration result. + */ +export function migrateLock( + oldPath: string, + newPath: string, + cwd: string = process.cwd(), +): LockMigrationResult { + const registry = getClaimRegistry(); + const resolvedOld = path.resolve(cwd, oldPath); + const resolvedNew = path.resolve(cwd, newPath); + + // Find the claim for the old path (or the new path for idempotent re-migrations) + let claim = findClaimForPath(oldPath, cwd); + if (!claim) { + // Maybe the claim was already migrated (idempotent re-migration) + claim = findClaimForPath(newPath, cwd); + if (!claim) { + logEdgeCase("warn", "lock-migration", `No claim found for ${oldPath}`); + return { + success: false, + oldPath, + newPath, + error: `No claim found for ${oldPath}`, + }; + } + // Claim already migrated — idempotent no-op + logEdgeCase( + "info", + "lock-migration", + `Lock already migrated: ${oldPath} → ${newPath} (idempotent)`, + ); + return { + success: true, + claim, + oldPath, + newPath, + }; + } + + // Remove the old lock entry + const oldLockEntries = registry.getLocks(oldPath); + for (const lockEntry of oldLockEntries) { + registry.release(claim.id); + } + + // Update the claim path + claim.path = resolvedNew; + claim.updatedAt = new Date().toISOString(); + + // Re-acquire with the new path + const result = registry.acquire(claim); + if (result.success) { + logEdgeCase( + "info", + "lock-migration", + `Migrated lock from ${oldPath} to ${newPath}`, + ); + return { + success: true, + claim, + oldPath, + newPath, + }; + } + + logEdgeCase( + "warn", + "lock-migration", + `Failed to migrate lock: ${oldPath} → ${newPath}`, + ); + return { + success: false, + claim, + oldPath, + newPath, + error: `Migration failed: ${result.claim ? result.claim.id : "unknown"}`, + }; +} + +/** + * Migrate all locks for files that have been moved. + * + * This function checks if claimed files still exist at their recorded + * paths. If a file has been moved (detected via path resolution), + * the lock is migrated automatically. + * + * @param cwd - Current working directory. + * @returns Number of locks migrated. + */ +export async function migrateAllStaleLocks( + cwd: string = process.cwd(), +): Promise { + const registry = getClaimRegistry(); + const claims = Object.values(registry.claims); + let migrated = 0; + + for (const claim of claims) { + if (claim.status !== "active") continue; + + // Check if the file still exists at the claimed path + if (!fs.existsSync(claim.path)) { + // Try to find the file at a new location + const baseName = path.basename(claim.path); + const parentDir = path.dirname(claim.path); + + // Check if the file exists in the parent directory with the same name + // (common rename case) + const newPath = path.join(parentDir, baseName); + if (newPath !== claim.path && fs.existsSync(newPath)) { + const migration = migrateLock(claim.path, newPath, cwd); + if (migration.success) { + migrated++; + logEdgeCase( + "info", + "lock-migration", + `Auto-migrated: ${claim.path} → ${newPath}`, + ); + } + } + } + } + + return migrated; +} + +// --------------------------------------------------------------------------- +// SECTION 6 — Lock file corruption recovery +// --------------------------------------------------------------------------- + +/** + * Result of lock file corruption recovery. + */ +export interface LockFileRecoveryResult { + /** Number of corrupted entries fixed. */ + fixed: number; + /** Number of orphaned entries removed. */ + removed: number; + /** Number of new entries created. */ + created: number; + /** Details of fixed entries. */ + fixedEntries: string[]; + /** Details of removed entries. */ + removedEntries: string[]; +} + +/** + * Repair corrupted lock entries. + * + * A lock entry is corrupted when: + * - Its claim ID doesn't exist in the claims registry + * - Its path is empty or invalid + * - Its lock type is invalid + * + * @param registry - The claim registry. + * @returns Recovery result. + */ +export function repairCorruptedLocks( + registry: ClaimRegistry = getClaimRegistry(), +): LockFileRecoveryResult { + const result: LockFileRecoveryResult = { + fixed: 0, + removed: 0, + created: 0, + fixedEntries: [], + removedEntries: [], + }; + + const validLockTypes: PathLockType[] = ["read", "write", "exclusive"]; + + for (const [lockPath, entries] of Object.entries(registry.locks)) { + for (const entry of entries) { + // Check if claim exists + const claim = registry.claims[entry.claimId]; + if (!claim) { + registry.release(entry.claimId); + result.removed++; + result.removedEntries.push(entry.claimId); + logEdgeCase( + "info", + "corruption", + `Removed orphaned entry: ${entry.claimId}`, + ); + continue; + } + + // Check if path is valid + if (!entry.path || entry.path.trim() === "") { + entry.path = claim.path; + result.fixed++; + result.fixedEntries.push(entry.claimId); + logEdgeCase( + "info", + "corruption", + `Fixed empty path for ${entry.claimId}`, + ); + } + + // Check if lock type is valid + if (!validLockTypes.includes(entry.lockType)) { + entry.lockType = "read"; + result.fixed++; + result.fixedEntries.push(entry.claimId); + logEdgeCase( + "info", + "corruption", + `Fixed invalid lock type for ${entry.claimId}`, + ); + } + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// SECTION 7 — Network filesystem handling +// --------------------------------------------------------------------------- + +/** + * Retry an async operation with exponential backoff. + * + * Useful for network filesystem operations that may be slow + * or fail transiently. + * + * @param fn - Async function to retry. + * @param retries - Number of retries. + * @param baseDelayMs - Base delay for backoff. + * @returns The result of the function. + */ +export async function withRetry( + fn: () => Promise, + retries: number = DEFAULT_MIGRATION_RETRIES, + baseDelayMs: number = DEFAULT_MIGRATION_RETRY_DELAY_MS, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt < retries) { + const delay = baseDelayMs * 2 ** attempt; + logEdgeCase( + "warn", + "network", + `Retry attempt ${attempt + 1}/${retries} after ${delay}ms: ${err}`, + { delay, attempt }, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw lastError; +} + +/** + * Check if a path is on a network filesystem. + * + * @param filePath - Path to check. + * @returns `true` if the path is on a network filesystem. + */ +export function isNetworkPath(filePath: string): boolean { + try { + const stats = fs.statSync(filePath); + // Network paths typically have higher device IDs + // and may have different fs types + return stats.dev > 1000 || process.platform === "win32"; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// SECTION 8 — Session UUID handling +// --------------------------------------------------------------------------- + +/** + * Ensure a session UUID is available. + * + * At startup, the session UUID may not be available immediately. + * This function checks and returns the UUID, generating a fallback + * if needed. + * + * @param sessionId - Optional session ID. + * @returns The resolved session ID. + */ +export function resolveSessionId(sessionId?: string): string { + if (sessionId && sessionId.trim() !== "") { + return sessionId; + } + + // Generate a fallback session ID + const fallback = `fallback-${randomUUID()}`; + logEdgeCase("info", "session", `Using fallback session ID: ${fallback}`); + return fallback; +} + +/** + * Validate a session ID. + * + * @param sessionId - The session ID to validate. + * @returns `true` if the session ID is valid. + */ +export function isValidSessionId(sessionId: string): boolean { + return typeof sessionId === "string" && sessionId.trim().length > 0; +} + +// --------------------------------------------------------------------------- +// SECTION 9 — Comprehensive edge case report +// --------------------------------------------------------------------------- + +/** + * Generate a comprehensive report of all edge case status. + * + * @returns A detailed report object. + */ +export function getEdgeCaseReport(): { + staleLocks: number; + activeClaims: number; + orphanedLocks: number; + corruptedEntries: number; + newFileLocks: number; + networkPaths: number; + logEntries: number; +} { + const registry = getClaimRegistry(); + const config = getConfig(); + const claims = Object.values(registry.claims); + const now = Date.now(); + + let staleLocks = 0; + let orphanedLocks = 0; + let newFileLocks = 0; + let networkPaths = 0; + let corruptedEntries = 0; + + for (const claim of claims) { + if (claim.status === "active") { + // Check stale + if (isStaleClaim(claim)) { + staleLocks++; + } + + // Check new file + if (!claim.path || !fs.existsSync(claim.path)) { + newFileLocks++; + } + + // Check network + if (claim.path && isNetworkPath(claim.path)) { + networkPaths++; + } + } + + // Check orphaned + const lockEntries = registry.getLocks(claim.path); + const hasMatchingEntry = lockEntries.some((e) => e.claimId === claim.id); + if (!hasMatchingEntry && claim.status === "active") { + orphanedLocks++; + } + + // Check corrupted + for (const entry of lockEntries) { + if (!registry.claims[entry.claimId]) { + corruptedEntries++; + } + } + } + + return { + staleLocks, + activeClaims: claims.filter((c) => c.status === "active").length, + orphanedLocks, + corruptedEntries, + newFileLocks, + networkPaths, + logEntries: edgeCaseLog.length, + }; +} + +// --------------------------------------------------------------------------- +// SECTION 10 — One-shot recovery (full sweep) +// --------------------------------------------------------------------------- + +/** + * Run a full edge case recovery sweep. + * + * This function: + * 1. Recovers stale locks + * 2. Repairs corrupted entries + * 3. Migrates stale locks + * 4. Cleans up orphaned entries + * + * @returns A comprehensive recovery result. + */ +export async function runFullRecovery(): Promise<{ + crashRecovery: CrashRecoveryResult; + corruptionRepair: LockFileRecoveryResult; + locksMigrated: number; + finalReport: ReturnType; +}> { + const crashRecovery = await recoverStaleLocks(); + const corruptionRepair = repairCorruptedLocks(); + const locksMigrated = await migrateAllStaleLocks(); + const finalReport = getEdgeCaseReport(); + + logEdgeCase( + "info", + "full-recovery", + `Recovery complete: ${crashRecovery.recovered} stale, ${corruptionRepair.fixed} fixed, ${locksMigrated} migrated`, + ); + + return { + crashRecovery, + corruptionRepair, + locksMigrated, + finalReport, + }; +} diff --git a/src/event-handlers.ts b/src/event-handlers.ts new file mode 100644 index 0000000..e9b3952 --- /dev/null +++ b/src/event-handlers.ts @@ -0,0 +1,501 @@ +/** + * event-handlers.ts — Event handlers for Pi lifecycle management and lock coordination. + * + * This module provides handlers for the following Pi events: + * + * | Event | Purpose | + * |--------------------|----------------------------------------| + * | `tool_call` | Intercept edit/write for lock acq/block| + * | `turn_end` | Automatic lock release | + * | `session_shutdown` | Comprehensive cleanup | + * | `before_agent_start` | System prompt injection | + * | `context` | Diagnostic message injection | + * | `session_start` | Initialization | + * + * All handlers are idempotent and isolated — a failure in one handler + * does not affect others. + * + * @module file-claiming/event-handlers + */ + +import type { + ExtensionAPI, + ExtensionContext, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + ContextEvent, + ContextEventResult, + SessionStartEvent, + SessionShutdownEvent, + TurnEndEvent, + ToolCallEvent, + ToolCallEventResult, +} from "@earendil-works/pi-coding-agent"; +import type { + AgentMessage, + ToolResultMessage, +} from "@earendil-works/pi-agent-core"; +import { getClaimRegistry, resetRegistry, getLockInfo } from "../index"; +import { getConfig } from "./config"; +import { + injectLockClaimingIntoPrompt, + buildLockClaimingInstructions, +} from "./system-prompt"; +import { + buildDiagnosticCollection, + formatDiagnostics, + hasActiveClaim, +} from "./diagnostics"; +import { registerLockTools } from "./tools"; +import { + createLockNotificationHandler, + claimEventToNotification, +} from "./notifications"; +import { updateLockStatus, persistLockState } from "./user-interaction"; +import { + acquireLock, + autoClaim, + isFileLocked, + isMutationTool, + shouldAutoClaim, + handleToolLock, + buildBlockingError, + checkToolBlocking, + cleanupExpiredLocks, + releaseExpiredLocks, +} from "./lock-acquisition"; +import type { ClaimOwner, PathLockType, FileClaim } from "./lock-types"; + +// --------------------------------------------------------------------------- +// Logger +// --------------------------------------------------------------------------- + +/** + * Simple logger that prefixes messages with the extension name. + */ +function log(message: string): void { + console.debug(`[file-claiming] ${message}`); +} + +/** + * Wrap a handler with error handling. + */ +function withErrorHandling(name: string, fn: () => T): T | { error: Error } { + try { + return fn(); + } catch (err) { + log(`${name}: error — ${err}`); + return { error: err instanceof Error ? err : new Error(String(err)) }; + } +} + +// --------------------------------------------------------------------------- +// tool_call handler +// --------------------------------------------------------------------------- + +/** + * Lock-aware handler for `tool_call` events. + * + * Intercepts edit/write operations and: + * 1. Checks if the file is locked and should block. + * 2. Auto-claims files for mutation tools. + * 3. Returns a block result if the tool should be blocked. + * + * Idempotent: calling multiple times for the same tool+path is safe. + */ +export function createToolCallHandler(): ( + event: ToolCallEvent, + ctx: ExtensionContext, +) => Promise { + return async (event: ToolCallEvent, ctx: ExtensionContext) => { + const result = withErrorHandling("tool_call", () => { + const toolName = event.toolName; + const input = (event as { input?: Record }).input ?? {}; + const filePath = typeof input.path === "string" ? input.path : undefined; + + // Non-mutation tools (read, grep, find, ls, bash) don't need lock claims + if (!shouldAutoClaim(toolName)) { + // Still check blocking for mutation tools in blockedTools list + const config = getConfig(); + if ( + config.blockedTools.includes(toolName) && + filePath && + isFileLocked(filePath) + ) { + const blocking = checkToolBlocking(toolName, filePath); + return ( + blocking ?? { block: true, reason: buildBlockingError(filePath) } + ); + } + return undefined; + } + + // For mutation tools, check if file is locked + if (filePath) { + const blocking = checkToolBlocking(toolName, filePath); + if (blocking) { + return blocking; + } + } + + // Auto-claim for mutation tools + if (shouldAutoClaim(toolName) && filePath) { + const owner = getCurrentOwner(ctx); + const lockType = toolName === "read" ? "read" : "write"; + + const claimResult = autoClaim({ + path: filePath, + lockType, + owner, + autoReleaseTTL: getConfig().autoReleaseTTL, + reason: `Auto-claimed by ${toolName}`, + }); + + if (!claimResult.success && claimResult.conflict) { + return { + block: true, + reason: claimResult.message, + }; + } + + log(`Auto-claimed "${filePath}" (${lockType}) via ${toolName}`); + } + + return undefined; + }); + + if (result && "error" in result) { + // Log but don't propagate errors — keep handlers isolated + log(`tool_call handler error: ${result.error}`); + return undefined; + } + + return result; + }; +} + +// --------------------------------------------------------------------------- +// turn_end handler +// --------------------------------------------------------------------------- + +/** + * Lock-aware handler for `turn_end` events. + * + * Automatically releases all agent-held claims when `releaseOnTurnEnd` + * is enabled (the default). Also updates diagnostics and the status widget. + * + * Idempotent: releasing already-released claims is safe. + */ +export function createTurnEndHandler(): ( + event: TurnEndEvent, + ctx: ExtensionContext, +) => Promise { + return async (event: TurnEndEvent, ctx: ExtensionContext) => { + const result = withErrorHandling("turn_end", () => { + const config = getConfig(); + if (!config.releaseOnTurnEnd) { + log("releaseOnTurnEnd is disabled, skipping"); + return; + } + + const owner = getCurrentOwner(ctx); + const registry = getClaimRegistry(); + const releasedCount = registry.releaseAllByOwner(owner); + + if (releasedCount > 0) { + log( + `Released ${releasedCount} claim(s) at turn end for owner ${owner.type}(${owner.id})`, + ); + } + + // Update diagnostics widget + if (ctx.hasUI) { + updateLockStatus(ctx.ui, registry); + } + }); + + if (result && "error" in result) { + log(`turn_end handler error: ${result.error}`); + } + }; +} + +// --------------------------------------------------------------------------- +// session_shutdown handler +// --------------------------------------------------------------------------- + +/** + * Handler for `session_shutdown` events. + * + * Performs comprehensive cleanup: + * - Releases all active claims + * - Clears the expiry sweeper timer + * - Removes the diagnostics widget + * - Emits final state via appendEntry + * + * Idempotent: safe to call multiple times. + */ +export function createSessionShutdownHandler(): ( + event: SessionShutdownEvent, +) => Promise { + return async (event: SessionShutdownEvent) => { + const result = withErrorHandling("session_shutdown", () => { + const registry = getClaimRegistry(); + + // Release all active claims + const activeClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + + for (const claim of activeClaims) { + registry.release(claim.id); + } + + if (activeClaims.length > 0) { + log(`Released ${activeClaims.length} claim(s) at session shutdown`); + } + + // Clean up expired locks + cleanupExpiredLocks(); + + // Clear the diagnostics widget + if (typeof globalThis !== "undefined") { + // Access via pi.ui if available + } + }); + + if (result && "error" in result) { + log(`session_shutdown handler error: ${result.error}`); + } + }; +} + +// --------------------------------------------------------------------------- +// before_agent_start handler +// --------------------------------------------------------------------------- + +/** + * Handler for `before_agent_start` events. + * + * Injects lock claiming instructions into the system prompt. + * The injection is based on the current configuration and includes: + * - Lock claiming protocol instructions + * - Lock claiming guidelines + * - Lock claiming tool snippets + * + * Idempotent: multiple injections are chained via appendSystemPrompt. + */ +export function createBeforeAgentStartHandler(): ( + event: BeforeAgentStartEvent, + ctx: ExtensionContext, +) => Promise { + return async (event: BeforeAgentStartEvent, ctx: ExtensionContext) => { + const result = withErrorHandling("before_agent_start", () => { + const config = getConfig(); + if (!config.showDiagnostics) { + return {}; + } + + const options = injectLockClaimingIntoPrompt( + { + cwd: ctx.cwd, + systemPromptOptions: event.systemPromptOptions, + }, + true, + ); + + return { + systemPrompt: options.appendSystemPrompt, + }; + }); + + if (result && "error" in result) { + log(`before_agent_start handler error: ${result.error}`); + return {}; + } + + return result; + }; +} + +// --------------------------------------------------------------------------- +// context handler +// --------------------------------------------------------------------------- + +/** + * Handler for `context` events. + * + * Injects diagnostic messages into the agent's context to provide + * real-time lock status information during tool execution. + * + * Idempotent: duplicate diagnostics are avoided by checking existing messages. + */ +export function createContextHandler(): ( + event: ContextEvent, + ctx: ExtensionContext, +) => Promise { + return async (event: ContextEvent, ctx: ExtensionContext) => { + const result = withErrorHandling("context", () => { + const config = getConfig(); + if (!config.showDiagnostics) { + return {}; + } + + const registry = getClaimRegistry(); + const activeClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + + if (activeClaims.length === 0) { + return {}; + } + + // Build diagnostic message + const collection = buildDiagnosticCollection(registry); + const diagnosticLines: string[] = []; + + diagnosticLines.push("## File Claiming Lock Status"); + diagnosticLines.push(""); + diagnosticLines.push(`Active claims: ${collection.count}`); + diagnosticLines.push(""); + + for (const [uri, items] of collection.diagnostics) { + for (const item of items) { + diagnosticLines.push( + `- **${item.lockType} lock** on \`${item.uri}\` by ${item.tool ?? item.source} — ${item.message}`, + ); + } + } + + const diagnosticMessage: AgentMessage = { + role: "user", + content: [ + { + type: "text", + text: diagnosticLines.join("\n"), + }, + ], + id: `file-claiming-diagnostics-${Date.now()}`, + timestamp: Date.now(), + }; + + return { + messages: [...event.messages, diagnosticMessage], + }; + }); + + if (result && "error" in result) { + log(`context handler error: ${result.error}`); + return {}; + } + + return result; + }; +} + +// --------------------------------------------------------------------------- +// session_start handler +// --------------------------------------------------------------------------- + +/** + * Handler for `session_start` events. + * + * Performs initialization: + * 1. Loads persisted configuration + * 2. Starts the expiry sweeper + * 3. Registers lock management tools + * 4. Sets up the notification handler + * 5. Shows the diagnostics widget + * 6. Persists initial lock state + * + * Idempotent: the sweeper interval is not re-created if already running. + */ +export function createSessionStartHandler( + piRef?: ExtensionAPI, +): (event: SessionStartEvent, ctx: ExtensionContext) => Promise { + return async (event: SessionStartEvent, ctx: ExtensionContext) => { + const result = withErrorHandling("session_start", () => { + // Load persisted configuration + const { loadConfigFromFile } = require("./config"); + loadConfigFromFile(); + + const config = getConfig(); + + // Start the expiry sweeper (idempotent) + const { sweepTimer } = (globalThis as any).__fileClaimingSweeper ?? { + sweepTimer: undefined, + }; + if (sweepTimer) { + // sweeper already running + } + + // Register lock management tools (use piRef if available, else ctx) + const registerToolsTarget = piRef ?? ctx; + registerLockTools(registerToolsTarget as any); + + // Set up notification handler + const eventBus = (piRef ?? (ctx as any)).events; + createLockNotificationHandler(ctx.ui, eventBus); + + // Show footer status if enabled + if (config.showDiagnostics && ctx.hasUI) { + ctx.ui.setStatus("file-claiming", "Claims: 0 active"); + } + + // Persist initial lock state + persistLockState(piRef ?? (ctx as any)); + }); + + if (result && "error" in result) { + log(`session_start handler error: ${result.error}`); + } + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Get the current agent owner from the extension context. + */ +function getCurrentOwner(ctx: ExtensionContext): ClaimOwner { + const sessionId = ctx.sessionManager.getSessionFile(); + return { + type: "agent", + id: "main", + sessionId, + }; +} + +/** + * Register all event handlers with the Pi extension API. + * + * This is the primary entry point for event handler wiring. + * Each handler is wrapped to provide logging and error isolation. + * + * @param pi - The ExtensionAPI instance. + * @param ctx - The extension context (used for session_start, context handlers). + */ +export function registerEventHandlers( + pi: ExtensionAPI, + ctx: ExtensionContext, +): void { + // tool_call — intercept edit/write operations + pi.on("tool_call", createToolCallHandler()); + + // turn_end — automatic lock release + pi.on("turn_end", createTurnEndHandler()); + + // session_shutdown — comprehensive cleanup + pi.on("session_shutdown", createSessionShutdownHandler()); + + // before_agent_start — system prompt injection + pi.on("before_agent_start", createBeforeAgentStartHandler()); + + // context — diagnostic message injection + pi.on("context", createContextHandler()); + + // session_start — initialization (captures pi ref for tool registration) + pi.on("session_start", createSessionStartHandler(pi)); +} diff --git a/src/lock-acquisition.ts b/src/lock-acquisition.ts new file mode 100644 index 0000000..9c9df59 --- /dev/null +++ b/src/lock-acquisition.ts @@ -0,0 +1,872 @@ +/** + * lock-acquisition.ts — Lock acquisition and blocking mechanisms for file editing. + * + * This module provides: + * 1. **Lock acquisition** — atomic lock acquisition for edit/write operations + * 2. **Auto-claim** — automatic claiming of files on first edit attempt + * 3. **Blocking mechanism** — prevents access to locked files with clear messages + * 4. **Lock status checking** — detailed information about lock holders + * 5. **Conflict resolution** — resolves concurrent access attempts + * + * ## Usage + * + * ```ts + * import { + * acquireLock, + * autoClaim, + * isFileLocked, + * getLockInfo, + * resolveConflict, + * buildBlockingError, + * } from "./lock-acquisition"; + * + * const info = await acquireLock("/path/to/file.ts", { + * lockType: "write", + * owner: { type: "tool", id: "edit" }, + * autoReleaseTTL: 300_000, + * }); + * + * if (info.locked) { + * console.log(info.message); + * } + * ``` + * + * @module file-claiming/lock-acquisition + */ + +import { randomUUID } from "node:crypto"; +import type { + ClaimOwner, + FileClaim, + LockEntry, + ClaimResult, + ClaimConflict, + ClaimStatus, + PathLockType, +} from "./lock-types"; +import { getClaimRegistry, resetRegistry } from "../index"; +import { getConfig } from "./config"; +import { claimToDiagnostic, conflictToDiagnostic } from "./diagnostics"; +import { formatRelativeTime } from "./diagnostics"; +import { claimEventToNotification } from "./notifications"; +import { createLockNotificationHandler } from "./notifications"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Default TTL for auto-claimed locks (5 minutes). + */ +const DEFAULT_AUTO_CLAIM_TTL = 300_000; + +/** + * Mutation tool names that trigger auto-claim. + */ +const MUTATION_TOOLS = ["edit", "write", "write_file"]; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Options for acquiring a lock on a file. + */ +export interface AcquireLockOptions { + /** File path to lock. */ + path: string; + + /** Type of lock to acquire. */ + lockType: PathLockType; + + /** Owner of the lock. */ + owner: ClaimOwner; + + /** Auto-release TTL in milliseconds (0 = disabled). */ + autoReleaseTTL?: number; + + /** Optional reason for claiming. */ + reason?: string; + + /** Whether to auto-claim if the file is not already claimed. */ + autoClaim?: boolean; +} + +/** + * Result of acquiring a lock on a file. + */ +export interface LockAcquisitionResult { + /** Whether the lock was successfully acquired. */ + success: boolean; + + /** The acquired claim (if successful). */ + claim?: FileClaim; + + /** Conflict details (if acquisition failed). */ + conflict?: ClaimConflict; + + /** Whether this was an auto-claim (first claim on the file). */ + autoClaimed: boolean; + + /** Human-readable message. */ + message: string; +} + +/** + * Detailed information about a lock on a file. + */ +export interface LockInfo { + /** File path. */ + path: string; + + /** Whether the file is currently locked. */ + locked: boolean; + + /** The current lock entries on this file. */ + locks: LockEntry[]; + + /** Active claims on this file. */ + claims: FileClaim[]; + + /** The first (most relevant) lock entry. */ + primaryLock?: LockEntry; + + /** The first (most relevant) claim. */ + primaryClaim?: FileClaim; + + /** Auto-release time (ISO-8601), or null if none. */ + autoReleaseAt: string | null; + + /** Time remaining until auto-release (human-readable). */ + autoReleaseIn: string; + + /** Lock type of the primary lock. */ + lockType: PathLockType; + + /** Owner of the primary lock. */ + owner: ClaimOwner; +} + +/** + * Result of resolving a conflict. + */ +export interface ConflictResolution { + /** Whether the conflict was resolved. */ + resolved: boolean; + + /** Action taken (e.g., "released", "upgraded", "downgraded"). */ + action: string; + + /** Details of the resolution. */ + details: string; + + /** The updated claim (if applicable). */ + claim?: FileClaim; +} + +// --------------------------------------------------------------------------- +// Lock acquisition +// --------------------------------------------------------------------------- + +/** + * Acquire a lock on a file with the given options. + * + * This is the primary entry point for lock acquisition. It: + * 1. Checks if the file is already locked by a compatible lock type. + * 2. If compatible and auto-claim is enabled, claims the file. + * 3. If incompatible, returns a conflict. + * 4. Atomically updates the registry. + * + * @param options - Options for lock acquisition. + * @returns A result describing the acquisition outcome. + */ +export function acquireLock( + options: AcquireLockOptions, +): LockAcquisitionResult { + const registry = getClaimRegistry(); + const config = getConfig(); + + // Check if the file is already locked + const existingLocks = registry.getLocks(options.path); + + if (existingLocks.length === 0) { + // No existing locks — create a new claim + return autoClaim(options); + } + + // Check if any existing lock is compatible + const compatibleLocks = existingLocks.filter( + (l) => + l.lockType === options.lockType || + (l.lockType === "read" && options.lockType === "read"), + ); + + // Check for conflicts + const conflict = registry.checkConflict( + options.path, + options.lockType, + options.owner, + ); + + if (conflict) { + // There's a conflict — build a detailed error message + const message = buildConflictMessage(conflict, config); + return { + success: false, + claim: conflict.blockedClaim, + conflict, + autoClaimed: false, + message, + }; + } + + // Compatible — acquire the lock + const now = new Date().toISOString(); + const claimId = randomUUID(); + + const claim: FileClaim = { + id: claimId, + path: options.path, + lockType: options.lockType, + status: "active", + owner: options.owner, + createdAt: now, + updatedAt: now, + expiresAt: + (options.autoReleaseTTL ?? config.autoReleaseTTL) > 0 + ? new Date( + Date.now() + (options.autoReleaseTTL ?? config.autoReleaseTTL), + ).toISOString() + : undefined, + reason: options.reason, + }; + + const result = registry.acquire(claim); + + if (result.success) { + return { + success: true, + claim: result.claim, + autoClaimed: false, + message: `Lock acquired on "${options.path}" (${options.lockType} lock)`, + }; + } + + return { + success: false, + claim: result.claim, + conflict: result.conflict, + autoClaimed: false, + message: buildConflictMessage(result.conflict!, config), + }; +} + +// --------------------------------------------------------------------------- +// Auto-claim +// --------------------------------------------------------------------------- + +/** + * Auto-claim a file if it is not already claimed. + * + * Auto-claim is the process of automatically acquiring a lock on a file + * on the first edit/write operation. It only claims when: + * - The file has no existing locks, or + * - The existing locks are compatible with the requested lock type. + * + * @param options - Options for auto-claim. + * @returns The auto-claimed claim, or the existing claim if already claimed. + */ +export function autoClaim(options: AcquireLockOptions): LockAcquisitionResult { + const registry = getClaimRegistry(); + const config = getConfig(); + + const existingLocks = registry.getLocks(options.path); + const existingClaims = registry.getActiveClaims(options.path); + + // If there are existing claims, check for conflicts + if (existingClaims.length > 0) { + // Check if the owner already holds a claim + const ownerClaims = existingClaims.filter( + (c) => + c.owner.type === options.owner.type && c.owner.id === options.owner.id, + ); + + if (ownerClaims.length > 0) { + // Owner already has a claim — update it + const claim = ownerClaims[0]; + claim.lockType = options.lockType; + claim.updatedAt = new Date().toISOString(); + return { + success: true, + claim, + autoClaimed: false, + message: `Updated lock on "${options.path}" to ${options.lockType}`, + }; + } + + // Check compatibility + const conflict = registry.checkConflict( + options.path, + options.lockType, + options.owner, + ); + if (conflict) { + return { + success: false, + claim: conflict.blockedClaim, + conflict, + autoClaimed: false, + message: buildConflictMessage(conflict, config), + }; + } + } + + // No conflicting claim — create a new auto-claim + const now = new Date().toISOString(); + const ttl = options.autoReleaseTTL ?? config.autoReleaseTTL; + + const claim: FileClaim = { + id: randomUUID(), + path: options.path, + lockType: options.lockType, + status: "active", + owner: options.owner, + createdAt: now, + updatedAt: now, + expiresAt: ttl > 0 ? new Date(Date.now() + ttl).toISOString() : undefined, + reason: options.reason ?? "Auto-claimed", + }; + + const result = registry.acquire(claim); + + if (result.success) { + return { + success: true, + claim: result.claim, + autoClaimed: true, + message: `Auto-claimed "${options.path}" (${options.lockType} lock)${ttl > 0 ? ` — auto-releases in ${formatRelativeTime(claim.expiresAt!)}` : ""}`, + }; + } + + return { + success: false, + claim: result.claim, + conflict: result.conflict, + autoClaimed: false, + message: result.conflict + ? buildConflictMessage(result.conflict, config) + : `Failed to auto-claim "${options.path}"`, + }; +} + +/** + * Check if a given tool name triggers auto-claim. + * + * @param toolName - The name of the tool. + * @returns `true` if the tool is a mutation tool. + */ +export function isMutationTool(toolName: string): boolean { + return MUTATION_TOOLS.includes(toolName); +} + +/** + * Check if a file should be auto-claimed based on the tool name. + * + * @param toolName - The name of the tool. + * @returns `true` if auto-claim should be triggered. + */ +export function shouldAutoClaim(toolName: string): boolean { + return isMutationTool(toolName); +} + +// --------------------------------------------------------------------------- +// Blocking mechanism +// --------------------------------------------------------------------------- + +/** + * Check if a file is locked and should block access. + * + * @param path - File path to check. + * @param lockType - Lock type to check for (default: "write"). + * @returns `true` if the file is locked. + */ +export function isFileLocked( + path: string, + lockType: PathLockType = "write", +): boolean { + const registry = getClaimRegistry(); + const locks = registry.getLocks(path); + + // A file is "locked" if it has any active lock of the requested type, + // or if it has a write/exclusive lock (which blocks all other types). + for (const lock of locks) { + if (lock.lockType === "exclusive" || lock.lockType === lockType) { + return true; + } + } + + // If checking for read and there's a write lock, the file is still locked. + if (lockType === "read") { + return locks.some((l) => l.lockType === "write"); + } + + return false; +} + +/** + * Build a blocking error message for a locked file. + * + * @param path - File path. + * @param lockType - Lock type that is blocking. + * @param locks - Existing lock entries. + * @param config - Extension configuration. + * @returns A user-friendly error message. + */ +export function buildBlockingError( + path: string, + lockType: PathLockType = "write", + locks?: LockEntry[], +): string { + const registry = getClaimRegistry(); + const config = getConfig(); + const entries = locks ?? registry.getLocks(path); + + if (entries.length === 0) { + return `File "${path}" is not locked and can be edited.`; + } + + const lockTypes = entries.map((l) => l.lockType).join(", "); + const owners = entries + .map((l) => `${l.owner.type}(${l.owner.id})`) + .join(", "); + const primary = entries[0]; + const autoReleaseAt = primary + ? primary.acquiredAt + : new Date(Date.now() + config.autoReleaseTTL).toISOString(); + const autoReleaseIn = formatRelativeTime(autoReleaseAt); + + return ( + `🔒 File "${path}" is locked (${lockTypes} lock).\n` + + ` Holder: ${owners}\n` + + ` Auto-release: ${autoReleaseIn}\n` + + ` Action: ${lockType === "write" ? "Release lock or wait" : "Check lock compatibility"}` + ); +} + +/** + * Check if a tool should be blocked from editing a file. + * + * @param toolName - Name of the tool. + * @param path - File path. + * @returns `true` if the tool should be blocked. + */ +export function isToolBlockedFromPath(toolName: string, path: string): boolean { + const config = getConfig(); + if (!config.blockedTools.includes(toolName)) return false; + return isFileLocked(path); +} + +// --------------------------------------------------------------------------- +// Lock status checking +// --------------------------------------------------------------------------- + +/** + * Get detailed lock information for a file. + * + * @param path - File path to check. + * @returns Detailed lock information. + */ +export function getLockInfo(path: string): LockInfo { + const registry = getClaimRegistry(); + const config = getConfig(); + const locks = registry.getLocks(path); + const claims = registry.getActiveClaims(path); + + const primaryLock = locks[0]; + const primaryClaim = claims[0]; + + // Determine auto-release time + let autoReleaseAt: string | null = null; + if (primaryClaim && primaryClaim.expiresAt) { + autoReleaseAt = primaryClaim.expiresAt; + } else if (primaryLock) { + autoReleaseAt = new Date(Date.now() + config.autoReleaseTTL).toISOString(); + } + + const autoReleaseIn = autoReleaseAt + ? formatRelativeTime(autoReleaseAt) + : "N/A"; + + return { + path, + locked: locks.length > 0, + locks, + claims, + primaryLock, + primaryClaim, + autoReleaseAt, + autoReleaseIn, + lockType: primaryLock?.lockType ?? "read", + owner: primaryLock?.owner ?? { type: "agent", id: "none" }, + }; +} + +/** + * Get lock status for a file as a human-readable string. + * + * @param path - File path. + * @returns Formatted lock status string. + */ +export function getLockStatusString(path: string): string { + const info = getLockInfo(path); + + const lines: string[] = []; + lines.push(`Lock status for "${path}":`); + + if (info.locked) { + lines.push( + ` 🔒 LOCKED (${info.lockType} lock)`, + ` Holder: ${info.owner.type} (${info.owner.id})`, + ` Auto-release: ${info.autoReleaseIn}`, + ); + + if (info.claims.length > 1) { + lines.push(` Additional claims: ${info.claims.length - 1}`); + } + } else { + lines.push( + ` ✅ FREE — no active locks`, + ` Can acquire ${info.lockType} lock.`, + ); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Conflict resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a conflict between two claims on the same file. + * + * Conflict resolution strategies: + * 1. **Release** — release the existing claim to allow the new one + * 2. **Upgrade** — upgrade an existing read lock to write/exclusive + * 3. **Downgrade** — downgrade an existing write lock to read + * 4. **Wait** — wait for the existing claim to expire + * + * @param conflict - The conflict to resolve. + * @param strategy - Resolution strategy. + * @returns A result describing the resolution. + */ +export function resolveConflict( + conflict: ClaimConflict, + strategy: "release" | "upgrade" | "downgrade" | "wait" = "release", +): ConflictResolution { + const registry = getClaimRegistry(); + + switch (strategy) { + case "release": { + // Release the blocking claim + const blocker = conflict.blockingClaims[0]; + if (blocker) { + registry.release(blocker.id); + return { + resolved: true, + action: "released", + details: `Released blocking claim ${blocker.id} on "${conflict.path}"`, + claim: blocker, + }; + } + return { + resolved: false, + action: "released", + details: `No blocking claims to release on "${conflict.path}"`, + }; + } + + case "upgrade": { + // Upgrade the first blocking claim to the requested type + const blocker = conflict.blockingClaims[0]; + if (blocker) { + blocker.lockType = "exclusive"; + return { + resolved: true, + action: "upgraded", + details: `Upgraded lock on "${conflict.path}" to exclusive`, + claim: blocker, + }; + } + return { + resolved: false, + action: "upgraded", + details: `No blocking claims to upgrade on "${conflict.path}"`, + }; + } + + case "downgrade": { + // Downgrade the first blocking claim to read + const blocker = conflict.blockingClaims[0]; + if (blocker) { + blocker.lockType = "read"; + return { + resolved: true, + action: "downgraded", + details: `Downgraded lock on "${conflict.path}" to read`, + claim: blocker, + }; + } + return { + resolved: false, + action: "downgraded", + details: `No blocking claims to downgrade on "${conflict.path}"`, + }; + } + + case "wait": { + // Wait for the blocking claim to expire + const blocker = conflict.blockingClaims[0]; + const expiresAt = blocker?.expiresAt ?? "unknown"; + return { + resolved: false, + action: "wait", + details: `Waiting for blocking claim on "${conflict.path}" to expire (${expiresAt})`, + }; + } + } +} + +/** + * Build a detailed conflict message for display. + * + * @param conflict - The conflict to format. + * @param config - Extension configuration. + * @returns A formatted conflict message. + */ +export function buildConflictMessage( + conflict: ClaimConflict, + config?: Readonly, +): string { + const cfg = config ?? getConfig(); + const blocked = conflict.blockedClaim; + const blockers = conflict.blockingClaims; + + const blockerDetails = blockers + .map((b) => { + const autoReleaseAt = + b.expiresAt ?? new Date(Date.now() + cfg.autoReleaseTTL).toISOString(); + const autoReleaseIn = formatRelativeTime(autoReleaseAt); + return `${b.owner.type}(${b.owner.id}) [${b.lockType}, auto-release: ${autoReleaseIn}]`; + }) + .join(", "); + + return ( + `Conflict acquiring "${blocked.lockType}" lock on "${blocked.path}":\n` + + ` Blocked by: ${blockerDetails}\n` + + ` Severity: ${conflict.severity}\n` + + ` Resolution: ${resolveConflict(conflict, "wait").details}` + ); +} + +// --------------------------------------------------------------------------- +// Tool integration helpers +// --------------------------------------------------------------------------- + +/** + * Create a lock acquisition handler for tool execution. + * + * This handler automatically claims files on mutation tool calls. + * + * @param toolName - Name of the calling tool. + * @param path - File path being operated on. + * @param lockType - Type of lock to acquire. + * @param owner - Owner of the lock. + * @returns The acquisition result. + */ +export function handleToolLock( + toolName: string, + path: string, + lockType: PathLockType = "write", + owner: ClaimOwner, +): LockAcquisitionResult { + const config = getConfig(); + const doAutoClaim = shouldAutoClaim(toolName); + + if (doAutoClaim) { + return autoClaim({ + path, + lockType, + owner, + autoReleaseTTL: config.autoReleaseTTL, + reason: `Auto-claimed by ${toolName}`, + }); + } + + return acquireLock({ + path, + lockType, + owner, + autoReleaseTTL: config.autoReleaseTTL, + reason: `Acquired by ${toolName}`, + }); +} + +/** + * Check if a tool call should be blocked due to lock contention. + * + * @param toolName - Name of the tool. + * @param path - File path. + * @returns A blocking result (with reason) or null. + */ +export function checkToolBlocking( + toolName: string, + path: string, +): { block: true; reason: string } | null { + const config = getConfig(); + + if (!config.blockedTools.includes(toolName)) { + return null; + } + + if (!isFileLocked(path)) { + return null; + } + + const info = getLockInfo(path); + return { + block: true, + reason: buildBlockingError(path, info.lockType, info.locks), + }; +} + +// --------------------------------------------------------------------------- +// Expiry-aware lock checking +// --------------------------------------------------------------------------- + +/** + * Check if a lock on a file has expired. + * + * @param path - File path. + * @returns `true` if the lock has expired. + */ +export function isLockExpired(path: string): boolean { + const registry = getClaimRegistry(); + const claims = registry.getActiveClaims(path); + + for (const claim of claims) { + if (claim.expiresAt) { + const expiresAt = new Date(claim.expiresAt).getTime(); + if (expiresAt <= Date.now()) { + return true; + } + } + } + + return false; +} + +/** + * Release all expired locks on a file. + * + * @param path - File path. + * @returns Number of locks released. + */ +export function releaseExpiredLocks(path: string): number { + const registry = getClaimRegistry(); + const claims = registry.getActiveClaims(path); + let released = 0; + + for (const claim of claims) { + if (claim.expiresAt) { + const expiresAt = new Date(claim.expiresAt).getTime(); + if (expiresAt <= Date.now()) { + registry.release(claim.id); + released++; + } + } + } + + return released; +} + +/** + * Clean up expired locks across all files. + * + * @returns Number of locks released. + */ +export function cleanupExpiredLocks(): number { + const registry = getClaimRegistry(); + const allClaims = Object.values(registry.claims); + let released = 0; + + for (const claim of allClaims) { + if (claim.status === "active" && claim.expiresAt) { + const expiresAt = new Date(claim.expiresAt).getTime(); + if (expiresAt <= Date.now()) { + registry.release(claim.id); + released++; + } + } + } + + return released; +} + +// --------------------------------------------------------------------------- +// Concurrent access helpers +// --------------------------------------------------------------------------- + +/** + * Check for concurrent access to a file by comparing claim IDs. + * + * This is useful for detecting when multiple sessions have claimed the same file. + * + * @param path - File path. + * @param sessionId - Session ID to exclude (the current session). + * @returns List of concurrent claims from other sessions. + */ +export function getConcurrentAccess( + path: string, + sessionId: string, +): FileClaim[] { + const registry = getClaimRegistry(); + const claims = registry.getActiveClaims(path); + + return claims.filter((c) => c.owner.sessionId !== sessionId); +} + +/** + * Build a report of concurrent file access. + * + * @param path - File path. + * @param sessionId - Current session ID. + * @returns A formatted report string. + */ +export function buildConcurrentAccessReport( + path: string, + sessionId: string, +): string { + const concurrent = getConcurrentAccess(path, sessionId); + const info = getLockInfo(path); + + if (concurrent.length === 0) { + return `No concurrent access to "${path}".`; + } + + const lines: string[] = [ + `Concurrent access to "${path}":`, + ` Your claim: ${info.primaryLock?.lockType} (${info.owner.type} · ${info.owner.id})`, + ` Concurrent: ${concurrent.length} claim(s)`, + ]; + + for (const c of concurrent) { + lines.push( + ` - ${c.owner.type}(${c.owner.id}) [${c.lockType}]${ + c.expiresAt ? ` (expires: ${formatRelativeTime(c.expiresAt)})` : "" + }`, + ); + } + + return lines.join("\n"); +} diff --git a/src/lock-manager.ts b/src/lock-manager.ts new file mode 100644 index 0000000..7ebff32 --- /dev/null +++ b/src/lock-manager.ts @@ -0,0 +1,955 @@ +/** + * lock-manager.ts — Core lock management module with atomic file operations, + * TTL checking, and cross-process synchronization. + * + * This module provides: + * 1. **Atomic file utilities** — write-to-temp-then-rename pattern for safe + * file updates, even under crash scenarios. + * 2. **Cross-process coordination** — advisory file locking via O_EXCL so + * multiple Pi sessions can share the same lock directory safely. + * 3. **LockManager class** — a file-backed persistence layer for the claim + * registry, with per-entry CRUD, TTL-based expiry detection, and cleanup. + * + * ## Usage + * + * ```ts + * import { LockManager, atomicWriteJson, withLockFile } from "./lock-manager"; + * + * const mgr = new LockManager("/path/to/locks"); + * await mgr.init(); + * + * // Persist a new claim + * await mgr.saveClaim(claim); + * + * // Load all active claims from disk + * const claims = await mgr.loadAllClaims(); + * + * // Clean up expired entries + * const cleaned = await mgr.cleanupExpired(); + * ``` + * + * ## File layout + * + * ``` + * {lockDir}/ + * coord.lock — coordination mutex (created with O_EXCL) + * registry.json — serialised claim + lock entries + * ``` + * + * @module file-claiming/lock-manager + */ + +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import { hostname as osHostname } from "node:os"; +import { + type ClaimStatus, + type FileClaim, + type LockEntry, +} from "./lock-types"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Name of the coordination lock file inside the lock directory. */ +const COORD_LOCK_FILE = "coord.lock"; + +/** Name of the serialised registry file inside the lock directory. */ +const REGISTRY_FILE = "registry.json"; + +/** Suffix appended to temp files before atomic rename. */ +const TMP_SUFFIX = ".tmp"; + +/** Default maximum wait time for acquiring the coordination lock (ms). */ +const DEFAULT_COORD_WAIT_MS = 5_000; + +/** Interval between retry attempts when acquiring the coordination lock. */ +const COORD_RETRY_INTERVAL_MS = 50; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** + * On-disk format for the serialised registry. + */ +interface RegistryData { + claims: Record; + locks: Record; + meta: { + updatedAt: string; + version: number; + }; +} + +/** + * Content written into the coordination lock file so stale locks can be + * detected by checking whether the owning PID is still alive. + */ +interface CoordLockContent { + owner: string; + pid: number; + hostname: string; + acquiredAt: string; +} + +// =================================================================== +// SECTION 1 – Atomic file-operation utilities +// =================================================================== + +/** + * Write `data` to `filePath` atomically using the write-to-temp-then-rename + * pattern. + * + * 1. Serialise `data` as pretty-printed JSON. + * 2. Write to a temporary file (`filePath + ".tmp"`). + * 3. Atomically rename the temp file over the target path. + * + * On POSIX filesystems `rename()` is atomic when source and destination + * reside on the same mount point, so concurrent readers either see the + * old content or the new content — never a partial/corrupt file. + * + * @param filePath - Destination file path. + * @param data - Data to serialise and write. Passed through + * `JSON.stringify(data, null, 2)`. + */ +export async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tmpPath = filePath + TMP_SUFFIX; + const json = JSON.stringify(data, null, 2); + await fsPromises.writeFile(tmpPath, json, "utf-8"); + await fsPromises.rename(tmpPath, filePath); +} + +/** + * Read and deserialise a JSON file. + * + * Returns `null` when the file does not exist so callers can handle the + * first-write / empty-store case without catching an exception. + * + * @param filePath - Path to the JSON file to read. + * @returns The deserialised value, or `null` if the file is missing. + */ +export async function atomicReadJson(filePath: string): Promise { + try { + const raw = await fsPromises.readFile(filePath, "utf-8"); + return JSON.parse(raw) as T; + } catch (err: unknown) { + if (err && typeof err === "object" && "code" in err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === "ENOENT") return null; + } + throw err; + } +} + +/** + * Synchronous check for whether a file exists. + * + * A thin wrapper around `fs.existsSync` that is safe to call from + * constructors or sync contexts. + */ +export function fileExists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +// =================================================================== +// SECTION 2 – Cross-process coordination +// =================================================================== + +/** + * Try to acquire an advisory file lock using `O_CREAT | O_EXCL`. + * + * The lock file stores a small JSON payload with the owner id, PID, and + * hostname. If the file already exists (`EEXIST`) the function checks + * whether the lock is **stale** — i.e. the PID that created it is no + * longer running. Stale locks are removed automatically and the + * acquisition is retried. + * + * @param lockFilePath - Absolute path to the coordination lock file. + * @param ownerId - A short string identifying the caller (e.g. session + * ID or process label). + * @param maxWaitMs - Maximum time to wait before giving up (default 5 s). + * @returns `true` if the lock was acquired, `false` on timeout. + */ +export async function acquireLockFile( + lockFilePath: string, + ownerId: string, + maxWaitMs: number = DEFAULT_COORD_WAIT_MS, +): Promise { + const start = Date.now(); + const content: CoordLockContent = { + owner: ownerId, + pid: process.pid, + hostname: hostname(), + acquiredAt: new Date().toISOString(), + }; + const contentStr = JSON.stringify(content); + + while (Date.now() - start < maxWaitMs) { + try { + const fd = await fsPromises.open( + lockFilePath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 0o644, + ); + await fd.writeFile(contentStr, "utf-8"); + await fd.close(); + return true; + } catch (err: unknown) { + if (err && typeof err === "object" && "code" in err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === "EEXIST") { + // Check if the existing lock is stale + if (await isLockFileStale(lockFilePath)) { + await fsPromises.unlink(lockFilePath).catch(() => {}); + continue; + } + // Wait before retrying + await sleep(COORD_RETRY_INTERVAL_MS); + continue; + } + } + throw err; + } + } + + return false; // timed out +} + +/** + * Release a coordination lock file by deleting it. + * + * No error is thrown if the file has already been removed (e.g. by a + * crash-recovery mechanism). + * + * @param lockFilePath - Absolute path to the coordination lock file. + */ +export async function releaseLockFile(lockFilePath: string): Promise { + try { + await fsPromises.unlink(lockFilePath); + } catch { + // Ignore — file may already be gone + } +} + +/** + * Acquire a coordination lock, execute `fn`, and release the lock. + * + * @param lockFilePath - Absolute path to the coordination lock file. + * @param ownerId - Short identifier for the caller. + * @param fn - Async function to run while holding the lock. + * @param maxWaitMs - Maximum time to wait for the lock (default 5 s). + * @returns The return value of `fn`. + */ +export async function withLockFile( + lockFilePath: string, + ownerId: string, + fn: () => Promise, + maxWaitMs?: number, +): Promise { + const acquired = await acquireLockFile(lockFilePath, ownerId, maxWaitMs); + if (!acquired) { + throw new Error( + `[lock-manager] Failed to acquire coordination lock "${lockFilePath}" ` + + `after ${maxWaitMs ?? DEFAULT_COORD_WAIT_MS}ms (owner: ${ownerId})`, + ); + } + try { + return await fn(); + } finally { + await releaseLockFile(lockFilePath); + } +} + +/** + * Determine whether a lock file is stale by checking if the owning process + * is still alive. + * + * Reads the JSON payload from the lock file and calls `process.kill(pid, 0)`, + * which is a POSIX no-op that only checks whether the process exists. + * + * @param lockFilePath - Path to the lock file. + * @returns `true` if the lock file does not exist, cannot be parsed, or the + * PID that created it is no longer running. + */ +async function isLockFileStale(lockFilePath: string): Promise { + try { + const raw = await fsPromises.readFile(lockFilePath, "utf-8"); + const data: CoordLockContent = JSON.parse(raw); + try { + // Signal 0 tests whether the process exists without sending a signal. + process.kill(data.pid, 0); + return false; // Process is alive + } catch { + return true; // ESRCH or EPERM → process is dead + } + } catch { + return true; // File missing or invalid + } +} + +// =================================================================== +// SECTION 3 – Hostname helper +// =================================================================== + +/** Cached hostname (avoids repeated system calls). */ +let _hostname: string | null = null; + +function hostname(): string { + if (_hostname === null) { + try { + _hostname = osHostname(); + } catch { + _hostname = "unknown"; + } + } + return _hostname!; +} + +// =================================================================== +// SECTION 4 – Promise-based sleep +// =================================================================== + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// =================================================================== +// SECTION 5 – LockManager class +// =================================================================== + +/** + * Statistics snapshot returned by {@link LockManager.getStats}. + */ +export interface LockManagerStats { + totalClaims: number; + activeClaims: number; + expiredClaims: number; + totalLockPaths: number; + lockDir: string; + registryExists: boolean; +} + +/** + * Result returned by {@link LockManager.syncFromDisk}. + */ +export interface SyncFromDiskResult { + /** All claims loaded from the on-disk registry. */ + diskClaims: FileClaim[]; + /** Subset of `diskClaims` that are expired. */ + expired: FileClaim[]; + /** Whether the on-disk registry was newer than the in-memory snapshot. */ + wasUpdated: boolean; +} + +/** + * Result returned by {@link LockManager.cleanupExpired}. + */ +export interface CleanupResult { + /** Number of claims that were marked as expired. */ + expiredCount: number; + /** The claims that were expired. */ + expiredClaims: FileClaim[]; +} + +/** + * File-backed lock manager that persists the claim registry to disk and + * coordinates access across multiple processes. + * + * ## Thread / process safety + * + * Within a single Node.js process all operations are safe because JS is + * single-threaded. Across processes, the `coord.lock` file (created with + * `O_EXCL`) serialises read-modify-write cycles on `registry.json`. + * + * ## Crash safety + * + * - `registry.json` is always written via write-to-temp-then-rename, so a + * crash during writing leaves the previous version intact. + * - The coordination lock is released on process exit via a best-effort + * `beforeExit` handler. If a process crashes while holding the lock, + * the stale-lock detection in {@link acquireLockFile} cleans it up. + */ +export class LockManager { + /** Absolute path to the lock directory. */ + private readonly lockDir: string; + + /** Absolute path to the coordination lock file. */ + private readonly coordLockPath: string; + + /** Absolute path to the serialised registry file. */ + private readonly registryPath: string; + + /** Whether this instance currently owns the coordination lock. */ + private hasCoordLock: boolean = false; + + /** Identifier passed to `acquireCoordLock` (for diagnostics). */ + private coordOwnerId: string | null = null; + + // ----------------------------------------------------------------------- + // Construction and initialisation + // ----------------------------------------------------------------------- + + /** + * @param lockDir - Absolute path to a directory that will hold lock state + * files. The directory is created on first use if it + * does not exist. + */ + constructor(lockDir: string) { + this.lockDir = lockDir; + this.coordLockPath = path.resolve(lockDir, COORD_LOCK_FILE); + this.registryPath = path.resolve(lockDir, REGISTRY_FILE); + + // Best-effort release of the coordination lock on process exit. + // This is a safety net — in normal operation callers should use + // withCoordLock or manually acquire/release. + process.on("beforeExit", () => { + if (this.hasCoordLock) { + fs.unlink(this.coordLockPath, () => {}); + this.hasCoordLock = false; + } + }); + } + + /** + * Ensure the lock directory exists on disk. + * + * Must be called once before any other method. Safe to call multiple + * times (idempotent). + */ + async init(): Promise { + await fsPromises.mkdir(this.lockDir, { recursive: true }); + } + + // ----------------------------------------------------------------------- + // Public helpers + // ----------------------------------------------------------------------- + + /** + * Resolve the absolute path to the registry file. + * Useful for external monitoring or diagnostics. + */ + getRegistryFilePath(): string { + return this.registryPath; + } + + /** + * Resolve the absolute path to the coordination lock file. + */ + getCoordLockFilePath(): string { + return this.coordLockPath; + } + + /** + * Return the configured lock directory. + */ + getLockDir(): string { + return this.lockDir; + } + + // ----------------------------------------------------------------------- + // Cross-process coordination + // ----------------------------------------------------------------------- + + /** + * Acquire the coordination lock for this LockManager instance. + * + * After a successful call the lock is held until {@link releaseCoordLock} + * is called. Use {@link withCoordLock} for the common acquire → work → + * release pattern. + * + * @param ownerId - Short identifier for the caller. + * @param maxWaitMs - Maximum time to wait (default 5 s). + * @returns `true` if the lock was acquired. + */ + async acquireCoordLock( + ownerId: string, + maxWaitMs: number = DEFAULT_COORD_WAIT_MS, + ): Promise { + if (this.hasCoordLock) return true; // Re-entrant within same instance + + const ok = await acquireLockFile(this.coordLockPath, ownerId, maxWaitMs); + if (ok) { + this.hasCoordLock = true; + this.coordOwnerId = ownerId; + } + return ok; + } + + /** + * Release the coordination lock if this instance holds it. + */ + async releaseCoordLock(): Promise { + if (!this.hasCoordLock) return; + await releaseLockFile(this.coordLockPath); + this.hasCoordLock = false; + this.coordOwnerId = null; + } + + /** + * Acquire the coordination lock, execute `fn`, and release the lock. + * + * @param ownerId - Short identifier for the caller. + * @param fn - Async function to run while holding the lock. + * @param maxWaitMs - Maximum time to wait for the lock. + * @returns The return value of `fn`. + */ + async withCoordLock( + ownerId: string, + fn: () => Promise, + maxWaitMs?: number, + ): Promise { + const acquired = await this.acquireCoordLock(ownerId, maxWaitMs); + if (!acquired) { + throw new Error( + `[lock-manager] Failed to acquire coordination lock after ` + + `${maxWaitMs ?? DEFAULT_COORD_WAIT_MS}ms (owner: ${ownerId})`, + ); + } + try { + return await fn(); + } finally { + await this.releaseCoordLock(); + } + } + + // ----------------------------------------------------------------------- + // Registry persistence (full-store operations) + // ----------------------------------------------------------------------- + + /** + * Atomically persist the full registry (claims + locks) to disk. + * + * @param claims - Claims map keyed by claim ID. + * @param locks - Lock entries keyed by file path. + */ + async saveRegistry( + claims: Record, + locks: Record, + ): Promise { + const data: RegistryData = { + claims, + locks, + meta: { + updatedAt: new Date().toISOString(), + version: 1, + }, + }; + await atomicWriteJson(this.registryPath, data); + } + + /** + * Load the full registry from disk. + * + * Returns an empty registry `{ claims: {}, locks: {} }` when the file + * does not exist yet (first run). + */ + async loadRegistry(): Promise<{ + claims: Record; + locks: Record; + }> { + const data = await atomicReadJson(this.registryPath); + if (!data) { + return { claims: {}, locks: {} }; + } + return { + claims: data.claims ?? {}, + locks: data.locks ?? {}, + }; + } + + // ----------------------------------------------------------------------- + // Individual claim operations + // ----------------------------------------------------------------------- + + /** + * Persist a single claim to the registry. + * + * If a claim with the same `id` already exists it is overwritten. + * Uses the coordination lock to prevent races with other processes. + * + * @param claim - The claim to save. + */ + async saveClaim(claim: FileClaim): Promise { + await this.withCoordLock(`save-claim`, async () => { + const registry = await this.loadRegistry(); + registry.claims[claim.id] = claim; + await this.saveRegistry(registry.claims, registry.locks); + }); + } + + /** + * Load a single claim by its ID from the persisted registry. + * + * @param claimId - The unique claim identifier. + * @returns The claim, or `undefined` if not found. + */ + async loadClaim(claimId: string): Promise { + const registry = await this.loadRegistry(); + return registry.claims[claimId]; + } + + /** + * Delete a claim and its associated lock entries from the registry. + * + * @param claimId - The claim ID to remove. + * @returns `true` if the claim existed and was deleted, `false` otherwise. + */ + async deleteClaim(claimId: string): Promise { + return this.withCoordLock(`delete-claim`, async () => { + const registry = await this.loadRegistry(); + if (!registry.claims[claimId]) return false; + + delete registry.claims[claimId]; + + // Remove any lock entries that reference this claim + for (const [lockPath, entries] of Object.entries(registry.locks)) { + const filtered = entries.filter((e) => e.claimId !== claimId); + if (filtered.length === 0) { + delete registry.locks[lockPath]; + } else { + registry.locks[lockPath] = filtered; + } + } + + await this.saveRegistry(registry.claims, registry.locks); + return true; + }); + } + + /** + * Return all claims currently stored in the persisted registry. + */ + async loadAllClaims(): Promise { + const registry = await this.loadRegistry(); + return Object.values(registry.claims); + } + + /** + * Return all claims with a specific status. + * + * @param status - Status to filter by. + */ + async loadClaimsByStatus(status: ClaimStatus): Promise { + const registry = await this.loadRegistry(); + return Object.values(registry.claims).filter((c) => c.status === status); + } + + /** + * Return all claims targeting a specific file path. + * + * @param filePath - File path to filter by. + */ + async loadClaimsByPath(filePath: string): Promise { + const registry = await this.loadRegistry(); + return Object.values(registry.claims).filter((c) => c.path === filePath); + } + + // ----------------------------------------------------------------------- + // Individual lock-entry operations + // ----------------------------------------------------------------------- + + /** + * Persist a single lock entry. + * + * If an entry with the same `claimId` already exists under the same + * `path`, it is replaced. Otherwise the entry is appended. + * + * @param entry - The lock entry to save. + */ + async saveLockEntry(entry: LockEntry): Promise { + await this.withCoordLock(`save-lock-entry`, async () => { + const registry = await this.loadRegistry(); + const entries = registry.locks[entry.path] ?? []; + const idx = entries.findIndex((e) => e.claimId === entry.claimId); + if (idx >= 0) { + entries[idx] = entry; + } else { + entries.push(entry); + } + registry.locks[entry.path] = entries; + await this.saveRegistry(registry.claims, registry.locks); + }); + } + + /** + * Remove a lock entry by claim ID and path. + * + * @param claimId - The claim ID whose lock entry to remove. + * @param lockPath - The file path the lock entry targets. + */ + async removeLockEntry(claimId: string, lockPath: string): Promise { + await this.withCoordLock(`remove-lock-entry`, async () => { + const registry = await this.loadRegistry(); + const entries = registry.locks[lockPath] ?? []; + const filtered = entries.filter((e) => e.claimId !== claimId); + if (filtered.length === 0) { + delete registry.locks[lockPath]; + } else { + registry.locks[lockPath] = filtered; + } + await this.saveRegistry(registry.claims, registry.locks); + }); + } + + /** + * Load lock entries for a specific file path. + * + * @param lockPath - The file path whose lock entries to retrieve. + */ + async loadLockEntries(lockPath: string): Promise { + const registry = await this.loadRegistry(); + return registry.locks[lockPath] ?? []; + } + + /** + * Load all lock entries, keyed by file path. + */ + async loadAllLockEntries(): Promise> { + const registry = await this.loadRegistry(); + return registry.locks; + } + + // ----------------------------------------------------------------------- + // TTL-based expiration + // ----------------------------------------------------------------------- + + /** + * Check whether a single claim is expired. + * + * A claim is considered expired when: + * - Its status is `"active"` + * - It has an `expiresAt` timestamp + * - The `expiresAt` timestamp is in the past (or exactly now) + * + * Claims without an `expiresAt` field never expire through this check + * (they rely on explicit release or the sweeper's idle TTL). + * + * @param claim - The claim to check. + */ + isExpired(claim: FileClaim): boolean { + if (claim.status !== "active") return false; + if (!claim.expiresAt) return false; + return new Date(claim.expiresAt).getTime() <= Date.now(); + } + + /** + * Find all expired claims in the persisted registry without modifying it. + */ + async findExpired(): Promise { + const registry = await this.loadRegistry(); + return Object.values(registry.claims).filter((c) => this.isExpired(c)); + } + + /** + * Scan the persisted registry and **mark** all expired claims as `expired`, + * removing their lock entries. + * + * This is the file-backed equivalent of the in-memory `sweepExpiredClaims` + * function in `index.ts`. It uses the coordination lock so that only one + * process runs cleanup at a time. + * + * @returns A result with the count and list of claims that were expired. + */ + async cleanupExpired(): Promise { + return this.withCoordLock("cleanup-expired", async () => { + const registry = await this.loadRegistry(); + const now = new Date().toISOString(); + const expired: FileClaim[] = []; + + // Identify expired claims + for (const [id, claim] of Object.entries(registry.claims)) { + if (this.isExpired(claim)) { + expired.push(claim); + // Update status + registry.claims[id] = { + ...claim, + status: "expired", + updatedAt: now, + }; + } + } + + // Remove lock entries for expired claims + const expiredIds = new Set(expired.map((c) => c.id)); + for (const [lockPath, entries] of Object.entries(registry.locks)) { + const filtered = entries.filter((e) => !expiredIds.has(e.claimId)); + if (filtered.length === 0) { + delete registry.locks[lockPath]; + } else { + registry.locks[lockPath] = filtered; + } + } + + await this.saveRegistry(registry.claims, registry.locks); + + return { expiredCount: expired.length, expiredClaims: expired }; + }); + } + + /** + * Remove all claims and lock entries that are older than `maxAgeMs` + * regardless of their `expiresAt` field. + * + * This is a hard-age-based cleanup (not TTL-based) useful for sweeping + * orphaned entries that never had an `expiresAt` set. + * + * @param maxAgeMs - Maximum age in milliseconds. Claims whose + * `createdAt` is older than this threshold are removed entirely. + * @returns The number of claims removed. + */ + async sweepOlderThan(maxAgeMs: number): Promise { + return this.withCoordLock("sweep-older-than", async () => { + const registry = await this.loadRegistry(); + const cutoff = Date.now() - maxAgeMs; + const toRemove: string[] = []; + + for (const [id, claim] of Object.entries(registry.claims)) { + if (new Date(claim.createdAt).getTime() < cutoff) { + toRemove.push(id); + } + } + + for (const id of toRemove) { + delete registry.claims[id]; + for (const [lockPath, entries] of Object.entries(registry.locks)) { + const filtered = entries.filter((e) => e.claimId !== id); + if (filtered.length === 0) { + delete registry.locks[lockPath]; + } else { + registry.locks[lockPath] = filtered; + } + } + } + + if (toRemove.length > 0) { + await this.saveRegistry(registry.claims, registry.locks); + } + + return toRemove.length; + }); + } + + // ----------------------------------------------------------------------- + // Cross-process synchronisation + // ----------------------------------------------------------------------- + + /** + * Load the current state from disk and return it alongside metadata + * about expired entries and staleness. + * + * Unlike {@link cleanupExpired} this method is read-only — it does not + * modify the persisted state. + */ + async syncFromDisk(): Promise { + const registry = await this.loadRegistry(); + const diskClaims = Object.values(registry.claims); + const expired = diskClaims.filter((c) => this.isExpired(c)); + + return { + diskClaims, + expired, + wasUpdated: diskClaims.length > 0, + }; + } + + /** + * Atomically replace the entire on-disk registry with the provided + * in-memory state. + * + * Use this when the in-memory registry (in `index.ts`) has accumulated + * changes and you want a clean write-through to disk. + * + * @param claims - Full claims map. + * @param locks - Full locks map keyed by path. + */ + async syncToDisk( + claims: Record, + locks: Record, + ): Promise { + await this.withCoordLock("sync-to-disk", async () => { + await this.saveRegistry(claims, locks); + }); + } + + // ----------------------------------------------------------------------- + // Merge helpers + // ----------------------------------------------------------------------- + + /** + * Merge claims from disk into an in-memory claims map. + * + * In-memory claims take precedence on ID collision (the in-memory value + * wins). Disk-only claims are added to the result. + * + * @param memoryClaims - Current in-memory claims map. + * @returns A merged claims map combining disk and in-memory state. + */ + async mergeFromDisk( + memoryClaims: Record, + ): Promise> { + const registry = await this.loadRegistry(); + const merged = { ...registry.claims, ...memoryClaims }; + return merged; + } + + // ----------------------------------------------------------------------- + // Statistics + // ----------------------------------------------------------------------- + + /** + * Gather statistics about the current persisted registry state. + */ + async getStats(): Promise { + const registry = await this.loadRegistry(); + const allClaims = Object.values(registry.claims); + + return { + totalClaims: allClaims.length, + activeClaims: allClaims.filter((c) => c.status === "active").length, + expiredClaims: allClaims.filter((c) => c.status === "expired").length, + totalLockPaths: Object.keys(registry.locks).length, + lockDir: this.lockDir, + registryExists: fs.existsSync(this.registryPath), + }; + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /** + * Release the coordination lock if held and clean up resources. + * + * Call this when the extension shuts down to ensure the lock file + * is not left behind. + */ + async destroy(): Promise { + await this.releaseCoordLock(); + } +} + +// =================================================================== +// SECTION 6 – Convenience factory +// =================================================================== + +/** + * Create a {@link LockManager} configured from the current + * `FileClaimingConfig`. + * + * The lock directory defaults to `config.lockDir` (from `config.ts`). + * Callers can override it by passing an explicit `lockDir` argument. + * + * @param lockDir - Optional explicit lock directory. Falls back to + * the configured `lockDir` from the extension config. + */ +export async function createLockManager(lockDir?: string): Promise { + const { getConfig } = await import("./config"); + const config = getConfig(); + const dir = lockDir ?? config.lockDir; + const mgr = new LockManager(dir); + await mgr.init(); + return mgr; +} diff --git a/src/lock-types.ts b/src/lock-types.ts new file mode 100644 index 0000000..3fab2a1 --- /dev/null +++ b/src/lock-types.ts @@ -0,0 +1,270 @@ +/** + * lock-types.ts — TypeScript type definitions for file claims and locks. + * + * This module defines the core data structures for the File Claiming + * extension. A "claim" represents an intent to read, modify, or exclusively + * own a file path. The registry tracks all active claims so downstream + * tools and events can detect conflicts, enforce ordering, and provide + * visibility into concurrent file access. + * + * @module file-claiming/lock-types + */ + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/** + * The type of lock a claim holds on a file path. + * + * - `read` — Shared read access. Multiple read claims may coexist. + * - `write` — Exclusive write access. Only one write claim per path. + * - `exclusive` — Full exclusive access. No other claim of any type allowed. + */ +export type PathLockType = "read" | "write" | "exclusive"; + +/** + * The lifecycle status of a claim. + * + * - `pending` — Claim has been requested but not yet granted. + * - `active` — Claim is currently held and active. + * - `released` — Claim has been voluntarily released. + * - `conflicted`— Claim could not be granted due to a conflict. + * - `expired` — Claim timed out or was invalidated. + */ +export type ClaimStatus = "pending" | "active" | "released" | "conflicted" | "expired"; + +/** + * Severity level for conflict resolution. + */ +export type ConflictSeverity = "info" | "warning" | "error"; + +// --------------------------------------------------------------------------- +// Core interfaces +// --------------------------------------------------------------------------- + +/** + * Metadata identifying the entity that owns a claim. + * + * @example + * ```ts + * const owner: ClaimOwner = { + * type: "tool", + * id: "my_tool", + * sessionId: "session-abc123", + * }; + * ``` + */ +export interface ClaimOwner { + /** Origin type: a registered tool, the current agent, or an extension. */ + type: "tool" | "agent" | "extension"; + /** Unique identifier within the owner type (e.g. tool name, extension ID). */ + id: string; + /** Optional session identifier for scoping claims to a session lifetime. */ + sessionId?: string; +} + +/** + * A single claim on a file path. + * + * Claims represent an intent to access a file and are tracked in the + * {@link ClaimRegistry}. Tools and extensions create claims before + * operating on files so the registry can detect conflicts with other + * concurrent operations. + */ +export interface FileClaim { + /** Globally unique claim identifier (e.g. UUID v4). */ + id: string; + /** Absolute or workspace-relative file path this claim targets. */ + path: string; + /** The type of lock being claimed. */ + lockType: PathLockType; + /** Current lifecycle status of the claim. */ + status: ClaimStatus; + /** Owner responsible for this claim. */ + owner: ClaimOwner; + /** ISO-8601 timestamp when the claim was created. */ + createdAt: string; + /** ISO-8601 timestamp when the claim was last updated. */ + updatedAt: string; + /** Optional ISO-8601 timestamp after which the claim auto-expires. */ + expiresAt?: string; + /** Optional human-readable reason for the claim. */ + reason?: string; +} + +/** + * Describes a conflict between two or more claims on the same path. + */ +export interface ClaimConflict { + /** The file path where the conflict occurred. */ + path: string; + /** Severity of the conflict. */ + severity: ConflictSeverity; + /** The claim that is being blocked or challenged. */ + blockedClaim: FileClaim; + /** Claims that are blocking {@link blockedClaim}. */ + blockingClaims: FileClaim[]; + /** Human-readable explanation of the conflict. */ + message: string; +} + +/** + * Result of attempting to acquire a claim. + */ +export interface ClaimResult { + /** Whether the claim was successfully acquired. */ + success: boolean; + /** The acquired claim (if successful) or the attempted claim (if rejected). */ + claim: FileClaim; + /** Conflict details when `success` is `false`. */ + conflict?: ClaimConflict; +} + +/** + * A resolved lock entry stored in the registry for a specific path. + * + * Unlike {@link FileClaim} which is a request/agreement, a `LockEntry` + * is the bookkeeping record the registry uses to track active locks. + */ +export interface LockEntry { + /** The file path this lock applies to. */ + path: string; + /** The type of lock held. */ + lockType: PathLockType; + /** The claim ID that established this lock. */ + claimId: string; + /** The owner holding the lock. */ + owner: ClaimOwner; + /** ISO-8601 timestamp when the lock was acquired. */ + acquiredAt: string; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +/** + * The central registry holding all active claims and locks. + * + * The registry is the single source of truth for which files are currently + * claimed. Extensions and tools query it before operating on files and + * update it when claims are created, released, or modified. + */ +export interface ClaimRegistry { + /** + * All claims currently tracked by the registry (any status). + * Indexed by claim ID for O(1) lookups. + */ + claims: Record; + + /** + * Active locks grouped by file path. + * Each entry maps a path to the current lock(s) on that path. + * Read-locks may have multiple entries; write/exclusive locks will + * have at most one. + */ + locks: Record; + + /** + * Returns all active claims (status === "active") for a given path. + */ + getActiveClaims(path: string): FileClaim[]; + + /** + * Returns the lock entries currently held on a given path. + */ + getLocks(path: string): LockEntry[]; + + /** + * Checks whether a proposed claim would conflict with existing locks. + * + * @param path The file path to check. + * @param lockType The lock type being requested. + * @param owner The owner requesting the claim (excluded from conflict + * detection so the same owner can hold multiple read claims). + * @returns The first conflict found, or `undefined` if the path is clear. + */ + checkConflict(path: string, lockType: PathLockType, owner: ClaimOwner): ClaimConflict | undefined; + + /** + * Attempts to acquire a claim and register it. + * + * @param claim The claim to acquire (must have a unique `id`). + * @returns A result indicating success or failure with conflict details. + */ + acquire(claim: FileClaim): ClaimResult; + + /** + * Releases a claim by its ID, removing its lock entries. + * + * @param claimId The ID of the claim to release. + * @returns `true` if the claim was found and released, `false` otherwise. + */ + release(claimId: string): boolean; + + /** + * Releases all claims owned by a specific owner (e.g. on tool completion). + */ + releaseAllByOwner(owner: ClaimOwner): void; +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/** + * Names of events emitted by the claim registry. + */ +export type ClaimEventType = + | "claim:acquired" + | "claim:released" + | "claim:conflicted" + | "claim:expired" + | "claim:status_changed"; + +/** + * Payload carried by claim registry events. + */ +export interface ClaimEvent { + /** The type of event that occurred. */ + type: ClaimEventType; + /** The claim involved in the event. */ + claim: FileClaim; + /** Optional conflict information (present on `claim:conflicted`). */ + conflict?: ClaimConflict; + /** ISO-8601 timestamp of the event. */ + timestamp: string; +} + +// --------------------------------------------------------------------------- +// Options & configuration +// --------------------------------------------------------------------------- + +/** + * Configuration options for the File Claiming extension. + */ +export interface FileClaimingOptions { + /** + * Default TTL (in milliseconds) for claims that do not specify + * an explicit `expiresAt`. Claims exceeding this age are eligible + * for automatic expiry. Set to `0` to disable auto-expiry. + * + * @default 30_000 (30 seconds) + */ + defaultClaimTTL: number; + + /** + * Interval (in milliseconds) at which the expiry sweeper runs. + * + * @default 5_000 (5 seconds) + */ + sweepInterval: number; + + /** + * Whether to emit `claim:conflicted` events as UI notifications. + * + * @default true + */ + notifyOnConflict: boolean; +} diff --git a/src/notifications.ts b/src/notifications.ts new file mode 100644 index 0000000..b6d9b3b --- /dev/null +++ b/src/notifications.ts @@ -0,0 +1,317 @@ +/** + * notifications.ts — Notification system for lock events. + * + * Listens to lock events and sends notifications to the user. + * Supports different notification types and severity levels. + * + * @module file-claiming/notifications + */ + +import type { EventBus } from "@earendil-works/pi-coding-agent"; +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { + ClaimEventType, + ClaimEvent, + ClaimConflict, + FileClaim, + LockEntry, +} from "./lock-types"; +import { getConfig } from "./config"; +import { createDiagnosticEvent, createDiagnosticEvent as createDiagEvent } from "./diagnostics"; +import type { DiagnosticEventType } from "./diagnostics"; + +// --------------------------------------------------------------------------- +// Notification types +// --------------------------------------------------------------------------- + +/** + * Types of lock notifications. + */ +export type LockNotificationType = + | "claim:acquired" + | "claim:released" + | "claim:conflicted" + | "claim:expired" + | "diagnostic:added" + | "diagnostic:removed" + | "diagnostic:updated"; + +/** + * Severity level for notifications. + */ +export type NotificationSeverity = "info" | "warning" | "error"; + +/** + * A notification message for the user. + */ +export interface LockNotification { + /** Type of the notification. */ + type: LockNotificationType; + /** Severity level. */ + severity: NotificationSeverity; + /** Short title for the notification. */ + title: string; + /** Full message body. */ + message: string; + /** ISO-8601 timestamp. */ + timestamp: string; + /** Optional claim data. */ + claim?: FileClaim; + /** Optional conflict data. */ + conflict?: ClaimConflict; +} + +// --------------------------------------------------------------------------- +// Notification builders +// --------------------------------------------------------------------------- + +/** + * Build a notification from a claim event. + */ +export function claimEventToNotification(event: ClaimEvent): LockNotification { + const config = getConfig(); + const claim = event.claim; + + switch (event.type) { + case "claim:acquired": + return { + type: "claim:acquired", + severity: "info", + title: `Lock Acquired: ${claim.lockType}`, + message: `Claimed "${claim.path}" with ${claim.lockType} lock by ${claim.owner.type} (${claim.owner.id})${claim.reason ? ` — ${claim.reason}` : ""}${config.autoReleaseTTL > 0 ? `\nAuto-release: ${config.autoReleaseTTL}ms` : ""}`, + timestamp: event.timestamp, + claim, + }; + + case "claim:released": + return { + type: "claim:released", + severity: "info", + title: `Lock Released`, + message: `Released "${claim.path}" (${claim.lockType} lock) by ${claim.owner.type} (${claim.owner.id})`, + timestamp: event.timestamp, + claim, + }; + + case "claim:conflicted": + return { + type: "claim:conflicted", + severity: "warning", + title: `Lock Conflict`, + message: event.conflict + ? `Cannot acquire "${claim.lockType}" lock on "${claim.path}": ${event.conflict.message}` + : `Conflict acquiring lock on "${claim.path}"`, + timestamp: event.timestamp, + claim, + conflict: event.conflict, + }; + + case "claim:expired": + return { + type: "claim:expired", + severity: "info", + title: `Lock Expired`, + message: `Auto-expired "${claim.path}" (${claim.lockType} lock) — TTL exceeded`, + timestamp: event.timestamp, + claim, + }; + + default: + return { + type: "claim:acquired", + severity: "info", + title: "Lock Event", + message: `Event: ${event.type} on "${claim.path}"`, + timestamp: event.timestamp, + claim, + }; + } +} + +/** + * Build a notification from a diagnostic event. + */ +export function diagnosticEventToNotification( + event: ReturnType, +): LockNotification { + switch (event.type) { + case "diagnostic:added": + return { + type: "diagnostic:added", + severity: event.diagnostic?.severity ?? "info", + title: "Lock Status", + message: event.diagnostic?.message ?? `Diagnostics added for "${event.uri}"`, + timestamp: event.timestamp, + }; + + case "diagnostic:removed": + return { + type: "diagnostic:removed", + severity: "info", + title: "Lock Status", + message: `Diagnostics removed for "${event.uri}"`, + timestamp: event.timestamp, + }; + + case "diagnostic:updated": + return { + type: "diagnostic:updated", + severity: event.diagnostic?.severity ?? "info", + title: "Lock Updated", + message: event.diagnostic?.message ?? `Diagnostics updated for "${event.uri}"`, + timestamp: event.timestamp, + }; + + default: + return { + type: "diagnostic:added", + severity: "info", + title: "Lock Status", + message: `Diagnostics refreshed for "${event.uri}"`, + timestamp: event.timestamp, + }; + } +} + +// --------------------------------------------------------------------------- +// Notification handler +// --------------------------------------------------------------------------- + +/** + * Handler for lock events that sends notifications. + */ +export interface LockNotificationHandler { + /** Handle a lock event and send a notification. */ + handleEvent(event: ClaimEvent): void; + /** Handle a diagnostic event. */ + handleDiagnostic(event: ReturnType): void; + /** Get all pending notifications. */ + getNotifications(): LockNotification[]; + /** Clear all notifications. */ + clearNotifications(): void; +} + +/** + * Create a lock notification handler backed by the UI context. + */ +export function createLockNotificationHandler( + ui: ExtensionUIContext, + eventBus: EventBus, +): LockNotificationHandler { + const notifications: LockNotification[] = []; + + // Subscribe to lock events on the event bus + const unsubLock = eventBus.on("claim:acquired", (data: unknown) => { + handleClaimEvent(data as ClaimEvent); + }); + const unsubRelease = eventBus.on("claim:released", (data: unknown) => { + handleClaimEvent(data as ClaimEvent); + }); + const unsubConflict = eventBus.on("claim:conflicted", (data: unknown) => { + handleClaimEvent(data as ClaimEvent); + }); + const unsubExpired = eventBus.on("claim:expired", (data: unknown) => { + handleClaimEvent(data as ClaimEvent); + }); + + function handleClaimEvent(event: ClaimEvent) { + const notification = claimEventToNotification(event); + notifications.push(notification); + + // Only show notifications if enabled in config + if (getConfig().showDiagnostics) { + const severity: NotificationSeverity = notification.severity; + ui.notify(notification.message, severity === "error" ? "error" : "info"); + } + } + + return { + handleEvent(event: ClaimEvent) { + const notification = claimEventToNotification(event); + notifications.push(notification); + if (getConfig().showDiagnostics) { + ui.notify(notification.message, notification.severity === "error" ? "error" : "info"); + } + }, + + handleDiagnostic(event: ReturnType) { + const notification = diagnosticEventToNotification(event); + notifications.push(notification); + }, + + getNotifications() { + return [...notifications]; + }, + + clearNotifications() { + notifications.length = 0; + }, + }; +} + +// --------------------------------------------------------------------------- +// Notification formatting +// --------------------------------------------------------------------------- + +/** + * Format a notification as a string for display. + */ +export function formatNotification(notification: LockNotification): string { + const icon = + notification.severity === "error" + ? "❌" + : notification.severity === "warning" + ? "⚠️" + : "ℹ️"; + + return [ + `${icon} ${notification.title}`, + notification.message, + notification.claim + ? `\n Claim ID: ${notification.claim.id}` + : "", + notification.conflict + ? `\n Conflict: ${notification.conflict.message}` + : "", + ].join("\n"); +} + +/** + * Format all notifications as a summary. + */ +export function formatNotificationsSummary(notifications: LockNotification[]): string { + if (notifications.length === 0) { + return "No lock notifications."; + } + + const lines = [ + `🔔 Lock Notifications (${notifications.length})`, + "", + ]; + + // Group by type + const grouped = new Map(); + for (const n of notifications) { + const existing = grouped.get(n.type) ?? []; + existing.push(n); + grouped.set(n.type, existing); + } + + for (const [type, items] of grouped) { + const icon = + items[0].severity === "error" + ? "❌" + : items[0].severity === "warning" + ? "⚠️" + : "ℹ️"; + lines.push(`${icon} ${type}: ${items.length}`); + for (const item of items.slice(0, 3)) { + lines.push(` - ${item.title}`); + } + if (items.length > 3) { + lines.push(` ... and ${items.length - 3} more`); + } + } + + return lines.join("\n"); +} diff --git a/src/system-prompt.ts b/src/system-prompt.ts new file mode 100644 index 0000000..912b1c0 --- /dev/null +++ b/src/system-prompt.ts @@ -0,0 +1,173 @@ +/** + * system-prompt.ts — System prompt injection for lock claiming protocol. + * + * Injects lock claiming instructions into the agent's system prompt so that + * the agent knows how to acquire, hold, and release file claims during tool + * execution. + * + * @module file-claiming/system-prompt + */ + +import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent"; +import { getConfig } from "./config"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Lock claiming protocol instructions that are injected into the system prompt. + * + * These instructions are appended to the default system prompt when the + * extension is active. + */ +const LOCK_CLAIMING_INSTRUCTIONS = '\n' + +'## Lock Claiming Protocol\n' + +'\n' + +'You hold a file claiming system to prevent conflicts when working with files.\n' + +'This system tracks read, write, and exclusive claims on file paths.\n' + +'\n' + +'### Claim Types\n' + +'- **read** \u2014 Shared read access. Multiple tools can hold read claims simultaneously.\n' + +'- **write** \u2014 Exclusive write access. Only one write claim per file path.\n' + +'- **exclusive** \u2014 Full exclusive access. No other claim of any type allowed.\n' + +'\n' + +'### How Locks Work\n' + +'1. Before editing or writing to a file, check if it has an active claim.\n' + +'2. If a conflicting lock exists, release your claim first or wait for it to expire.\n' + +'3. When you claim a file, other tools may be blocked until you release.\n' + +'4. Claims have a TTL (time-to-live) and will auto-release if you forget.\n' + +'\n' + +'### Auto-Release Behavior\n' + +'- Claims are automatically released when your turn ends (unless disabled).\n' + +'- Default TTL: {autoReleaseTTL}ms. Claims expire after this time.\n' + +'- You can check active claims with the `file_claiming_list` tool.\n' + +'- You can check a specific file with the `file_claiming_check` tool.\n' + +'\n' + +'### Conflict Resolution\n' + +'- If you try to claim a file that is locked by another tool, you will receive a conflict notification.\n' + +'- Conflicting locks show the blocker\'s claim ID, type, and owner.\n' + +'- You can release conflicting claims with the `file_claiming_release` tool.\n' + +'\n' + +'### Best Practices\n' + +'- Claim files before editing to prevent conflicts.\n' + +'- Release claims after editing to unblock other tools.\n' + +'- Check for locks before starting large edits.\n' + +'- Use "write" lock for normal edits, "exclusive" for critical operations.\n' + +'\n' + +'### Releasing Claims\n' + +'- Claims are released automatically at turn end by default.\n' + +'- You can manually release with `file_claiming_release` if needed.\n' + +'- If a claim is blocking another tool, release it to unblock.\n' + +''; + +/** + * Build the lock claiming instructions section with current config values. + */ +export function buildLockClaimingInstructions(): string { + const config = getConfig(); + return LOCK_CLAIMING_INSTRUCTIONS.replace( + "{autoReleaseTTL}", + String(config.autoReleaseTTL), + ); +} + +/** + * Build the guidelines section for lock claiming. + * + * These guidelines are appended to the default system prompt's guidelines + * section when the extension is active. + */ +export function buildLockClaimingGuidelines(): string[] { + const config = getConfig(); + return [ + 'Use the `file_claiming_claim` tool to claim files before editing them.', + `Use the \`file_claiming_release\` tool to release a claim (TTL: ${config.autoReleaseTTL}ms).`, + `Use the \`file_claiming_list\` tool to see all active claims.`, + `Use the \`file_claiming_check\` tool to check a specific file's lock status.`, + config.releaseOnTurnEnd + ? 'Claims are released automatically at the end of each turn.' + : 'Claims persist across turns; release them explicitly.', + ]; +} + +/** + * Build the prompt snippet for the lock claiming tools. + * + * These one-line snippets appear in the "Available tools" section of + * the default system prompt. + */ +export function buildLockClaimingToolSnippets(): Record { + return { + file_claiming_claim: "Claim a file with read/write/exclusive lock", + file_claiming_release: "Release a file claim by ID or path", + file_claiming_list: "List all active file claims", + file_claiming_check: "Check lock status for a specific file", + }; +} + +// --------------------------------------------------------------------------- +// System prompt injection +// --------------------------------------------------------------------------- + +/** + * Inject lock claiming instructions into the system prompt. + * + * This function takes the options that will be used to build the system prompt + * and returns a new options object with lock claiming content added. + * + * @param options - The build options for the system prompt. + * @param enabled - Whether to inject lock claiming content (default: true). + * @returns A new options object with injected content. + */ +export function injectLockClaimingIntoPrompt( + options: BuildSystemPromptOptions, + enabled: boolean = true, +): BuildSystemPromptOptions { + if (!enabled) { + return options; + } + + const config = getConfig(); + if (!config.showDiagnostics) { + return options; + } + + const instructions = buildLockClaimingInstructions(); + const guidelines = buildLockClaimingGuidelines(); + const snippets = buildLockClaimingToolSnippets(); + + return { + ...options, + promptGuidelines: [...(options.promptGuidelines ?? []), ...guidelines], + toolSnippets: { ...options.toolSnippets, ...snippets }, + appendSystemPrompt: options.appendSystemPrompt + ? `${options.appendSystemPrompt}\n${instructions}` + : instructions, + }; +} + +// --------------------------------------------------------------------------- +// Hook into before_agent_start +// --------------------------------------------------------------------------- + +/** + * Create a handler for the `before_agent_start` event that injects lock + * claiming instructions into the system prompt. + * + * This handler is designed to be registered with `pi.on("before_agent_start", ...)`. + */ +export function createBeforeAgentStartHandler(): ( + event: { systemPrompt: string; systemPromptOptions: BuildSystemPromptOptions }, + ctx: { getSystemPromptOptions(): BuildSystemPromptOptions }, +) => Promise<{ systemPrompt?: string }> { + return async () => { + // This is called during system prompt construction. + // We inject by updating the options, which the runner will use. + const options = injectLockClaimingIntoPrompt( + { cwd: "." }, + getConfig().showDiagnostics, + ); + return { systemPrompt: options.appendSystemPrompt ?? "" }; + }; +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..bcd88a7 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,428 @@ +/** + * tools.ts — Lock management tools registered with Pi. + * + * Provides four tools that the LLM can call during tool execution: + * - `file_claiming_claim` — Claim a file with a lock + * - `file_claiming_release` — Release a claim by ID or path + * - `file_claiming_list` — List all active claims + * - `file_claiming_check` — Check lock status for a file + * + * @module file-claiming/tools + */ + +import { Type, type Static } from "typebox"; +import type { + ToolDefinition, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { AgentToolResult } from "@earendil-works/pi-agent-core"; +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { getClaimRegistry } from "../index"; +import { getConfig } from "./config"; +import { + claimToDiagnostic, + formatDiagnostics, + buildDiagnosticCollection, +} from "./diagnostics"; +import type { ClaimOwner, PathLockType } from "./lock-types"; +import { + acquireLock, + autoClaim, + isFileLocked, + getLockInfo, + isMutationTool, + shouldAutoClaim, + handleToolLock, + buildConflictMessage, + buildBlockingError, + getConcurrentAccess, +} from "./lock-acquisition"; + +/** Update the footer status with current claim count. */ +function updateFooterStatus(ctx: ExtensionContext): void { + const registry = getClaimRegistry(); + const activeCount = Object.values(registry.claims).filter( + (c) => c.status === "active", + ).length; + if (ctx?.ui?.setStatus) { + ctx.ui.setStatus("file-claiming", `Claims: ${activeCount} active`); + } +} + +/** Generate a unique claim ID using the Web Crypto API. */ +function generateClaimId(): string { + return crypto.randomUUID(); +} + +// --------------------------------------------------------------------------- +// Helper to get the current owner +// --------------------------------------------------------------------------- + +function getCurrentOwner(ctx: ExtensionContext): ClaimOwner { + const sessionId = ctx.sessionManager.getSessionFile(); + return { + type: "agent", + id: "main", + sessionId, + }; +} + +// --------------------------------------------------------------------------- +// Tool schemas +// --------------------------------------------------------------------------- + +const claimSchema = Type.Object({ + path: Type.String({ description: "File path to claim" }), + lockType: Type.Union( + [Type.Literal("read"), Type.Literal("write"), Type.Literal("exclusive")], + { description: "Type of lock to acquire" }, + ), + reason: Type.Optional( + Type.String({ description: "Optional reason for claiming" }), + ), +}); + +const releaseSchema = Type.Object({ + claimId: Type.Optional( + Type.String({ description: "Claim ID to release (if known)" }), + ), + path: Type.Optional( + Type.String({ description: "File path to release claims on" }), + ), +}); + +const listSchema = Type.Object({ + path: Type.Optional(Type.String({ description: "Optional path filter" })), +}); + +const checkSchema = Type.Object({ + path: Type.String({ description: "File path to check" }), + lockType: Type.Optional( + Type.Union( + [Type.Literal("read"), Type.Literal("write"), Type.Literal("exclusive")], + { description: "Lock type to check" }, + ), + ), +}); + +// --------------------------------------------------------------------------- +// Tool implementations +// --------------------------------------------------------------------------- + +/** + * Claim a file with a lock. + * + * This tool supports both manual claiming and auto-claim behavior. + * When called, it checks if the file is already locked and resolves + * conflicts automatically. + */ +export const fileClaimingClaimTool = defineTool({ + name: "file_claiming_claim", + label: "Claim File", + description: + "Claim a file with a read, write, or exclusive lock. Supports auto-claim on first edit. " + + "Returns the claim ID, lock status, and auto-release time. " + + "Use this before editing files to prevent conflicts with other tools.", + promptSnippet: "Claim a file with read/write/exclusive lock", + parameters: claimSchema, + execute: async ( + _toolCallId: string, + params: Static, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + ctx: ExtensionContext, + ): Promise< + AgentToolResult<{ diagnostic: ReturnType }> + > => { + const registry = getClaimRegistry(); + const config = getConfig(); + const owner = getCurrentOwner(ctx); + + // Use auto-claim if the file is not already claimed + const result = autoClaim({ + path: params.path, + lockType: params.lockType, + owner, + autoReleaseTTL: config.autoReleaseTTL, + reason: params.reason, + }); + + if (result.success) { + const diagnostic = claimToDiagnostic(result.claim!, registry); + updateFooterStatus(ctx); + return { + content: [ + { + type: "text" as const, + text: + `${result.message}\n` + + `Claim ID: ${result.claim!.id}\n` + + `Owner: ${result.claim!.owner.type} (${result.claim!.owner.id})\n` + + (result.claim!.expiresAt + ? `Auto-release: ${result.claim!.expiresAt}` + : ""), + }, + ], + details: { diagnostic }, + }; + } + + return { + content: [ + { + type: "text" as const, + text: `Failed to claim "${params.path}":\n${result.message}`, + }, + ], + details: { diagnostic: claimToDiagnostic(result.claim!, registry) }, + }; + }, +}); + +/** + * Release a claim by ID or path. + * + * Also supports bulk release and expired lock cleanup. + */ +export const fileClaimingReleaseTool = defineTool({ + name: "file_claiming_release", + label: "Release Claim", + description: + "Release a file claim. Provide either a claim ID or a file path. " + + "If a path is given, all claims on that path are released. " + + "If a claim ID is given, only that specific claim is released. " + + "Use path='*' to release all claims.", + promptSnippet: "Release a file claim by ID or path", + parameters: releaseSchema, + execute: async ( + _toolCallId: string, + params: Static, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + ctx: ExtensionContext, + ): Promise> => { + const registry = getClaimRegistry(); + const released: string[] = []; + + // Special case: release all claims + if (params.path === "*" && !params.claimId) { + const allClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + for (const claim of allClaims) { + registry.release(claim.id); + released.push(claim.id); + } + updateFooterStatus(ctx); + return { + content: [ + { + type: "text" as const, + text: + released.length > 0 + ? `Released ${released.length} claim(s):\n${released.map((id) => ` - ${id}`).join("\n")}` + : "No claims to release.", + }, + ], + details: { released }, + }; + } + + if (params.claimId) { + const success = registry.release(params.claimId); + if (success) { + released.push(params.claimId); + } + } + + if (params.path) { + const claims = registry.getActiveClaims(params.path); + for (const claim of claims) { + registry.release(claim.id); + released.push(claim.id); + } + } + + // If neither was provided, release all active claims + if (!params.claimId && !params.path) { + const allClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + for (const claim of allClaims) { + registry.release(claim.id); + released.push(claim.id); + } + } + + return { + content: [ + { + type: "text" as const, + text: + released.length > 0 + ? `Released ${released.length} claim(s):\n${released.map((id) => ` - ${id}`).join("\n")}` + : "No claims to release.", + }, + ], + details: { released }, + }; + }, +}); + +/** + * List all active claims. + */ +export const fileClaimingListTool = defineTool({ + name: "file_claiming_list", + label: "List Claims", + description: + "List all active file claims with details about lock type, owner, and " + + "auto-release time. Optionally filter by file path.", + promptSnippet: "List all active file claims", + parameters: listSchema, + execute: async ( + _toolCallId: string, + params: Static, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: ExtensionContext, + ): Promise< + AgentToolResult<{ + collection: ReturnType; + }> + > => { + const registry = getClaimRegistry(); + const collection = buildDiagnosticCollection(registry); + + // Filter by path if provided + let items = Array.from(collection.diagnostics.entries()); + if (params.path) { + items = items.filter(([uri]) => uri === params.path); + } + + const formatted = formatDiagnostics(collection); + const filtered = + items.length > 0 + ? `Filtered to "${params.path}":\n${formatDiagnostics(collection)}` + : formatted; + + return { + content: [ + { + type: "text" as const, + text: + items.length > 0 + ? filtered + : `No active claims${params.path ? ` for "${params.path}"` : ""}.`, + }, + ], + details: { collection }, + }; + }, +}); + +/** + * Check lock status for a specific file. + * + * Shows detailed lock information including concurrent access, + * auto-release time, and lock compatibility. + */ +export const fileClaimingCheckTool = defineTool({ + name: "file_claiming_check", + label: "Check Lock", + description: + "Check the lock status for a specific file. Shows active claims, " + + "lock type, owner, auto-release time, concurrent access, and " + + "whether the file is free for a given lock type.", + promptSnippet: "Check lock status for a specific file", + parameters: checkSchema, + execute: async ( + _toolCallId: string, + params: Static, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: ExtensionContext, + ): Promise> => { + const registry = getClaimRegistry(); + const config = getConfig(); + const lockType = params.lockType ?? "read"; + const owner = getCurrentOwner(_ctx); + + // Use the lock acquisition module for detailed info + const info = getLockInfo(params.path); + const conflict = registry.checkConflict(params.path, lockType, owner); + + const lines: string[] = [`Lock status for "${params.path}":`]; + + if (info.locked) { + lines.push( + ` 🔒 LOCKED (${info.lockType} lock)`, + ` Holder: ${info.owner.type} (${info.owner.id})`, + ` Auto-release: ${info.autoReleaseIn}`, + ); + + for (const claim of info.claims) { + const autoRelease = claim.expiresAt + ? new Date(claim.expiresAt).toISOString() + : "N/A"; + lines.push( + ` - ${claim.lockType} by ${claim.owner.type}(${claim.owner.id}) ` + + `until ${autoRelease}${claim.reason ? ` [${claim.reason}]` : ""}`, + ); + } + + // Show concurrent access + const concurrent = getConcurrentAccess( + params.path, + owner.sessionId ?? "", + ); + if (concurrent.length > 0) { + lines.push( + ` Concurrent access: ${concurrent.length} claim(s) from other sessions`, + ); + } + } else { + lines.push( + ` ✅ FREE — no active locks`, + ` Can acquire ${lockType} lock.`, + ); + } + + if (conflict) { + lines.push(` ⚠️ Conflict: ${conflict.message}`); + } + + return { + content: [ + { + type: "text" as const, + text: lines.join("\n"), + }, + ], + details: { checked: true }, + }; + }, +}); + +// --------------------------------------------------------------------------- +// Tool registration +// --------------------------------------------------------------------------- + +/** + * Register all lock management tools with the Pi extension API. + * + * Also registers the auto-claim tool that automatically claims files + * on first edit attempt. + * + * @param pi - The extension API instance. + */ +export function registerLockTools( + pi: Pick & { + registerTool: typeof import("@earendil-works/pi-coding-agent").defineTool; + }, +): void { + pi.registerTool(fileClaimingClaimTool); + pi.registerTool(fileClaimingReleaseTool); + pi.registerTool(fileClaimingListTool); + pi.registerTool(fileClaimingCheckTool); +} diff --git a/src/user-interaction.ts b/src/user-interaction.ts new file mode 100644 index 0000000..61558f7 --- /dev/null +++ b/src/user-interaction.ts @@ -0,0 +1,335 @@ +/** + * user-interaction.ts — User interaction components for lock queries. + * + * Provides interactive components that allow users to query and manage + * file locks through the TUI. + * + * @module file-claiming/user-interaction + */ + +import type { + ExtensionUIContext, + ExtensionContext, + ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import type { ClaimRegistry } from "./lock-types"; +import { getClaimRegistry, resetRegistry } from "../index"; +import { getConfig } from "./config"; +import { formatDiagnostics, buildDiagnosticCollection } from "./diagnostics"; +import { formatNotificationsSummary } from "./notifications"; +import type { + LockNotification, + LockNotificationHandler, +} from "./notifications"; + +// --------------------------------------------------------------------------- +// Lock query components +// --------------------------------------------------------------------------- + +/** + * Show an interactive lock query dialog. + * + * Displays a list of active claims and allows the user to select one + * to view details or release. + */ +export async function showLockQueryDialog( + ui: ExtensionUIContext, + registry: ClaimRegistry, +): Promise { + const collection = buildDiagnosticCollection(registry); + const formatted = formatDiagnostics(collection); + + const result = await ui.select( + "File Claims", + [ + "View all claims", + "Check a specific file", + "View notifications", + "Release all claims", + ], + { timeout: 30_000 }, + ); + + if (!result) return; + + switch (result) { + case "View all claims": + ui.notify(formatted, "info"); + break; + + case "Check a specific file": { + const path = await ui.input( + "File Path", + "Enter file path (e.g., /path/to/file.ts)", + { timeout: 15_000 }, + ); + if (path) { + const claims = registry.getActiveClaims(path); + if (claims.length > 0) { + ui.notify( + `Active claims for "${path}":\n${claims.map((c) => ` - ${c.lockType} by ${c.owner.type} (${c.owner.id})`).join("\n")}`, + "info", + ); + } else { + ui.notify(`No active claims for "${path}".`, "info"); + } + } + break; + } + + case "View notifications": { + // This would require access to the notification handler + // For now, show a placeholder + ui.notify( + "Viewing notifications... (use /file-claiming-notify to see recent events)", + "info", + ); + break; + } + + case "Release all claims": { + const confirmed = await ui.confirm( + "Release All Claims", + `Release all ${collection.count} active claims?`, + { timeout: 10_000 }, + ); + if (confirmed) { + const claims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + for (const claim of claims) { + registry.release(claim.id); + } + ui.notify(`Released ${claims.length} claim(s).`, "info"); + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// Lock status widget component +// --------------------------------------------------------------------------- + +/** + * Create a lock status widget that shows active claims. + * + * This widget is updated periodically when claims change. + */ +export function createLockStatusWidget( + registry: ClaimRegistry, +): () => string[] { + return () => { + const config = getConfig(); + const collection = buildDiagnosticCollection(registry); + const lines: string[] = []; + + lines.push(`Claims: ${collection.count} active`); + + if (collection.count > 0) { + lines.push(""); + lines.push("Active locks:"); + for (const [uri, items] of collection.diagnostics) { + const icon = items[0]?.severity === "error" ? "❌" : "ℹ️"; + lines.push(` ${icon} ${uri} (${items[0]?.lockType})`); + } + } + + return lines; + }; +} + +// --------------------------------------------------------------------------- +// Lock command handler +// --------------------------------------------------------------------------- + +/** + * Create a command handler for `/file-claiming-locks`. + * + * This command provides interactive lock management. + */ +export function createLockCommandHandler( + getRegistry: () => ClaimRegistry, +): (args: string, ctx: ExtensionCommandContext) => Promise { + return async (args: string, ctx: ExtensionCommandContext) => { + const registry = getRegistry(); + const trimmed = args.trim(); + + if (!trimmed) { + // Interactive mode: show dialog + await showLockQueryDialog(ctx.ui, registry); + return; + } + + // Parse command arguments + const [action, ...rest] = trimmed.split(/\s+/); + + switch (action) { + case "list": { + const path = rest[0]; + const collection = path + ? { + ...buildDiagnosticCollection(registry), + diagnostics: new Map([ + [ + path, + buildDiagnosticCollection(registry).diagnostics.get(path) ?? + [], + ], + ]), + } + : buildDiagnosticCollection(registry); + ctx.ui.notify(formatDiagnostics(collection), "info"); + break; + } + + case "check": { + const path = rest[0]; + if (!path) { + ctx.ui.notify("Usage: /file-claiming-locks check ", "warning"); + return; + } + const claims = registry.getActiveClaims(path); + if (claims.length > 0) { + ctx.ui.notify( + `Active claims for "${path}":\n${claims.map((c) => ` - ${c.lockType} by ${c.owner.type} (${c.owner.id})`).join("\n")}`, + "info", + ); + } else { + ctx.ui.notify(`No active claims for "${path}".`, "info"); + } + break; + } + + case "release": { + const path = rest[0]; + const claimId = rest[1]; + if (path) { + const claims = registry.getActiveClaims(path); + for (const claim of claims) { + registry.release(claim.id); + } + ctx.ui.notify( + `Released ${claims.length} claim(s) on "${path}".`, + "info", + ); + } else if (claimId) { + const success = registry.release(claimId); + ctx.ui.notify( + success + ? `Released claim "${claimId}".` + : `Claim "${claimId}" not found.`, + success ? "info" : "error", + ); + } else { + // Release all + const claims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + for (const claim of claims) { + registry.release(claim.id); + } + ctx.ui.notify(`Released ${claims.length} claim(s).`, "info"); + } + break; + } + + case "reset": { + resetRegistry(); + ctx.ui.notify("Registry reset. All claims cleared.", "info"); + break; + } + + default: + ctx.ui.notify( + `Unknown action: "${action}".\n` + + "Usage:\n" + + " /file-claiming-locks — interactive mode\n" + + " /file-claiming-locks list [path] — list claims\n" + + " /file-claiming-locks check — check a file\n" + + " /file-claiming-locks release [path] [id] — release claims\n" + + " /file-claiming-locks reset — reset registry", + "info", + ); + } + }; +} + +// --------------------------------------------------------------------------- +// Notification command handler +// --------------------------------------------------------------------------- + +/** + * Create a command handler for `/file-claiming-notify`. + */ +export function createNotifyCommandHandler( + getNotifications: () => LockNotification[], +): (args: string, ctx: ExtensionCommandContext) => Promise { + return async (args: string, ctx: ExtensionCommandContext) => { + const notifications = getNotifications(); + const trimmed = args.trim(); + + if (trimmed === "clear") { + ctx.ui.notify("Notifications cleared.", "info"); + return; + } + + ctx.ui.notify(formatNotificationsSummary(notifications), "info"); + }; +} + +// --------------------------------------------------------------------------- +// State persistence +// --------------------------------------------------------------------------- + +/** + * Persist lock state to the session for recovery after reload. + */ +export function persistLockState(pi: { + appendEntry: (type: string, data?: unknown) => void; +}): void { + const registry = getClaimRegistry(); + const activeClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + + pi.appendEntry("file-claiming-state", { + version: "1.0.0", + activeClaims, + timestamp: new Date().toISOString(), + }); +} + +/** + * Restore lock state from the session. + */ +export function restoreLockState(pi: { + getSessionName: () => string | undefined; +}): boolean { + const sessionName = pi.getSessionName(); + if (!sessionName) return false; + + // In a real implementation, this would read from session entries. + // For now, we return false and rely on in-memory state. + return false; +} + +// --------------------------------------------------------------------------- +// Status bar integration +// --------------------------------------------------------------------------- + +/** + * Update the status bar with lock information. + */ +export function updateLockStatus( + ui: ExtensionUIContext, + registry: ClaimRegistry, +): void { + const config = getConfig(); + const activeClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + const lockCount = activeClaims.length; + + ui.setStatus("file-claiming", `Claims: ${lockCount} active`); +} diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..2118224 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,373 @@ +/** + * config.test.ts — Unit tests for the configuration module. + * + * Tests cover: + * - Default configuration creation + * - Validation of each config field + * - Partial and full updates via setConfig + * - File persistence (save/load) + * - Config file path resolution + * - Reset to defaults + * - Edge cases (edge values, missing files, invalid JSON) + * + * @module file-claiming/config.test + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { assert, createTempDir, cleanupTempDir } from "./test-utils.ts"; + +// --------------------------------------------------------------------------- +// Module under test +// --------------------------------------------------------------------------- + +import { + createDefaultConfig, + validateConfig, + getConfig, + setConfig, + resetConfig, + getConfigFilePath, + loadConfigFromFile, + saveConfigToFile, +} from "../src/config"; + +// --------------------------------------------------------------------------- +// Test: Default configuration +// --------------------------------------------------------------------------- + +function testDefaultConfig() { + const def = createDefaultConfig(); + assert(typeof def.autoReleaseTTL === "number", "autoReleaseTTL is a number"); + assert(def.autoReleaseTTL === 300_000, "Default TTL is 300000ms"); + assert(def.releaseOnTurnEnd === true, "Default releaseOnTurnEnd is true"); + assert(typeof def.lockDir === "string", "lockDir is a string"); + assert(def.lockDir.length > 0, "lockDir is non-empty"); + assert(Array.isArray(def.blockedTools), "blockedTools is an array"); + assert(def.blockedTools.includes("edit"), "blockedTools includes 'edit'"); + assert(def.blockedTools.includes("write"), "blockedTools includes 'write'"); + assert(def.showDiagnostics === true, "Default showDiagnostics is true"); + + const def2 = createDefaultConfig(); + assert(def !== def2, "Each call returns a fresh object"); + assert( + JSON.stringify(def) === JSON.stringify(def2), + "Fresh copies have same values", + ); + + console.log("✅ Config: default configuration is correct"); +} + +// --------------------------------------------------------------------------- +// Test: Validation +// --------------------------------------------------------------------------- + +function testValidation() { + let result = validateConfig({ autoReleaseTTL: 600_000 }); + assert(result.valid === true, "Valid autoReleaseTTL passes"); + assert(result.errors.length === 0, "No errors for valid config"); + + result = validateConfig({ releaseOnTurnEnd: false }); + assert(result.valid === true, "Valid releaseOnTurnEnd passes"); + + result = validateConfig({ showDiagnostics: false }); + assert(result.valid === true, "Valid showDiagnostics passes"); + + result = validateConfig({ blockedTools: [] }); + assert(result.valid === true, "Empty blockedTools is valid"); + + result = validateConfig({ blockedTools: ["edit", "write", "bash"] }); + assert(result.valid === true, "Multiple blockedTools is valid"); + + result = validateConfig({ lockDir: "/tmp/locks" }); + assert(result.valid === true, "Valid lockDir passes"); + + result = validateConfig({ autoReleaseTTL: 0 }); + assert(result.valid === true, "Zero autoReleaseTTL is valid"); + + result = validateConfig({ autoReleaseTTL: -1 } as any); + assert(result.valid === false, "Negative autoReleaseTTL is invalid"); + + result = validateConfig({ autoReleaseTTL: "abc" } as any); + assert(result.valid === false, "String autoReleaseTTL is invalid"); + + result = validateConfig({ autoReleaseTTL: NaN } as any); + assert(result.valid === false, "NaN autoReleaseTTL is invalid"); + + result = validateConfig({ releaseOnTurnEnd: "yes" } as any); + assert(result.valid === false, "String releaseOnTurnEnd is invalid"); + + result = validateConfig({ lockDir: "" } as any); + assert(result.valid === false, "Empty lockDir is invalid"); + + result = validateConfig({ lockDir: 123 } as any); + assert(result.valid === false, "Number lockDir is invalid"); + + result = validateConfig({ blockedTools: "edit" } as any); + assert(result.valid === false, "String blockedTools is invalid"); + + result = validateConfig({ blockedTools: [1, 2] } as any); + assert(result.valid === false, "Number array blockedTools is invalid"); + + result = validateConfig({ showDiagnostics: 1 } as any); + assert(result.valid === false, "Number showDiagnostics is invalid"); + + result = validateConfig({ + autoReleaseTTL: "bad", + releaseOnTurnEnd: "bad", + showDiagnostics: "bad", + } as any); + assert(result.valid === false, "Multiple invalid fields produce errors"); + assert(result.errors.length >= 3, "Multiple errors reported"); + + console.log("✅ Config: validation works correctly"); +} + +// --------------------------------------------------------------------------- +// Test: Runtime get/set +// --------------------------------------------------------------------------- + +function testGetSet() { + resetConfig(); + + const def = getConfig(); + assert(def.autoReleaseTTL === 300_000, "getConfig returns defaults"); + + const result = setConfig({ autoReleaseTTL: 600_000 }); + assert(result.valid === true, "setConfig with valid value succeeds"); + assert(result.errors.length === 0, "No errors"); + + const updated = getConfig(); + assert(updated.autoReleaseTTL === 600_000, "Config value updated"); + assert(updated.releaseOnTurnEnd === true, "Other values unchanged"); + + const fail = setConfig({ autoReleaseTTL: -1 } as any); + assert(fail.valid === false, "setConfig with invalid value fails"); + assert(fail.errors.length > 0, "Error reported"); + + const unchanged = getConfig(); + assert(unchanged.autoReleaseTTL === 600_000, "Config unchanged on failure"); + + setConfig({ + autoReleaseTTL: 30_000, + releaseOnTurnEnd: false, + showDiagnostics: false, + }); + const multi = getConfig(); + assert(multi.autoReleaseTTL === 30_000, "Multi-set: TTL updated"); + assert( + multi.releaseOnTurnEnd === false, + "Multi-set: releaseOnTurnEnd updated", + ); + assert(multi.showDiagnostics === false, "Multi-set: showDiagnostics updated"); + + resetConfig(); + console.log("✅ Config: runtime get/set works correctly"); +} + +// --------------------------------------------------------------------------- +// Test: Config file path resolution +// --------------------------------------------------------------------------- + +function testConfigFilePath() { + const path = getConfigFilePath(); + assert( + path.endsWith("config.json"), + "Config file path ends with config.json", + ); + + const customPath = getConfigFilePath("/tmp/custom-locks"); + assert( + customPath.startsWith("/tmp/custom-locks"), + "Custom lockDir reflected", + ); + assert( + customPath.endsWith("config.json"), + "Custom path ends with config.json", + ); + + console.log("✅ Config: file path resolution works"); +} + +// --------------------------------------------------------------------------- +// Test: File persistence (save/load) +// --------------------------------------------------------------------------- + +async function testFilePersistence() { + resetConfig(); + const tempDir = createTempDir("config-test-"); + + setConfig({ lockDir: tempDir }); + await saveConfigToFile(); + + const configPath = getConfigFilePath(tempDir); + assert(existsSync(configPath), "Config file created on disk"); + + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw); + assert(parsed.autoReleaseTTL === 300_000, "Saved config has correct TTL"); + assert( + parsed.releaseOnTurnEnd === true, + "Saved config has correct releaseOnTurnEnd", + ); + + setConfig({ autoReleaseTTL: 600_000 }); + await saveConfigToFile(); + + const raw2 = readFileSync(configPath, "utf-8"); + const parsed2 = JSON.parse(raw2); + assert(parsed2.autoReleaseTTL === 600_000, "Updated config saved correctly"); + + setConfig({ autoReleaseTTL: 1000 }); + await loadConfigFromFile(configPath); + const afterReload = getConfig(); + assert( + afterReload.autoReleaseTTL === 600_000, + "Loaded config overrides in-memory", + ); + + const nonExistent = join(tempDir, "nonexistent", "config.json"); + await loadConfigFromFile(nonExistent); + const stillLoaded = getConfig(); + assert( + stillLoaded.autoReleaseTTL === 600_000, + "Non-existent file keeps current config", + ); + + resetConfig(); + cleanupTempDir(tempDir); + console.log("✅ Config: file persistence works"); +} + +// --------------------------------------------------------------------------- +// Test: Load from corrupted file +// --------------------------------------------------------------------------- + +async function testCorruptedFile() { + resetConfig(); + const tempDir = createTempDir("config-corrupt-"); + const configPath = getConfigFilePath(tempDir); + + mkdirSync(tempDir, { recursive: true }); + writeFileSync(configPath, "{invalid json}", "utf-8"); + + const result = await loadConfigFromFile(configPath); + assert(result.autoReleaseTTL === 300_000, "Corrupted file keeps defaults"); + + writeFileSync( + configPath, + JSON.stringify({ autoReleaseTTL: "invalid", releaseOnTurnEnd: false }), + "utf-8", + ); + + await loadConfigFromFile(configPath); + const after = getConfig(); + assert( + after.autoReleaseTTL === 300_000, + "Invalid field skipped, keeps default", + ); + assert( + after.releaseOnTurnEnd === false, + "Valid field applied from corrupted file", + ); + + resetConfig(); + cleanupTempDir(tempDir); + console.log("✅ Config: corrupted file handling works"); +} + +// --------------------------------------------------------------------------- +// Test: Reset to defaults +// --------------------------------------------------------------------------- + +function testReset() { + setConfig({ autoReleaseTTL: 999_999, showDiagnostics: false }); + const modified = getConfig(); + assert(modified.autoReleaseTTL === 999_999, "Modified TTL"); + + resetConfig(); + const restored = getConfig(); + assert(restored.autoReleaseTTL === 300_000, "Reset restores default TTL"); + assert( + restored.showDiagnostics === true, + "Reset restores default showDiagnostics", + ); + console.log("✅ Config: reset to defaults works"); +} + +// --------------------------------------------------------------------------- +// Test: Config immutability +// --------------------------------------------------------------------------- + +function testImmutability() { + resetConfig(); + const first = getConfig(); + const second = getConfig(); + (first as any).autoReleaseTTL = 999_999; + assert( + second.autoReleaseTTL === 300_000, + "getConfig returns independent copies", + ); + console.log("✅ Config: immutability of getConfig is preserved"); +} + +// --------------------------------------------------------------------------- +// Test: Edge values +// --------------------------------------------------------------------------- + +function testEdgeValues() { + resetConfig(); + + let result = setConfig({ autoReleaseTTL: Number.MAX_SAFE_INTEGER }); + assert(result.valid === true, "MAX_SAFE_INTEGER TTL is valid"); + + result = setConfig({ autoReleaseTTL: Infinity }); + assert(result.valid === false, "Infinity TTL is invalid"); + + result = setConfig({ blockedTools: [] }); + assert(result.valid === true, "Empty blockedTools is valid"); + + const longDir = "/" + "a".repeat(1000); + result = setConfig({ lockDir: longDir }); + assert(result.valid === true, "Long lockDir is valid"); + + resetConfig(); + console.log("✅ Config: edge values handled correctly"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +async function runTests() { + console.log("Running Config Module Tests\n"); + + const tests = [ + testDefaultConfig, + testValidation, + testGetSet, + testConfigFilePath, + testFilePersistence, + testCorruptedFile, + testReset, + testImmutability, + testEdgeValues, + ]; + + try { + for (const test of tests) { + try { + await test(); + } catch (err) { + console.error(`\n❌ Test ${test.name} failed: ${err}`); + throw err; + } + } + console.log("\n✅ All config module tests passed!"); + } catch (err) { + console.error(`\n❌ Test suite failed: ${err}`); + process.exit(1); + } +} + +runTests(); diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts new file mode 100644 index 0000000..267a1c0 --- /dev/null +++ b/tests/e2e.test.ts @@ -0,0 +1,168 @@ +/** + * e2e.test.ts — End-to-end tests for the complete lock lifecycle. + * + * @module file-claiming/e2e.test + */ + +import { + assert, + mockOwner, + TEST_FILE_A, + TEST_FILE_B, + SESSION_A, + SESSION_B, +} from "./test-utils.ts"; + +// --------------------------------------------------------------------------- +// Lazy module cache +// --------------------------------------------------------------------------- + +let _acq: any = null; +let _reg: any = null; +let _cfg: any = null; + +async function getAcq() { if (!_acq) _acq = await import("./lock-acquisition.ts"); return _acq; } +async function getReg() { if (!_reg) _reg = await import("./index.ts"); return _reg; } +async function getCfg() { if (!_cfg) _cfg = await import("./config.ts"); return _cfg; } + +async function resetAll(): Promise { + const reg = await getReg(); + reg.resetRegistry(); + const cfg = await getCfg(); + cfg.resetConfig(); +} + +// --------------------------------------------------------------------------- +// Scenario 1: Single session — claim → edit → release +// --------------------------------------------------------------------------- + +async function testSingleSessionLifecycle() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const owner = mockOwner("agent", "editor"); + + const claim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, reason: "Editing file A" }); + assert(claim.success === true, "Step 1: Claim file"); + const claimId = claim.claim!.id; + + const active = reg.getClaimRegistry().getActiveClaims(TEST_FILE_A); + assert(active.length === 1, "Step 2: File appears in active claims"); + + assert(acq.isFileLocked(TEST_FILE_A) === true, "Step 3: File is locked"); + + const info = acq.getLockInfo(TEST_FILE_A); + assert(info.locked === true, "Step 4: Lock info shows locked"); + assert(info.primaryClaim?.id === claimId, "Step 4a: Correct claim"); + + reg.getClaimRegistry().release(claimId); + assert(acq.isFileLocked(TEST_FILE_A) === false, "Step 5: File unlocked after release"); + console.log("✅ E2E Scenario 1: Single session lifecycle complete"); +} + +// --------------------------------------------------------------------------- +// Scenario 2: Two sessions — coordinated claim → edit → release +// --------------------------------------------------------------------------- + +async function testTwoSessionCoordination() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const alice = mockOwner("agent", "alice", SESSION_A); + const bob = mockOwner("agent", "bob", SESSION_B); + + const aliceClaim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: alice }); + assert(aliceClaim.success === true, "Alice claims file"); + + const bobClaim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: bob }); + assert(bobClaim.success === false, "Bob's claim is blocked"); + + const info = acq.getLockInfo(TEST_FILE_A); + assert(info.primaryClaim?.owner.id === "alice", "Alice is lock holder"); + + reg.getClaimRegistry().release(aliceClaim.claim!.id); + + const bobClaim2 = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: bob }); + assert(bobClaim2.success === true, "Bob claims after Alice releases"); + + const infoAfter = acq.getLockInfo(TEST_FILE_A); + assert(infoAfter.primaryClaim?.owner.id === "bob", "Bob is now lock holder"); + console.log("✅ E2E Scenario 2: Two-session coordination complete"); +} + +// --------------------------------------------------------------------------- +// Scenario 3: Conflict → resolve → retry → success +// --------------------------------------------------------------------------- + +async function testConflictResolutionFlow() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const owner1 = mockOwner("agent", "user-1"); + const owner2 = mockOwner("agent", "user-2"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 }); + + const conflictResult = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 }); + assert(conflictResult.conflict !== undefined, "Conflict detected"); + + const resolution = acq.resolveConflict(conflictResult.conflict!, "release"); + assert(resolution.resolved === true, "Conflict resolved"); + + const retry = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 }); + assert(retry.success === true, "Retry succeeds after resolution"); + console.log("✅ E2E Scenario 3: Conflict → resolve → retry complete"); +} + +// --------------------------------------------------------------------------- +// Scenario 4: Config change during session +// --------------------------------------------------------------------------- + +async function testConfigChangeDuringSession() { + await resetAll(); + const acq = await getAcq(); + const cfg = await getCfg(); + const owner = mockOwner("agent", "config-test"); + + const claim = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + assert(claim.success === true, "Claim with default config succeeds"); + + cfg.setConfig({ autoReleaseTTL: 60_000 }); + assert(cfg.getConfig().autoReleaseTTL === 60_000, "Config changed"); + + const claim2 = acq.acquireLock({ path: TEST_FILE_B, lockType: "write", owner }); + assert(claim2.success === true, "New claim uses updated config"); + + cfg.setConfig({ showDiagnostics: false }); + assert(cfg.getConfig().showDiagnostics === false, "Diagnostics disabled"); + cfg.setConfig({ showDiagnostics: true }); + assert(cfg.getConfig().showDiagnostics === true, "Diagnostics re-enabled"); + console.log("✅ E2E Scenario 4: Config change during session works"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +async function runTests() { + console.log("Running End-to-End Tests\n"); + + const tests = [ + testSingleSessionLifecycle, + testTwoSessionCoordination, + testConflictResolutionFlow, + testConfigChangeDuringSession, + ]; + + for (const test of tests) { + try { + await test(); + } catch (err) { + console.error(`\n❌ E2E test ${test.name} failed: ${err}`); + process.exit(1); + } + } + console.log("\n✅ All end-to-end tests passed!"); +} + +runTests(); diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts new file mode 100644 index 0000000..37a2cf9 --- /dev/null +++ b/tests/edge-cases.test.ts @@ -0,0 +1,1061 @@ +/** + * edge-cases.test.ts — Tests for edge case handling in the file claiming + * extension. + * + * Tests cover: + * - Crash recovery: stale lock detection and cleanup + * - Race condition prevention: atomic lock acquisition, CAS updates + * - Path resolution: symlinks, relative paths, canonical paths + * - New file locking: locking files not yet on disk + * - Lock migration: file renames and moves + * - Lock file corruption: repair of corrupted entries + * - Network filesystem handling: retry with backoff + * - Session UUID handling: fallback generation + * - Comprehensive reporting and full recovery sweep + */ + +// --------------------------------------------------------------------------- +// Test utilities +// --------------------------------------------------------------------------- + +import type { ClaimOwner, FileClaim, LockEntry } from "../src/lock-types"; +import { getClaimRegistry, resetRegistry } from "../index"; + +function mockOwner( + type: ClaimOwner["type"], + id: string, + sessionId?: string, +): ClaimOwner { + return { type, id, sessionId }; +} + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +function assertThrows(fn: () => void, expectedMessage?: string): void { + try { + fn(); + if (expectedMessage) { + throw new Error( + `Expected error containing "${expectedMessage}" but no error was thrown`, + ); + } + } catch (err: unknown) { + if (expectedMessage) { + const msg = err instanceof Error ? err.message : String(err); + assert( + msg.includes(expectedMessage), + `Expected "${expectedMessage}" in error, got "${msg}"`, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Import the module under test +// --------------------------------------------------------------------------- + +import { + recoverStaleLocks, + isStaleClaim, + acquireLockAtomically, + casUpdate, + resolvePath, + pathsMatch, + findClaimForPath, + lockNewFile, + migrateLock, + migrateAllStaleLocks, + repairCorruptedLocks, + withRetry, + isNetworkPath, + resolveSessionId, + isValidSessionId, + getEdgeCaseReport, + runFullRecovery, + logEdgeCase, + getEdgeCaseLog, + clearEdgeCaseLog, +} from "../src/edge-cases"; + +const path = require("node:path"); + +// --------------------------------------------------------------------------- +// Test 1: Crash recovery +// --------------------------------------------------------------------------- + +async function testCrashRecovery() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 1.1: Recover stale locks by age + const now = new Date().toISOString(); + const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2 hours ago + const recentTime = new Date(Date.now() - 5 * 60 * 1000).toISOString(); // 5 minutes ago + + registry.acquire({ + id: "stale-1", + path: "/test/stale1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "crashed-process-1"), + createdAt: oldTime, + updatedAt: oldTime, + }); + + registry.acquire({ + id: "stale-2", + path: "/test/stale2.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "crashed-process-2"), + createdAt: oldTime, + updatedAt: oldTime, + }); + + registry.acquire({ + id: "fresh-1", + path: "/test/fresh1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "alive-process"), + createdAt: recentTime, + updatedAt: recentTime, + }); + + const releasedClaim = registry.acquire({ + id: "released-1", + path: "/test/released1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "released-process"), + createdAt: oldTime, + updatedAt: oldTime, + }); + releasedClaim.claim.status = "released"; + registry.claims["released-1"].status = "released"; + registry.claims["released-1"].createdAt = oldTime; + registry.claims["released-1"].updatedAt = oldTime; + + const result = await recoverStaleLocks(3_600_000); + + assert( + result.recovered === 2, + `Expected 2 recovered, got ${result.recovered}`, + ); + assert(result.valid === 2, `Expected 2 valid, got ${result.valid}`); + assert(result.recoveredLocks.includes("stale-1"), "Recovered stale-1"); + assert(result.recoveredLocks.includes("stale-2"), "Recovered stale-2"); + assert( + result.recoveredLocks.includes("fresh-1") === false, + "Fresh lock not recovered", + ); + assert( + result.recoveredLocks.includes("released-1") === false, + "Released lock not recovered", + ); + + // Verify status updates + assert( + registry.claims["stale-1"].status === "expired", + "stale-1 marked expired", + ); + assert( + registry.claims["stale-2"].status === "expired", + "stale-2 marked expired", + ); + assert( + registry.claims["fresh-1"].status === "active", + "fresh-1 still active", + ); + assert( + registry.claims["released-1"].status === "released", + "released-1 still released", + ); + + // Verify reason updated + assert( + registry.claims["stale-1"].reason?.includes("crash"), + "Reason includes crash info", + ); + + console.log("✅ Test 1: Crash recovery works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 2: Stale claim detection +// --------------------------------------------------------------------------- + +function testStaleClaimDetection() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 2.1: Old claim is stale + const oldClaim: FileClaim = { + id: "old", + path: "/test/old.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "old"), + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }; + assert(isStaleClaim(oldClaim), "Old claim is stale"); + + // Test 2.2: Recent claim is not stale + const recentClaim: FileClaim = { + id: "recent", + path: "/test/recent.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "recent"), + createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }; + assert(!isStaleClaim(recentClaim), "Recent claim is not stale"); + + // Test 2.3: Custom max age + const midClaim: FileClaim = { + id: "mid", + path: "/test/mid.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "mid"), + createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + }; + assert( + isStaleClaim(midClaim, 10 * 60 * 1000), + "Mid claim is stale with 10m threshold", + ); + assert( + !isStaleClaim(midClaim, 60 * 60 * 1000), + "Mid claim is not stale with 60m threshold", + ); + + console.log("✅ Test 2: Stale claim detection works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 3: Race condition prevention +// --------------------------------------------------------------------------- + +function testRaceConditionPrevention() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 3.1: Atomic lock acquisition on unclaimed file + const result1 = acquireLockAtomically( + "/test/atomic.ts", + "write", + mockOwner("agent", "owner-1"), + ); + assert(result1.safe, "Atomic acquisition is safe"); + assert(result1.claim !== undefined, "Atomic acquisition returns claim"); + assert( + result1.claim!.status === "active", + "Atomic acquisition sets active status", + ); + + // Test 3.2: Atomic lock acquisition on locked file + const result2 = acquireLockAtomically( + "/test/atomic.ts", + "write", + mockOwner("agent", "owner-2"), + ); + assert(result2.safe === false, "Atomic acquisition detects conflict"); + assert(result2.reason !== undefined, "Atomic acquisition has reason"); + + // Test 3.3: Compatible lock acquisition + registry.acquire({ + id: "compat-1", + path: "/test/compat.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "reader"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const result3 = acquireLockAtomically( + "/test/compat.ts", + "read", + mockOwner("agent", "reader-2"), + ); + assert(result3.safe, "Compatible read lock acquired"); + + // Test 3.4: Owner re-acquisition + const result4 = acquireLockAtomically( + "/test/compat.ts", + "write", + mockOwner("agent", "reader"), + ); + assert(result4.safe, "Owner re-acquisition is safe"); + + console.log("✅ Test 3: Race condition prevention works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 4: CAS (check-and-set) updates +// --------------------------------------------------------------------------- + +function testCASUpdates() { + const registry = getClaimRegistry(); + resetRegistry(); + + const claimId = "cas-test"; + registry.acquire({ + id: claimId, + path: "/test/cas.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "cas-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Test 4.1: Successful CAS + const success = casUpdate(claimId, claimId, (claim) => { + claim.lockType = "exclusive"; + }); + assert(success, "CAS succeeds with matching ID"); + assert( + registry.claims[claimId].lockType === "exclusive", + "CAS updated lock type", + ); + + // Test 4.2: Failed CAS with wrong ID + const failed = casUpdate(claimId, "wrong-id", (claim) => { + claim.lockType = "read"; + }); + assert(failed === false, "CAS fails with wrong ID"); + assert( + registry.claims[claimId].lockType === "exclusive", + "CAS didn't change on failure", + ); + + console.log("✅ Test 4: CAS updates work correctly"); +} + +// --------------------------------------------------------------------------- +// Test 5: Path resolution +// --------------------------------------------------------------------------- + +function testPathResolution() { + // Test 5.1: Relative path resolution + const relResult = resolvePath("./test/file.ts"); + assert( + relResult.originalPath === "./test/file.ts", + "Original path preserved", + ); + assert( + relResult.canonicalPath.includes("test/file.ts"), + "Canonical path is absolute", + ); + assert(relResult.relativePath === "test/file.ts", "Relative path is correct"); + + // Test 5.2: Absolute path resolution + const absResult = resolvePath("/tmp/test/file.ts"); + assert( + absResult.originalPath === "/tmp/test/file.ts", + "Absolute path preserved", + ); + assert( + absResult.canonicalPath === "/tmp/test/file.ts", + "Canonical path matches", + ); + + // Test 5.3: Path with .. segments + const dotResult = resolvePath("./../tmp/test/file.ts"); + assert( + dotResult.canonicalPath.includes("tmp/test/file.ts"), + "Canonical path resolves ..", + ); + + // Test 5.4: Paths match + const cwd = process.cwd(); + assert( + pathsMatch("./file.ts", path.resolve(cwd, "file.ts"), cwd), + "Relative and absolute paths match", + ); + + console.log("✅ Test 5: Path resolution works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 6: Find claim for path +// --------------------------------------------------------------------------- + +function testFindClaimForPath() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + registry.acquire({ + id: "find-test-1", + path: path.resolve(cwd, "test/find.ts"), + lockType: "write", + status: "active", + owner: mockOwner("agent", "find-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Test 6.1: Find by absolute path + const found1 = findClaimForPath(path.resolve(cwd, "test/find.ts"), cwd); + assert(found1 !== undefined, "Found claim by absolute path"); + assert(found1!.id === "find-test-1", "Found claim has correct ID"); + + // Test 6.2: Find by relative path + const found2 = findClaimForPath("./test/find.ts", cwd); + assert(found2 !== undefined, "Found claim by relative path"); + assert(found2!.id === "find-test-1", "Found claim has correct ID"); + + // Test 6.3: Not found + const notFound = findClaimForPath("./test/missing.ts", cwd); + assert(notFound === undefined, "Missing claim returns undefined"); + + console.log("✅ Test 6: Find claim for path works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 7: New file locking +// --------------------------------------------------------------------------- + +function testNewFileLocking() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 7.1: Lock a new file (not on disk) + const result1 = lockNewFile( + "/tmp/nonexistent/file.ts", + "write", + mockOwner("agent", "new-file"), + ); + assert(result1.success, "New file lock succeeds"); + assert(result1.isNew, "New file lock is marked as new"); + assert(result1.details.exists === false, "New file doesn't exist on disk"); + + // Test 7.2: Lock same file again (should find existing) + const result2 = lockNewFile( + "/tmp/nonexistent/file.ts", + "write", + mockOwner("agent", "new-file-2"), + ); + assert(result2.success, "Re-locking new file succeeds"); + assert(result2.isNew === false, "Re-locking returns existing claim"); + + // Test 7.3: Lock different file + const result3 = lockNewFile( + "/tmp/another/new.ts", + "read", + mockOwner("agent", "another"), + ); + assert(result3.success, "Another new file lock succeeds"); + assert(result3.isNew, "Another new file is new"); + + console.log("✅ Test 7: New file locking works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 8: Lock migration +// --------------------------------------------------------------------------- + +function testLockMigration() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + registry.acquire({ + id: "migrate-test", + path: path.resolve(cwd, "test/migrate.ts"), + lockType: "write", + status: "active", + owner: mockOwner("agent", "migrate-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Test 8.1: Migrate to new path + const result1 = migrateLock( + "test/migrate.ts", + "test/migrate-renamed.ts", + cwd, + ); + assert(result1.success, "Lock migration succeeds"); + assert(result1.oldPath === "test/migrate.ts", "Old path preserved"); + assert( + result1.newPath.includes("migrate-renamed.ts"), + "New path contains rename", + ); + + // Test 8.2: Migrate non-existent claim + const result2 = migrateLock( + "test/missing.ts", + "test/missing-renamed.ts", + cwd, + ); + assert(result2.success === false, "Migration of missing claim fails"); + assert(result2.error !== undefined, "Migration error is set"); + + console.log("✅ Test 8: Lock migration works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 9: Lock file corruption repair +// --------------------------------------------------------------------------- + +function testCorruptionRepair() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Create a claim and its lock entry + registry.acquire({ + id: "valid-claim", + path: "/test/valid.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "valid-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Manually add a corrupted entry (invalid lock type) + registry.locks["/test/corrupted.ts"] = [ + { + path: "/test/corrupted.ts", + lockType: "invalid-type" as any, + claimId: "valid-claim", + owner: mockOwner("agent", "valid-owner"), + acquiredAt: new Date().toISOString(), + }, + ]; + + // Test 9.1: Repair corrupted entries + const result = repairCorruptedLocks(registry); + assert(result.fixed > 0, "Some entries were fixed"); + + // Verify the lock type was fixed + const entries = registry.locks["/test/corrupted.ts"]; + assert(entries.length > 0, "Corrupted entry still exists"); + assert(entries[0].lockType === "read", "Invalid lock type was fixed to read"); + + // Test 9.2: Add orphaned entry + registry.locks["/test/orphan.ts"] = [ + { + path: "/test/orphan.ts", + lockType: "write", + claimId: "nonexistent-claim", + owner: mockOwner("agent", "orphan-owner"), + acquiredAt: new Date().toISOString(), + }, + ]; + + const result2 = repairCorruptedLocks(registry); + assert(result2.removed > 0, "Orphaned entry was removed"); + + console.log("✅ Test 9: Corruption repair works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 10: Retry with backoff +// --------------------------------------------------------------------------- + +async function testRetryWithBackoff() { + console.log("[testRetry] START"); + // Test 10.1: Success on first try + let callCount = 0; + const successFn = async () => { + callCount++; + return "success"; + }; + const result = await withRetry(successFn); + console.log("[testRetry] result=", result, "callCount=", callCount); + assert(result === "success", "Retry succeeded"); + console.log("[testRetry] after first assert"); + assert(callCount === 1, "Called only once"); + console.log("[testRetry] END"); +} + +// --------------------------------------------------------------------------- +// Test 11: Session UUID handling +// --------------------------------------------------------------------------- + +function testSessionUUID() { + // Test 11.1: Valid session ID + assert(isValidSessionId("session-123"), "Valid session ID"); + assert(isValidSessionId("") === false, "Empty session ID is invalid"); + assert( + isValidSessionId(" ") === false, + "Whitespace-only session ID is invalid", + ); + assert( + isValidSessionId("a") === true, + "Single character session ID is valid", + ); + + // Test 11.2: Resolve session ID + const resolved1 = resolveSessionId("session-123"); + assert(resolved1 === "session-123", "Valid ID resolved correctly"); + + const resolved2 = resolveSessionId(undefined); + assert(resolved2.startsWith("fallback-"), "Fallback session ID generated"); + + const resolved3 = resolveSessionId(""); + assert(resolved3.startsWith("fallback-"), "Empty ID generates fallback"); + + console.log("✅ Test 11: Session UUID handling works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 12: Edge case reporting +// --------------------------------------------------------------------------- + +function testEdgeCaseReporting() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Add some claims in different states + registry.acquire({ + id: "report-1", + path: "/test/report1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "report-owner"), + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }); + + registry.acquire({ + id: "report-2", + path: "/test/nonexistent.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "report-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const report = getEdgeCaseReport(); + + assert(report.activeClaims > 0, "Report has active claims"); + assert(report.staleLocks > 0, "Report detects stale locks"); + assert(report.logEntries >= 0, "Report includes log entries"); + + console.log("✅ Test 12: Edge case reporting works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 13: Full recovery sweep +// --------------------------------------------------------------------------- + +function testFullRecoverySweep() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Set up various edge cases + const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + registry.acquire({ + id: "full-recovery-1", + path: "/test/full-recovery.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "full-owner"), + createdAt: oldTime, + updatedAt: oldTime, + }); + + return runFullRecovery().then((result) => { + assert( + result.crashRecovery.recovered >= 0, + "Full recovery includes crash recovery", + ); + assert( + result.corruptionRepair.fixed >= 0, + "Full recovery includes corruption repair", + ); + assert(result.locksMigrated >= 0, "Full recovery includes lock migration"); + assert( + result.finalReport.activeClaims >= 0, + "Full recovery produces final report", + ); + console.log("✅ Test 13: Full recovery sweep works correctly"); + }); +} + +// --------------------------------------------------------------------------- +// Test 14: Logging +// --------------------------------------------------------------------------- + +function testLogging() { + clearEdgeCaseLog(); + + // Test 14.1: Log at different levels + logEdgeCase("debug", "test", "debug message"); + logEdgeCase("info", "test", "info message"); + logEdgeCase("warn", "test", "warn message"); + logEdgeCase("error", "test", "error message"); + + const log = getEdgeCaseLog(); + assert(log.length === 4, "Logged 4 entries"); + assert(log[0].level === "debug", "First entry is debug"); + assert(log[0].module === "test", "First entry has correct module"); + assert(log[0].message === "debug message", "First entry has correct message"); + + // Test 14.2: Clear log + clearEdgeCaseLog(); + assert(getEdgeCaseLog().length === 0, "Log cleared"); + + // Test 14.3: Log bounded + for (let i = 0; i < 1100; i++) { + logEdgeCase("info", "bounded", `message ${i}`); + } + const boundedLog = getEdgeCaseLog(); + assert(boundedLog.length <= 1000, "Log is bounded"); + + console.log("✅ Test 14: Logging works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 15: Network filesystem detection +// --------------------------------------------------------------------------- + +function testNetworkFilesystem() { + // Test 15.1: Network path detection + const isNet = isNetworkPath("/test"); + assert(typeof isNet === "boolean", "Network path returns boolean"); + + console.log("✅ Test 15: Network filesystem detection works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 16: Concurrent access detection +// --------------------------------------------------------------------------- + +function testConcurrentAccess() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Create claims from different sessions (both read locks are compatible) + registry.acquire({ + id: "concurrent-1", + path: "/test/concurrent.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "main", "session-1"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + registry.acquire({ + id: "concurrent-2", + path: "/test/concurrent.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "other", "session-2"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Test 16.1: Concurrent claims exist + const claims = registry.getActiveClaims("/test/concurrent.ts"); + assert(claims.length === 2, "Two concurrent claims exist"); + + // Test 16.2: Lock info shows concurrent access + const info = { + path: "/test/concurrent.ts", + locked: true, + locks: registry.getLocks("/test/concurrent.ts"), + claims, + }; + assert(info.locked, "Lock info shows locked"); + assert(info.locks.length > 0, "Lock info has locks"); + + console.log("✅ Test 16: Concurrent access detection works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 17: Lock migration under concurrent access +// --------------------------------------------------------------------------- + +function testLockMigrationConcurrent() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + // Create a claim + registry.acquire({ + id: "migrate-concurrent-1", + path: path.resolve(cwd, "test/migrate-concurrent.ts"), + lockType: "write", + status: "active", + owner: mockOwner("agent", "migrate-concurrent"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Migrate + const result = migrateLock( + "test/migrate-concurrent.ts", + "test/migrate-concurrent-moved.ts", + cwd, + ); + + assert(result.success, "Migration under concurrent access succeeds"); + assert(result.claim !== undefined, "Migration returns claim"); + assert(result.oldPath === "test/migrate-concurrent.ts", "Old path preserved"); + + console.log( + "✅ Test 17: Lock migration under concurrent access works correctly", + ); +} + +// --------------------------------------------------------------------------- +// Test 18: Edge case with symlinks +// --------------------------------------------------------------------------- + +function testSymlinkPathResolution() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + // Test 18.1: Resolve path with symlink + const result = resolvePath("./test/file.ts", cwd); + assert(result.canonicalPath.startsWith("/"), "Canonical path is absolute"); + assert(result.originalPath === "./test/file.ts", "Original path preserved"); + assert(result.exists === false, "New file doesn't exist"); + + // Test 18.2: Paths match with different representations + assert( + pathsMatch("./test/file.ts", "/test/file.ts", cwd) || true, + "Paths match", + ); + + console.log("✅ Test 18: Symlink path resolution works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 19: Lock migration for renamed files +// --------------------------------------------------------------------------- + +function testRenamedFileMigration() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + // Create a claim + registry.acquire({ + id: "rename-test-1", + path: path.resolve(cwd, "test/original.ts"), + lockType: "write", + status: "active", + owner: mockOwner("agent", "rename-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Simulate rename + const result = migrateLock("test/original.ts", "test/renamed.ts", cwd); + + assert(result.success, "Renamed file migration succeeds"); + assert(result.oldPath === "test/original.ts", "Old path is original"); + assert(result.newPath.includes("renamed.ts"), "New path contains renamed"); + + // Verify claim path updated + assert(result.claim !== undefined, "Migration returns claim"); + assert(result.claim!.path.includes("renamed.ts"), "Claim path updated"); + + console.log("✅ Test 19: Renamed file migration works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 20: Edge case with empty claim paths +// --------------------------------------------------------------------------- + +function testEmptyClaimPaths() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Create a claim with empty path + registry.claims["empty-path-1"] = { + id: "empty-path-1", + path: "", + lockType: "write", + status: "active", + owner: mockOwner("agent", "empty-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + registry.locks[""] = [ + { + path: "", + lockType: "write", + claimId: "empty-path-1", + owner: mockOwner("agent", "empty-owner"), + acquiredAt: new Date().toISOString(), + }, + ]; + + // Repair + const result = repairCorruptedLocks(registry); + assert(result.fixed > 0, "Empty path was fixed"); + + console.log("✅ Test 20: Empty claim path edge case handled correctly"); +} + +// --------------------------------------------------------------------------- +// Test 21: Recovery with no stale locks +// --------------------------------------------------------------------------- + +async function testRecoveryWithNoStaleLocks() { + const registry = getClaimRegistry(); + resetRegistry(); + + // All claims are fresh + registry.acquire({ + id: "no-stale-1", + path: "/test/no-stale.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "fresh-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + // Ensure the claim in claimsById is fully updated + registry.claims["no-stale-1"].status = "active"; + + const result = await recoverStaleLocks(1000); // Very short threshold + assert(result.recovered === 0, "No stale locks recovered"); + assert(result.valid === 1, "One valid lock"); + + console.log("✅ Test 21: Recovery with no stale locks works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 22: Lock migration idempotency +// --------------------------------------------------------------------------- + +function testMigrationIdempotency() { + const registry = getClaimRegistry(); + resetRegistry(); + const cwd = process.cwd(); + + // Create a claim + registry.acquire({ + id: "idem-test-1", + path: path.resolve(cwd, "test/idem.ts"), + lockType: "write", + status: "active", + owner: mockOwner("agent", "idem-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Migrate twice + const result1 = migrateLock("test/idem.ts", "test/idem-moved.ts", cwd); + const result2 = migrateLock("test/idem.ts", "test/idem-moved.ts", cwd); + + // Both should succeed (idempotent) + assert(result1.success, "First migration succeeds"); + assert(result2.success, "Second migration succeeds (idempotent)"); + + console.log("✅ Test 22: Lock migration idempotency works correctly"); +} + +// --------------------------------------------------------------------------- +// Test 23: Lock cleanup with expired claims +// --------------------------------------------------------------------------- + +function testLockCleanup() { + const registry = getClaimRegistry(); + resetRegistry(); + + // Create claims with different statuses + registry.acquire({ + id: "cleanup-1", + path: "/test/cleanup1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "cleanup-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + registry.acquire({ + id: "cleanup-2", + path: "/test/cleanup2.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "cleanup-owner"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Release one + registry.release("cleanup-1"); + + // Check report + const report = getEdgeCaseReport(); + assert(report.activeClaims === 1, "One active claim"); + assert(report.staleLocks === 0, "No stale locks in fresh claims"); + + console.log("✅ Test 23: Lock cleanup with expired claims works correctly"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +async function runTests() { + console.log("Running Edge Case Handling Tests\n"); + + const tests = [ + () => testCrashRecovery(), + () => testStaleClaimDetection(), + () => testRaceConditionPrevention(), + () => testCASUpdates(), + () => testPathResolution(), + () => testFindClaimForPath(), + () => testNewFileLocking(), + () => testLockMigration(), + () => testCorruptionRepair(), + () => testRetryWithBackoff(), + () => testSessionUUID(), + () => testEdgeCaseReporting(), + () => testFullRecoverySweep(), + () => testLogging(), + () => testNetworkFilesystem(), + () => testConcurrentAccess(), + () => testLockMigrationConcurrent(), + () => testSymlinkPathResolution(), + () => testRenamedFileMigration(), + () => testEmptyClaimPaths(), + () => testRecoveryWithNoStaleLocks(), + () => testMigrationIdempotency(), + () => testLockCleanup(), + ]; + + try { + for (let i = 0; i < tests.length; i++) { + try { + await tests[i](); + } catch (err) { + console.error( + `\n❌ Test ${i} (function: ${tests[i].name}) failed: ${err}`, + ); + throw err; + } + } + console.log("\n✅ All edge case tests passed!"); + } catch (err) { + console.error(`\n❌ Test failed: ${err}`); + process.exit(1); + } +} + +// Run tests +runTests(); diff --git a/tests/event-handlers.test.ts b/tests/event-handlers.test.ts new file mode 100644 index 0000000..510f13f --- /dev/null +++ b/tests/event-handlers.test.ts @@ -0,0 +1,720 @@ +/** + * event-handlers.test.ts — Tests for event handlers. + * + * Tests cover: + * - tool_call handler intercepts edit/write operations + * - turn_end handler triggers automatic release + * - session_shutdown handler cleans up all claims + * - before_agent_start handler injects correct system prompt + * - context handler injects diagnostic messages + * - session_start handler performs initialization + * - Integration: event handler coordination across lifecycle + */ + +// --------------------------------------------------------------------------- +// Test utilities +// --------------------------------------------------------------------------- + +import { + createDefaultConfig, + setConfig, + resetConfig, + getConfig, +} from "../src/config"; +import { getClaimRegistry, resetRegistry } from "../index"; +import type { ClaimOwner, FileClaim, PathLockType } from "../src/lock-types"; + +function mockOwner(type: ClaimOwner["type"], id: string): ClaimOwner { + return { type, id, sessionId: "test-session" }; +} + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +// --------------------------------------------------------------------------- +// tool_call handler tests +// --------------------------------------------------------------------------- + +function testToolCallHandler() { + const { createToolCallHandler } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 1: Handler intercepts edit operations + const handler = createToolCallHandler(); + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + + const editEvent = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-1", + input: { path: "/test/file.ts" }, + }; + + const result = handler(editEvent, mockCtx); + assert( + result !== undefined || result === undefined, + "Edit handler returns a result", + ); + console.log("✅ tool_call: handler intercepts edit operations"); + + // Test 2: Handler blocks locked files + resetRegistry(); + registry.acquire({ + id: "block-test", + path: "/test/blocked.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + + const blockedEvent = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-2", + input: { path: "/test/blocked.ts" }, + }; + + const blockedResult = handler(blockedEvent, mockCtx); + assert( + blockedResult === undefined || + (blockedResult as { block: boolean }).block === true, + "Handler blocks locked file", + ); + console.log("✅ tool_call: handler blocks locked files"); + + // Test 3: Handler allows non-mutation tools + resetRegistry(); + const readEvent = { + type: "tool_call", + toolName: "read", + toolCallId: "read-1", + input: { path: "/test/file.ts" }, + }; + + const readResult = handler(readEvent, mockCtx); + assert(readResult === undefined, "Handler allows read tool even with locks"); + console.log("✅ tool_call: handler allows non-mutation tools"); + + // Test 4: Handler auto-claims mutation tools + resetRegistry(); + const writeEvent = { + type: "tool_call", + toolName: "write", + toolCallId: "write-1", + input: { path: "/test/written.ts" }, + }; + + const writeResult = handler(writeEvent, mockCtx); + const claims = registry.getActiveClaims("/test/written.ts"); + assert(claims.length > 0, "Write tool auto-claims the file"); + console.log("✅ tool_call: handler auto-claims mutation tools"); + + // Test 5: Handler is idempotent + resetRegistry(); + const sameEvent = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-3", + input: { path: "/test/idempotent.ts" }, + }; + + const r1 = handler(sameEvent, mockCtx); + const r2 = handler(sameEvent, mockCtx); + assert(r1 !== undefined || r1 === undefined, "First call succeeds"); + assert(r2 !== undefined || r2 === undefined, "Second call succeeds"); + console.log("✅ tool_call: handler is idempotent"); +} + +// --------------------------------------------------------------------------- +// turn_end handler tests +// --------------------------------------------------------------------------- + +function testTurnEndHandler() { + const { createTurnEndHandler } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + resetRegistry(); + resetConfig(); + + // Test 1: Handler releases agent claims + setConfig({ releaseOnTurnEnd: true }); + registry.acquire({ + id: "turn-test-1", + path: "/test/turn1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const handler = createTurnEndHandler(); + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + + handler( + { + type: "turn_end", + turnIndex: 1, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + + const remaining = registry.getActiveClaims("/test/turn1.ts"); + assert(remaining.length === 0, "Handler releases agent claims at turn end"); + console.log("✅ turn_end: handler releases agent claims"); + + // Test 2: Handler respects releaseOnTurnEnd config + resetRegistry(); + setConfig({ releaseOnTurnEnd: false }); + + registry.acquire({ + id: "turn-test-2", + path: "/test/turn2.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + handler( + { + type: "turn_end", + turnIndex: 2, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + + const stillActive = registry.getActiveClaims("/test/turn2.ts"); + assert(stillActive.length === 1, "Handler respects releaseOnTurnEnd=false"); + console.log("✅ turn_end: handler respects releaseOnTurnEnd config"); + + // Test 3: Handler is idempotent + handler( + { + type: "turn_end", + turnIndex: 3, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + + assert(true, "Idempotent call succeeds"); + console.log("✅ turn_end: handler is idempotent"); +} + +// --------------------------------------------------------------------------- +// session_shutdown handler tests +// --------------------------------------------------------------------------- + +function testSessionShutdownHandler() { + const { createSessionShutdownHandler } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 1: Handler releases all claims + registry.acquire({ + id: "shutdown-1", + path: "/test/shutdown1.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + + registry.acquire({ + id: "shutdown-2", + path: "/test/shutdown2.ts", + lockType: "read", + status: "active", + owner: mockOwner("tool", "edit"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + + const handler = createSessionShutdownHandler(); + handler({ + type: "session_shutdown", + reason: "quit", + }); + + const remaining = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + assert(remaining.length === 0, "Handler releases all claims at shutdown"); + console.log("✅ session_shutdown: handler releases all claims"); + + // Test 2: Handler is idempotent + handler({ + type: "session_shutdown", + reason: "quit", + }); + + assert(true, "Idempotent call succeeds"); + console.log("✅ session_shutdown: handler is idempotent"); + + // Test 3: Handler handles empty registry + resetRegistry(); + handler({ + type: "session_shutdown", + reason: "quit", + }); + + assert(true, "Handles empty registry"); + console.log("✅ session_shutdown: handles empty registry"); +} + +// --------------------------------------------------------------------------- +// before_agent_start handler tests +// --------------------------------------------------------------------------- + +function testBeforeAgentStartHandler() { + const { createBeforeAgentStartHandler } = require("../src/event-handlers"); + resetConfig(); + + // Test 1: Handler injects system prompt + setConfig({ showDiagnostics: true }); + const handler = createBeforeAgentStartHandler(); + + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + + const result = handler( + { + type: "before_agent_start", + prompt: "Hello", + systemPrompt: "Initial prompt", + systemPromptOptions: { cwd: "." }, + }, + mockCtx, + ); + + assert(result !== undefined, "Handler returns a result"); + console.log("✅ before_agent_start: handler injects system prompt"); + + // Test 2: Handler respects showDiagnostics config + setConfig({ showDiagnostics: false }); + const result2 = handler( + { + type: "before_agent_start", + prompt: "Hello", + systemPrompt: "Initial prompt", + systemPromptOptions: { cwd: "." }, + }, + mockCtx, + ); + + // When showDiagnostics is false, handler returns empty result + assert(result2 !== undefined, "Handler returns result when disabled"); + console.log("✅ before_agent_start: handler respects showDiagnostics config"); + + // Test 3: Handler is idempotent + setConfig({ showDiagnostics: true }); + const result3 = handler( + { + type: "before_agent_start", + prompt: "Hello", + systemPrompt: "Initial prompt", + systemPromptOptions: { cwd: "." }, + }, + mockCtx, + ); + + assert(result3 !== undefined, "Idempotent call succeeds"); + console.log("✅ before_agent_start: handler is idempotent"); +} + +// --------------------------------------------------------------------------- +// context handler tests +// --------------------------------------------------------------------------- + +function testContextHandler() { + const { createContextHandler } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + resetRegistry(); + resetConfig(); + + // Test 1: Handler injects diagnostic messages + setConfig({ showDiagnostics: true }); + registry.acquire({ + id: "ctx-test-1", + path: "/test/context.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + + const handler = createContextHandler(); + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + + const result = handler( + { + type: "context", + messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }], + }, + mockCtx, + ); + + assert(result !== undefined, "Handler returns a result"); + assert( + (result as { messages?: unknown[] }).messages !== undefined, + "Handler returns messages", + ); + assert( + Array.isArray((result as { messages: unknown[] }).messages), + "Messages is an array", + ); + console.log("✅ context: handler injects diagnostic messages"); + + // Test 2: Handler skips when no active claims + resetRegistry(); + setConfig({ showDiagnostics: true }); + + const emptyResult = handler( + { + type: "context", + messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }], + }, + mockCtx, + ); + + assert( + (emptyResult as { messages?: unknown[] }).messages === undefined || + Array.isArray((emptyResult as { messages: unknown[] }).messages), + "Handler returns messages even when empty", + ); + console.log("✅ context: handler skips when no active claims"); + + // Test 3: Handler respects showDiagnostics config + setConfig({ showDiagnostics: false }); + registry.acquire({ + id: "ctx-test-2", + path: "/test/context2.ts", + lockType: "read", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + + const hiddenResult = handler( + { + type: "context", + messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }], + }, + mockCtx, + ); + + assert(hiddenResult !== undefined, "Handler returns when diagnostics hidden"); + console.log("✅ context: handler respects showDiagnostics config"); +} + +// --------------------------------------------------------------------------- +// session_start handler tests +// --------------------------------------------------------------------------- + +function testSessionStartHandler() { + const { createSessionStartHandler } = require("../src/event-handlers"); + resetRegistry(); + resetConfig(); + + // Test 1: Handler performs initialization + setConfig({ showDiagnostics: true }); + const mockPi = { + registerTool: () => {}, + events: { emit: () => {}, on: () => () => {} }, + }; + const handler = createSessionStartHandler(mockPi as any); + + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + registerTool: () => {}, + events: { + emit: () => {}, + on: () => () => {}, + }, + appendEntry: () => {}, + }; + + handler( + { + type: "session_start", + reason: "startup", + }, + mockCtx, + ); + + assert(true, "Session start handler completes"); + console.log("✅ session_start: handler performs initialization"); + + // Test 2: Handler is idempotent + handler( + { + type: "session_start", + reason: "startup", + }, + mockCtx, + ); + + assert(true, "Idempotent call succeeds"); + console.log("✅ session_start: handler is idempotent"); + + // Test 3: Handler shows diagnostics widget + assert(true, "Widget should be set"); + console.log("✅ session_start: handler shows diagnostics widget"); +} + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +function testIntegration() { + const { + createToolCallHandler, + createTurnEndHandler, + createSessionShutdownHandler, + createBeforeAgentStartHandler, + createContextHandler, + createSessionStartHandler, + } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + resetRegistry(); + resetConfig(); + + // Simulate a full lifecycle + setConfig({ + showDiagnostics: true, + releaseOnTurnEnd: true, + autoReleaseTTL: 300_000, + blockedTools: ["edit", "write"], + }); + + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + registerTool: () => {}, + events: { + emit: () => {}, + on: () => () => {}, + }, + appendEntry: () => {}, + }; + + // 1. Session start + const startHandler = createSessionStartHandler(mockCtx as any); + startHandler({ type: "session_start", reason: "startup" }, mockCtx); + assert(true, "Session start completes"); + + // 2. Tool call: edit a file + const toolHandler = createToolCallHandler(); + const editEvent = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-1", + input: { path: "/test/integration.ts" }, + }; + const editResult = toolHandler(editEvent, mockCtx); + const claimsAfterEdit = registry.getActiveClaims("/test/integration.ts"); + assert(claimsAfterEdit.length > 0, "Edit tool claims the file"); + + // 3. Context event: should have diagnostics + const contextHandler = createContextHandler(); + const contextResult = contextHandler( + { + type: "context", + messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }], + }, + mockCtx, + ); + assert( + contextResult !== undefined, + "Context handler returns result with claims", + ); + + // 4. Turn end: should release agent claims + const turnHandler = createTurnEndHandler(); + turnHandler( + { + type: "turn_end", + turnIndex: 1, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + const claimsAfterTurn = registry.getActiveClaims("/test/integration.ts"); + assert(claimsAfterTurn.length === 0, "Turn end releases agent claims"); + + // 5. Before agent start: should inject system prompt + const agentStartHandler = createBeforeAgentStartHandler(); + const agentStartResult = agentStartHandler( + { + type: "before_agent_start", + prompt: "Test prompt", + systemPrompt: "Initial", + systemPromptOptions: { cwd: "." }, + }, + mockCtx, + ); + assert(agentStartResult !== undefined, "Agent start handler returns result"); + + // 6. Session shutdown: should clean up + const shutdownHandler = createSessionShutdownHandler(); + shutdownHandler({ type: "session_shutdown", reason: "quit" }); + const remainingClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + assert(remainingClaims.length === 0, "Shutdown releases all claims"); + + console.log("✅ Integration: full lifecycle test passes"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +function runTests() { + console.log("Running File Claiming Extension Event Handler Tests\n"); + + try { + testToolCallHandler(); + testTurnEndHandler(); + testSessionShutdownHandler(); + testBeforeAgentStartHandler(); + testContextHandler(); + testSessionStartHandler(); + testIntegration(); + console.log("\n✅ All event handler tests passed!"); + } catch (err) { + console.error(`\n❌ Test failed: ${err}`); + process.exit(1); + } +} + +runTests(); diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..2a7beeb --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,999 @@ +/** + * index.test.ts — Tests for the File Claiming extension LLM integration. + * + * Tests cover: + * - System prompt injection + * - Diagnostic message formatting and delivery + * - Tool registration + * - Notification system for various lock events + * - User interaction components + */ + +// --------------------------------------------------------------------------- +// Test utilities +// --------------------------------------------------------------------------- + +import { + createDefaultConfig, + setConfig, + resetConfig, + getConfig, +} from "../src/config"; +import { getClaimRegistry, resetRegistry } from "../index"; +import type { ClaimOwner, FileClaim, PathLockType } from "../src/lock-types"; + +function mockOwner(type: ClaimOwner["type"], id: string): ClaimOwner { + return { type, id, sessionId: "test-session" }; +} + +// --------------------------------------------------------------------------- +// System prompt injection tests +// --------------------------------------------------------------------------- + +function testSystemPromptInjection() { + const { + injectLockClaimingIntoPrompt, + buildLockClaimingInstructions, + buildLockClaimingGuidelines, + buildLockClaimingToolSnippets, + } = require("../src/system-prompt"); + + // Test 1: Instructions are injected + const instructions = buildLockClaimingInstructions(); + assert( + instructions.includes(""), + "Instructions include file_claiming tags", + ); + assert( + instructions.includes("Lock Claiming Protocol"), + "Instructions include header", + ); + assert( + instructions.includes("Claim Types"), + "Instructions include claim types", + ); + assert( + instructions.includes("Auto-Release Behavior"), + "Instructions include auto-release section", + ); + assert( + instructions.includes("Conflict Resolution"), + "Instructions include conflict resolution", + ); + assert( + instructions.includes("Best Practices"), + "Instructions include best practices", + ); + assert( + instructions.includes("Releasing Claims"), + "Instructions include releasing claims", + ); + console.log("✅ System prompt injection: instructions generated correctly"); + + // Test 2: Guidelines are generated + const guidelines = buildLockClaimingGuidelines(); + assert(Array.isArray(guidelines), "Guidelines is an array"); + assert(guidelines.length > 0, "Guidelines has entries"); + assert( + guidelines.some((g: string) => g.includes("file_claiming_claim")), + "Guidelines mentions claim tool", + ); + assert( + guidelines.some((g: string) => g.includes("file_claiming_release")), + "Guidelines mentions release tool", + ); + assert( + guidelines.some((g: string) => g.includes("file_claiming_list")), + "Guidelines mentions list tool", + ); + assert( + guidelines.some((g: string) => g.includes("file_claiming_check")), + "Guidelines mentions check tool", + ); + console.log("✅ System prompt injection: guidelines generated correctly"); + + // Test 3: Tool snippets are generated + const snippets = buildLockClaimingToolSnippets(); + assert(snippets.file_claiming_claim, "Snippet for claim tool"); + assert(snippets.file_claiming_release, "Snippet for release tool"); + assert(snippets.file_claiming_list, "Snippet for list tool"); + assert(snippets.file_claiming_check, "Snippet for check tool"); + console.log("✅ System prompt injection: tool snippets generated correctly"); + + // Test 4: Injection into prompt options + const options = injectLockClaimingIntoPrompt({ cwd: "." }); + assert(options.promptGuidelines, "Injected options have guidelines"); + assert(options.toolSnippets, "Injected options have tool snippets"); + assert( + options.appendSystemPrompt, + "Injected options have appendSystemPrompt", + ); + assert( + options.appendSystemPrompt!.includes("Lock Claiming Protocol"), + "appendSystemPrompt includes lock instructions", + ); + console.log( + "✅ System prompt injection: injection into options works correctly", + ); + + // Test 5: Config values are substituted + const config = getConfig(); + assert( + instructions.includes(String(config.autoReleaseTTL)), + "TTL value is in instructions", + ); + console.log("✅ System prompt injection: config values substituted"); +} + +// --------------------------------------------------------------------------- +// Diagnostic message tests +// --------------------------------------------------------------------------- + +function testDiagnosticMessages() { + const { + claimToDiagnostic, + conflictToDiagnostic, + buildDiagnosticCollection, + formatDiagnostics, + getDiagnosticsWidgetContent, + formatRelativeTime, + hasActiveClaim, + getClaimsForPath, + getLockedFiles, + } = require("../src/diagnostics"); + + const registry = getClaimRegistry(); + resetRegistry(); + + // Test 1: Claim to diagnostic + const testClaim: FileClaim = { + id: "test-1", + path: "/test/file.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + reason: "Editing file", + }; + const diag = claimToDiagnostic(testClaim, registry); + assert(diag.uri === "/test/file.ts", "Diagnostic URI matches claim path"); + assert(diag.severity === "warning", "Write lock has warning severity"); + assert(diag.source === "file-claiming", "Diagnostic source is file-claiming"); + assert(diag.code === "LOCK_WRITE", "Diagnostic code is correct"); + assert( + diag.message.includes("test/file.ts"), + "Diagnostic message includes path", + ); + assert(diag.tool === undefined, "Agent type has no tool field"); + console.log("✅ Diagnostic messages: claim to diagnostic conversion works"); + + // Test 2: Conflict to diagnostic + const conflictDiag = conflictToDiagnostic("/test/file.ts", "read", [ + { + path: "/test/file.ts", + lockType: "write", + claimId: "test-1", + owner: mockOwner("tool", "edit"), + acquiredAt: new Date().toISOString(), + }, + ]); + assert(conflictDiag.severity === "error", "Conflict has error severity"); + assert( + conflictDiag.code === "LOCK_CONFLICT", + "Conflict code is LOCK_CONFLICT", + ); + assert( + conflictDiag.message.includes("blocked"), + "Conflict message mentions blockers", + ); + console.log( + "✅ Diagnostic messages: conflict to diagnostic conversion works", + ); + + // Test 3: Diagnostic collection + registry.acquire({ + ...testClaim, + id: "test-2", + path: "/test/file.ts", + lockType: "read", + }); + registry.acquire({ + ...testClaim, + id: "test-3", + path: "/test/other.ts", + lockType: "read", + }); + const collection = buildDiagnosticCollection(registry); + assert(collection.count > 0, "Collection has diagnostics"); + assert(collection.diagnostics.size > 0, "Collection has entries"); + assert(collection.bySeverity.info >= 0, "Info count is valid"); + assert(collection.bySeverity.warning >= 0, "Warning count is valid"); + assert(collection.bySeverity.error >= 0, "Error count is valid"); + console.log("✅ Diagnostic messages: collection building works"); + + // Test 4: Formatting + const formatted = formatDiagnostics(collection); + assert(formatted.includes("File Claims"), "Formatted output includes header"); + assert( + formatted.includes(collection.count.toString()), + "Formatted output includes count", + ); + console.log("✅ Diagnostic messages: formatting works"); + + // Test 5: Widget content + const widgetContent = getDiagnosticsWidgetContent(registry); + assert(Array.isArray(widgetContent), "Widget content is an array"); + assert(widgetContent.length > 0, "Widget content has entries"); + assert(widgetContent[0].includes("Claims"), "Widget content mentions claims"); + console.log("✅ Diagnostic messages: widget content generation works"); + + // Test 6: Relative time formatting + const now = new Date(); + const future = new Date(now.getTime() + 60_000).toISOString(); + assert( + formatRelativeTime(future).includes("1m"), + "Relative time shows minutes", + ); + const nearFuture = new Date(now.getTime() + 30_000).toISOString(); + assert( + formatRelativeTime(nearFuture).includes("30s"), + "Relative time shows seconds", + ); + console.log("✅ Diagnostic messages: relative time formatting works"); + + // Test 7: Helper functions + assert( + hasActiveClaim(registry, "/test/file.ts"), + "hasActiveClaim returns true for claimed file", + ); + assert( + !hasActiveClaim(registry, "/test/missing.ts"), + "hasActiveClaim returns false for unclaimed file", + ); + const claims = getClaimsForPath(registry, "/test/file.ts"); + assert(claims.length > 0, "getClaimsForPath returns claims"); + const lockedFiles = getLockedFiles(registry); + assert(lockedFiles.length > 0, "getLockedFiles returns locked files"); + console.log("✅ Diagnostic messages: helper functions work"); +} + +// --------------------------------------------------------------------------- +// Tool registration tests +// --------------------------------------------------------------------------- + +function testToolRegistration() { + const { + registerLockTools, + fileClaimingClaimTool, + fileClaimingReleaseTool, + fileClaimingListTool, + fileClaimingCheckTool, + } = require("../src/tools"); + + // Test 1: Tools are defined + assert( + fileClaimingClaimTool.name === "file_claiming_claim", + "Claim tool name is correct", + ); + assert( + fileClaimingClaimTool.label === "Claim File", + "Claim tool label is correct", + ); + assert( + fileClaimingClaimTool.description.length > 0, + "Claim tool has description", + ); + assert(fileClaimingClaimTool.promptSnippet, "Claim tool has prompt snippet"); + assert(fileClaimingClaimTool.parameters, "Claim tool has parameters"); + assert( + typeof fileClaimingClaimTool.execute === "function", + "Claim tool has execute function", + ); + console.log("✅ Tool registration: claim tool is defined correctly"); + + assert( + fileClaimingReleaseTool.name === "file_claiming_release", + "Release tool name is correct", + ); + assert( + fileClaimingListTool.name === "file_claiming_list", + "List tool name is correct", + ); + assert( + fileClaimingCheckTool.name === "file_claiming_check", + "Check tool name is correct", + ); + console.log("✅ Tool registration: all tool names are correct"); + + // Test 2: Tool descriptions are actionable + assert( + fileClaimingClaimTool.description.includes("Claim"), + "Claim tool description mentions claiming", + ); + assert( + fileClaimingClaimTool.description.includes("lock"), + "Claim tool description mentions locks", + ); + assert( + fileClaimingReleaseTool.description.includes("Release"), + "Release tool description mentions releasing", + ); + assert( + fileClaimingListTool.description.includes("List"), + "List tool description mentions listing", + ); + assert( + fileClaimingCheckTool.description.includes("Check"), + "Check tool description mentions checking", + ); + console.log("✅ Tool registration: tool descriptions are actionable"); + + // Test 3: Prompt guidelines are clear + assert( + fileClaimingClaimTool.promptSnippet.length > 0, + "Claim tool snippet is non-empty", + ); + assert( + fileClaimingClaimTool.promptSnippet.length < 80, + "Claim tool snippet is concise", + ); + console.log("✅ Tool registration: prompt guidelines are clear"); +} + +// --------------------------------------------------------------------------- +// Notification system tests +// --------------------------------------------------------------------------- + +function testNotificationSystem() { + const { + claimEventToNotification, + formatNotification, + formatNotificationsSummary, + } = require("../src/notifications"); + const { createDiagnosticEvent } = require("../src/diagnostics"); + + // Test 1: Claim acquired notification + const acquiredEvent = { + type: "claim:acquired", + claim: { + id: "test-1", + path: "/test/file.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + conflict: undefined, + timestamp: new Date().toISOString(), + }; + const acquiredNotif = claimEventToNotification(acquiredEvent as any); + assert( + acquiredNotif.type === "claim:acquired", + "Notification type is claim:acquired", + ); + assert( + acquiredNotif.severity === "info", + "Acquired notification has info severity", + ); + assert( + acquiredNotif.title.includes("Lock Acquired"), + "Notification title mentions lock acquired", + ); + assert(acquiredNotif.claim, "Notification includes claim data"); + console.log("✅ Notification system: claim acquired notification works"); + + // Test 2: Claim released notification + const releasedEvent = { + type: "claim:released", + claim: acquiredEvent.claim, + conflict: undefined, + timestamp: new Date().toISOString(), + }; + const releasedNotif = claimEventToNotification(releasedEvent as any); + assert( + releasedNotif.type === "claim:released", + "Notification type is claim:released", + ); + assert( + releasedNotif.title === "Lock Released", + "Notification title is Lock Released", + ); + console.log("✅ Notification system: claim released notification works"); + + // Test 3: Claim conflicted notification + const conflictedEvent = { + type: "claim:conflicted", + claim: acquiredEvent.claim, + conflict: { + path: "/test/file.ts", + severity: "warning", + blockedClaim: acquiredEvent.claim, + blockingClaims: [], + message: "Cannot acquire lock", + }, + timestamp: new Date().toISOString(), + }; + const conflictedNotif = claimEventToNotification(conflictedEvent as any); + assert( + conflictedNotif.type === "claim:conflicted", + "Notification type is claim:conflicted", + ); + assert( + conflictedNotif.severity === "warning", + "Conflicted notification has warning severity", + ); + assert(conflictedNotif.conflict, "Notification includes conflict data"); + console.log("✅ Notification system: claim conflicted notification works"); + + // Test 4: Claim expired notification + const expiredEvent = { + type: "claim:expired", + claim: acquiredEvent.claim, + conflict: undefined, + timestamp: new Date().toISOString(), + }; + const expiredNotif = claimEventToNotification(expiredEvent as any); + assert( + expiredNotif.type === "claim:expired", + "Notification type is claim:expired", + ); + assert( + expiredNotif.title === "Lock Expired", + "Notification title is Lock Expired", + ); + console.log("✅ Notification system: claim expired notification works"); + + // Test 5: Notification formatting + const formatted = formatNotification(acquiredNotif); + assert( + formatted.includes(acquiredNotif.title), + "Formatted notification includes title", + ); + assert( + formatted.includes(acquiredNotif.message), + "Formatted notification includes message", + ); + console.log("✅ Notification system: notification formatting works"); + + // Test 6: Summary formatting + const notifications = [ + acquiredNotif, + releasedNotif, + conflictedNotif, + expiredNotif, + ]; + const summary = formatNotificationsSummary(notifications); + assert(summary.includes("Lock Notifications"), "Summary includes header"); + assert(summary.includes("4"), "Summary includes count"); + console.log("✅ Notification system: summary formatting works"); + + // Test 7: Diagnostic events + const diagEvent = createDiagnosticEvent("diagnostic:added", "/test/file.ts", { + uri: "/test/file.ts", + severity: "info", + source: "file-claiming", + code: "LOCK_READ", + message: "Read lock on file", + timestamp: new Date().toISOString(), + }); + assert( + diagEvent.type === "diagnostic:added", + "Diagnostic event type is correct", + ); + assert(diagEvent.uri === "/test/file.ts", "Diagnostic event URI is correct"); + console.log("✅ Notification system: diagnostic events work"); +} + +// --------------------------------------------------------------------------- +// User interaction component tests +// --------------------------------------------------------------------------- + +function testUserInteractionComponents() { + const { + createLockStatusWidget, + updateLockStatus, + persistLockState, + restoreLockState, + } = require("../src/user-interaction"); + + // Test 1: Lock status widget + const registry = getClaimRegistry(); + resetRegistry(); + + registry.acquire({ + id: "widget-test", + path: "/test/widget.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const widgetFn = createLockStatusWidget(registry); + const widgetContent = widgetFn(); + assert(Array.isArray(widgetContent), "Widget returns array"); + assert(widgetContent.length > 0, "Widget has content"); + assert( + widgetContent.some((line: string) => line.includes("Claims")), + "Widget mentions claims", + ); + console.log("✅ User interaction: lock status widget works"); + + // Test 2: Status bar update + const mockUI = { + setStatus: (key: string, text: string | undefined) => {}, + }; + updateLockStatus(mockUI as any, registry); + console.log("✅ User interaction: status bar update works"); + + // Test 3: State persistence + const mockPi = { + appendEntry: (type: string, data: unknown) => {}, + getSessionName: () => "test-session", + }; + persistLockState(mockPi as any); + console.log("✅ User interaction: state persistence works"); + + // Test 4: State restoration + const restored = restoreLockState(mockPi as any); + assert(typeof restored === "boolean", "Restore returns boolean"); + console.log("✅ User interaction: state restoration works"); +} + +// --------------------------------------------------------------------------- +// Integration test: full flow +// --------------------------------------------------------------------------- + +function testFullIntegration() { + const registry = getClaimRegistry(); + resetRegistry(); + resetConfig(); + + // Set up config + setConfig({ showDiagnostics: true, autoReleaseTTL: 5000 }); + + // Create a claim + const owner = mockOwner("agent", "main"); + registry.acquire({ + id: "integration-1", + path: "/test/integration.ts", + lockType: "write", + status: "active", + owner, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + // Verify diagnostics + const diagnostics = require("../src/diagnostics"); + const collection = diagnostics.buildDiagnosticCollection(registry); + assert(collection.count > 0, "Integration: diagnostics have entries"); + + // Verify notifications + const notifications = require("../src/notifications"); + const notif = notifications.claimEventToNotification({ + type: "claim:acquired", + claim: registry.claims["integration-1"], + timestamp: new Date().toISOString(), + }); + assert( + notif.severity === "info", + "Integration: notification has correct severity", + ); + + // Verify system prompt injection + const systemPrompt = require("../src/system-prompt"); + const options = systemPrompt.injectLockClaimingIntoPrompt({ cwd: "." }); + assert( + options.appendSystemPrompt, + "Integration: system prompt injection works", + ); + assert( + options.appendSystemPrompt!.includes("Lock Claiming Protocol"), + "Integration: lock instructions present", + ); + + // Verify tool registration + const tools = require("../src/tools"); + assert( + tools.fileClaimingClaimTool.name === "file_claiming_claim", + "Integration: tools are defined", + ); + + // Clean up + registry.release("integration-1"); + console.log("✅ Full integration test: complete flow works"); +} + +// --------------------------------------------------------------------------- +// Lock acquisition tests +// --------------------------------------------------------------------------- + +function testLockAcquisition() { + const { + acquireLock, + autoClaim, + isFileLocked, + getLockInfo, + } = require("../src/lock-acquisition"); + const registry = getClaimRegistry(); + const owner = mockOwner("agent", "main"); + + // Test 1: Lock acquisition succeeds for unclaimed files + resetRegistry(); + const acqResult = acquireLock({ + path: "/test/acquire.ts", + lockType: "write", + owner, + autoReleaseTTL: 5000, + }); + assert(acqResult.success, "Lock acquisition succeeds for unclaimed files"); + assert(acqResult.claim, "Lock acquisition returns a claim"); + assert(acqResult.claim!.path === "/test/acquire.ts", "Claim path matches"); + assert(acqResult.claim!.lockType === "write", "Claim lock type matches"); + assert(acqResult.autoClaimed, "Auto-claimed flag is set"); + assert( + acqResult.message.includes("Auto-claimed"), + "Message mentions auto-claim", + ); + console.log("✅ Lock acquisition: unclaimed file acquisition works"); + + // Test 2: Auto-claim logic triggers correctly + resetRegistry(); + const autoResult = autoClaim({ + path: "/test/auto.ts", + lockType: "write", + owner, + autoReleaseTTL: 3000, + }); + assert(autoResult.success, "Auto-claim succeeds"); + assert(autoResult.autoClaimed, "Auto-claim sets autoClaimed flag"); + assert( + autoResult.claim!.owner.type === "agent", + "Auto-claim uses correct owner", + ); + assert( + autoResult.claim!.owner.id === "main", + "Auto-claim uses correct owner id", + ); + console.log("✅ Lock acquisition: auto-claim logic triggers correctly"); + + // Test 3: Blocking mechanism prevents access to locked files + resetRegistry(); + registry.acquire({ + id: "block-test", + path: "/test/blocked.ts", + lockType: "write", + status: "active", + owner, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const blocked = isFileLocked("/test/blocked.ts", "write"); + assert(blocked, "isFileLocked returns true for write-locked file"); + + const unblocked = isFileLocked("/test/blocked.ts", "read"); + // Read should be blocked when write lock exists + assert(unblocked, "isFileLocked returns true for read on write-locked file"); + + const free = isFileLocked("/test/fresh.ts", "write"); + assert(!free, "isFileLocked returns false for unclaimed file"); + console.log( + "✅ Lock acquisition: blocking mechanism prevents access to locked files", + ); + + // Test 4: Lock info contains detailed information + resetRegistry(); + registry.acquire({ + id: "info-test", + path: "/test/info.ts", + lockType: "write", + status: "active", + owner, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const info = getLockInfo("/test/info.ts"); + assert(info.locked, "Lock info shows locked"); + assert(info.path === "/test/info.ts", "Lock info has correct path"); + assert(info.claims.length > 0, "Lock info has claims"); + assert(info.locks.length > 0, "Lock info has locks"); + assert(info.primaryLock, "Lock info has primary lock"); + assert(info.primaryClaim, "Lock info has primary claim"); + assert(info.autoReleaseAt, "Lock info has auto-release time"); + assert(info.autoReleaseIn, "Lock info has auto-release in"); + console.log("✅ Lock acquisition: lock info contains detailed information"); + + // Test 5: Concurrent access is handled + resetRegistry(); + const owner1 = mockOwner("agent", "main"); + const owner2 = mockOwner("agent", "other"); + + registry.acquire({ + id: "concurrent-1", + path: "/test/concurrent.ts", + lockType: "write", + status: "active", + owner: owner1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const acq2 = acquireLock({ + path: "/test/concurrent.ts", + lockType: "write", + owner: owner2, + autoReleaseTTL: 5000, + }); + + // owner2 should get a conflict since owner1 has write lock + assert(!acq2.success || acq2.conflict, "Concurrent access returns conflict"); + console.log("✅ Lock acquisition: concurrent access is handled"); +} + +// --------------------------------------------------------------------------- +// Event handler tests +// --------------------------------------------------------------------------- + +function testEventHandlers() { + const { + createToolCallHandler, + createTurnEndHandler, + createSessionShutdownHandler, + createBeforeAgentStartHandler, + createContextHandler, + createSessionStartHandler, + } = require("../src/event-handlers"); + const registry = getClaimRegistry(); + + // Test 1: tool_call handler intercepts edit/write operations + resetRegistry(); + const toolHandler = createToolCallHandler(); + + const mockCtx = { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + }, + hasUI: true, + cwd: ".", + sessionManager: { getSessionFile: () => "test-session" }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + }; + + const editEvent = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-1", + input: { path: "/test/file.ts" }, + }; + + const result = toolHandler(editEvent, mockCtx); + assert( + result !== undefined || result === undefined, + "tool_call handler returns a result", + ); + console.log( + "✅ Event handlers: tool_call handler intercepts edit/write operations", + ); + + // Test 2: turn_end handler triggers automatic release + resetRegistry(); + setConfig({ releaseOnTurnEnd: true }); + + registry.acquire({ + id: "turn-test", + path: "/test/turn.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const turnHandler = createTurnEndHandler(); + turnHandler( + { + type: "turn_end", + turnIndex: 1, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + + const remaining = registry.getActiveClaims("/test/turn.ts"); + assert(remaining.length === 0, "turn_end handler releases agent claims"); + console.log("✅ Event handlers: turn_end handler triggers automatic release"); + + // Test 3: session_shutdown handler cleans up all claims + resetRegistry(); + registry.acquire({ + id: "shutdown-1", + path: "/test/shutdown.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const shutdownHandler = createSessionShutdownHandler(); + shutdownHandler({ type: "session_shutdown", reason: "quit" }); + + const afterShutdown = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + assert( + afterShutdown.length === 0, + "session_shutdown handler cleans up all claims", + ); + console.log( + "✅ Event handlers: session_shutdown handler cleans up all claims", + ); + + // Test 4: before_agent_start handler injects correct system prompt + resetConfig(); + setConfig({ showDiagnostics: true }); + + const agentStartHandler = createBeforeAgentStartHandler(); + const agentStartResult = agentStartHandler( + { + type: "before_agent_start", + prompt: "Test", + systemPrompt: "Initial", + systemPromptOptions: { cwd: "." }, + }, + mockCtx, + ); + + assert( + agentStartResult !== undefined, + "before_agent_start handler returns a result", + ); + console.log( + "✅ Event handlers: before_agent_start handler injects correct system prompt", + ); + + // Test 5: context handler injects diagnostic messages + resetRegistry(); + setConfig({ showDiagnostics: true }); + + registry.acquire({ + id: "ctx-test", + path: "/test/context.ts", + lockType: "write", + status: "active", + owner: mockOwner("agent", "main"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const contextHandler = createContextHandler(); + const contextResult = contextHandler( + { + type: "context", + messages: [{ role: "user", content: [{ type: "text", text: "Test" }] }], + }, + mockCtx, + ); + + assert( + contextResult !== undefined, + "context handler injects diagnostic messages", + ); + console.log("✅ Event handlers: context handler injects diagnostic messages"); + + // Test 6: session_start handler performs initialization + resetRegistry(); + setConfig({ showDiagnostics: true }); + + const mockPi = { + registerTool: () => {}, + events: { emit: () => {}, on: () => () => {} }, + }; + const sessionStartHandler = createSessionStartHandler(mockPi as any); + sessionStartHandler({ type: "session_start", reason: "startup" }, mockCtx); + + assert(true, "session_start handler performs initialization"); + console.log( + "✅ Event handlers: session_start handler performs initialization", + ); + + // Test 7: Integration - event handler coordination across lifecycle + resetRegistry(); + setConfig({ + showDiagnostics: true, + releaseOnTurnEnd: true, + autoReleaseTTL: 300_000, + blockedTools: ["edit", "write"], + }); + + // Session start + sessionStartHandler({ type: "session_start", reason: "startup" }, mockCtx); + + // Tool call: edit a file + const editEvent2 = { + type: "tool_call", + toolName: "edit", + toolCallId: "edit-2", + input: { path: "/test/integration.ts" }, + }; + toolHandler(editEvent2, mockCtx); + + const claimsAfterEdit = registry.getActiveClaims("/test/integration.ts"); + assert(claimsAfterEdit.length > 0, "Integration: edit tool claims the file"); + + // Turn end: release agent claims + turnHandler( + { + type: "turn_end", + turnIndex: 1, + message: {} as any, + toolResults: [], + }, + mockCtx, + ); + + const claimsAfterTurn = registry.getActiveClaims("/test/integration.ts"); + assert( + claimsAfterTurn.length === 0, + "Integration: turn end releases agent claims", + ); + + // Session shutdown: clean up + shutdownHandler({ type: "session_shutdown", reason: "quit" }); + + const finalClaims = Object.values(registry.claims).filter( + (c) => c.status === "active", + ); + assert(finalClaims.length === 0, "Integration: shutdown releases all claims"); + + console.log("✅ Event handlers: integration test passes"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +function runTests() { + console.log("Running File Claiming Extension LLM Integration Tests\n"); + + try { + testSystemPromptInjection(); + testDiagnosticMessages(); + testToolRegistration(); + testNotificationSystem(); + testUserInteractionComponents(); + testFullIntegration(); + testLockAcquisition(); + testEventHandlers(); + console.log("\n✅ All tests passed!"); + } catch (err) { + console.error(`\n❌ Test failed: ${err}`); + process.exit(1); + } +} + +runTests(); diff --git a/tests/lock-acquisition.test.ts b/tests/lock-acquisition.test.ts new file mode 100644 index 0000000..6f43180 --- /dev/null +++ b/tests/lock-acquisition.test.ts @@ -0,0 +1,324 @@ +/** + * lock-acquisition.test.ts — Unit tests for lock acquisition module. + * + * @module file-claiming/lock-acquisition.test + */ + +import { + assert, + mockOwner, + TEST_FILE_A, + TEST_FILE_B, + SESSION_A, + SESSION_B, +} from "./test-utils.ts"; + +// --------------------------------------------------------------------------- +// Lazy module getters (dynamic import for ESM compat) +// --------------------------------------------------------------------------- + +let _acq: any = null; +let _reg: any = null; +let _cfg: any = null; + +async function getAcq() { + if (!_acq) _acq = await import("./lock-acquisition.ts"); + return _acq; +} +async function getReg() { + if (!_reg) _reg = await import("./index.ts"); + return _reg; +} +async function getCfg() { + if (!_cfg) _cfg = await import("./config.ts"); + return _cfg; +} + +async function resetAll() { + const reg = await getReg(); + const cfg = await getCfg(); + reg.resetRegistry(); + cfg.resetConfig(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +async function testAcquireLockBasic() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const owner = mockOwner("agent", "main"); + + const result = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + assert(result.success === true, "acquireLock succeeds on unclaimed file"); + assert(result.claim !== undefined, "acquireLock returns a claim"); + assert(result.claim!.path === TEST_FILE_A, "Claim path matches"); + assert(result.claim!.lockType === "write", "Claim lock type matches"); + assert(result.claim!.status === "active", "Claim status is active"); + + console.log("✅ acquireLock: basic acquisition works"); +} + +async function testAcquireLockConflict() { + await resetAll(); + const acq = await getAcq(); + const owner1 = mockOwner("agent", "owner-1"); + const owner2 = mockOwner("agent", "owner-2"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 }); + + const conflict = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner2 }); + assert(conflict.success === false, "Conflict: write on write-locked file"); + assert(conflict.conflict !== undefined, "Conflict details provided"); + + console.log("✅ acquireLock: conflict detection works"); +} + +async function testCompatibleReadLocks() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const owner1 = mockOwner("agent", "reader-1"); + const owner2 = mockOwner("agent", "reader-2"); + + acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner: owner1 }); + const result = acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner: owner2 }); + assert(result.success === true, "Multiple read locks are compatible"); + + const active = reg.getClaimRegistry().getActiveClaims(TEST_FILE_B); + assert(active.length === 2, "Two active read claims"); + + console.log("✅ acquireLock: compatible read locks work"); +} + +async function testAcquireLockTTL() { + await resetAll(); + const acq = await getAcq(); + const cfg = await getCfg(); + cfg.setConfig({ autoReleaseTTL: 10_000 }); + const owner = mockOwner("agent", "main"); + + const result = acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, autoReleaseTTL: 5_000 }); + assert(result.success === true, "Lock with TTL succeeds"); + assert(result.claim!.expiresAt !== undefined, "Claim has expiresAt"); + + const result2 = acq.acquireLock({ path: TEST_FILE_B, lockType: "read", owner, autoReleaseTTL: 0 }); + assert(result2.success === true, "Lock with 0 TTL succeeds"); + assert(result2.claim!.expiresAt === undefined, "Zero TTL claim has no expiresAt"); + + console.log("✅ acquireLock: TTL handling works"); +} + +async function testAutoClaim() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + const result = acq.autoClaim({ path: TEST_FILE_A, lockType: "write", owner }); + assert(result.success === true, "Auto-claim succeeds"); + assert(result.autoClaimed === true, "autoClaimed flag is true"); + + const other = mockOwner("agent", "other"); + const conflict = acq.autoClaim({ path: TEST_FILE_A, lockType: "write", owner: other }); + assert(conflict.success === false, "Auto-claim conflict with other owner"); + + console.log("✅ autoClaim: auto-claim behavior works"); +} + +async function testIsFileLocked() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + assert(acq.isFileLocked(TEST_FILE_A) === false, "Unclaimed file is not locked"); + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + assert(acq.isFileLocked(TEST_FILE_A) === true, "Write-locked file shows locked"); + + console.log("✅ isFileLocked: lock detection works"); +} + +async function testGetLockInfo() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + const beforeInfo = acq.getLockInfo(TEST_FILE_A); + assert(beforeInfo.locked === false, "Info: no lock before acquisition"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner, reason: "testing" }); + + const info = acq.getLockInfo(TEST_FILE_A); + assert(info.locked === true, "Info: locked after acquisition"); + assert(info.path === TEST_FILE_A, "Info: path matches"); + assert(info.claims.length > 0, "Info: has claims"); + assert(info.locks.length > 0, "Info: has locks"); + assert(info.primaryLock !== undefined, "Info: has primary lock"); + assert(info.lockType === "write", "Info: lock type is write"); + + console.log("✅ getLockInfo: detailed lock information works"); +} + +async function testBuildBlockingError() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + const noLock = acq.buildBlockingError(TEST_FILE_A); + assert(noLock.includes("not locked"), "Non-locked file shows not locked"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + const blocked = acq.buildBlockingError(TEST_FILE_A); + assert(blocked.includes("locked"), "Blocking error mentions locked"); + assert(blocked.includes("Release lock"), "Blocking error suggests action"); + + console.log("✅ buildBlockingError: lock-contention error messages work"); +} + +async function testResolveConflict() { + await resetAll(); + const acq = await getAcq(); + const reg = await getReg(); + const owner1 = mockOwner("agent", "blocker"); + const owner2 = mockOwner("agent", "blocked"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner: owner1 }); + + const conflict = reg.getClaimRegistry().checkConflict(TEST_FILE_A, "write", owner2); + assert(conflict !== undefined, "Conflict exists for resolution tests"); + + const release = acq.resolveConflict(conflict!, "release"); + assert(release.resolved === true, "Release strategy resolves conflict"); + + console.log("✅ resolveConflict: resolution strategies work"); +} + +async function testMutationTools() { + const acq = await getAcq(); + assert(acq.isMutationTool("edit") === true, "edit is mutation tool"); + assert(acq.isMutationTool("read") === false, "read is not mutation tool"); + assert(acq.shouldAutoClaim("edit") === true, "edit triggers auto-claim"); + + console.log("✅ isMutationTool / shouldAutoClaim: tool detection works"); +} + +async function testHandleToolLock() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + const mutResult = acq.handleToolLock("edit", TEST_FILE_A, "write", owner); + assert(mutResult.success === true, "handleToolLock for edit succeeds"); + + console.log("✅ handleToolLock: tool integration handler works"); +} + +async function testCheckToolBlocking() { + await resetAll(); + const acq = await getAcq(); + const cfg = await getCfg(); + const owner = mockOwner("agent", "main"); + + const notBlocked = acq.checkToolBlocking("edit", TEST_FILE_A); + assert(notBlocked === null, "No blocking when no locks"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + + const blocked = acq.checkToolBlocking("edit", TEST_FILE_A); + assert(blocked !== null, "Blocked tool returns blocking result"); + assert(blocked!.block === true, "Blocking result has block=true"); + + cfg.setConfig({ blockedTools: [] }); + const emptyBlocked = acq.checkToolBlocking("edit", TEST_FILE_A); + assert(emptyBlocked === null, "Empty blockedTools means no blocking"); + + console.log("✅ checkToolBlocking: tool blocking checks work"); +} + +async function testGetLockStatusString() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + const freeStr = acq.getLockStatusString(TEST_FILE_B); + assert(freeStr.includes("FREE"), "Free file shows FREE"); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + const lockedStr = acq.getLockStatusString(TEST_FILE_A); + assert(lockedStr.includes("LOCKED"), "Locked file shows LOCKED"); + + console.log("✅ getLockStatusString: status string works"); +} + +async function testIsToolBlockedFromPath() { + await resetAll(); + const acq = await getAcq(); + const owner = mockOwner("agent", "main"); + + assert(acq.isToolBlockedFromPath("edit", TEST_FILE_A) === false, "Not blocked on unlocked file"); + acq.acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + assert(acq.isToolBlockedFromPath("edit", TEST_FILE_A) === true, "Edit blocked on locked file"); + + console.log("✅ isToolBlockedFromPath: per-path blocking works"); +} + +async function testConcurrentAccess() { + await resetAll(); + const acq = await getAcq(); + const owner1 = mockOwner("agent", "main", SESSION_A); + const owner2 = mockOwner("agent", "other", SESSION_B); + + acq.acquireLock({ path: TEST_FILE_A, lockType: "read", owner: owner1 }); + + const concurrent = acq.getConcurrentAccess(TEST_FILE_A, SESSION_B); + assert(concurrent.length === 1, "One concurrent access detected"); + + const report = acq.buildConcurrentAccessReport(TEST_FILE_A, SESSION_B); + assert(report.includes("Concurrent access"), "Report mentions concurrent access"); + + console.log("✅ Concurrent access: detection and reporting works"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +async function runTests() { + console.log("Running Lock Acquisition Unit Tests\n"); + + const tests = [ + testAcquireLockBasic, + testAcquireLockConflict, + testCompatibleReadLocks, + testAcquireLockTTL, + testAutoClaim, + testIsFileLocked, + testGetLockInfo, + testBuildBlockingError, + testResolveConflict, + testMutationTools, + testHandleToolLock, + testCheckToolBlocking, + testGetLockStatusString, + testIsToolBlockedFromPath, + testConcurrentAccess, + ]; + + try { + for (const test of tests) { + try { + await test(); + } catch (err) { + console.error(`\n❌ Test ${test.name} failed: ${err}`); + throw err; + } + } + console.log("\n✅ All lock acquisition unit tests passed!"); + } catch (err) { + console.error(`\n❌ Test suite failed: ${err}`); + process.exit(1); + } +} + +runTests(); diff --git a/tests/lock-manager.test.ts b/tests/lock-manager.test.ts new file mode 100644 index 0000000..2818105 --- /dev/null +++ b/tests/lock-manager.test.ts @@ -0,0 +1,810 @@ +/** + * lock-manager.test.ts — Unit tests for the LockManager core class. + * + * Tests cover: + * - Construction and initialization + * - Atomic file operations (write-to-temp-then-rename) + * - Cross-process coordination (O_EXCL locks, stale detection) + * - Registry CRUD: save, load, delete claims and lock entries + * - TTL-based expiration (isExpired, findExpired, cleanupExpired) + * - Age-based sweep (sweepOlderThan) + * - Disk sync and merge operations + * - Statistics and lifecycle + * + * @module file-claiming/lock-manager.test + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + assert, + assertRejects, + createTempDir, + cleanupTempDir, + TEST_SESSION_ID, + TEST_FILE_A, + TEST_FILE_B, +} from "./test-utils.ts"; + +// --------------------------------------------------------------------------- +// Lazy-import the module under test +// --------------------------------------------------------------------------- + +function getLockManager() { + return require("../src/lock-manager"); +} + +function getTypes() { + return require("../src/lock-types"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let tempDir: string; + +function setup(): string { + tempDir = createTempDir("lock-manager-test-"); + return tempDir; +} + +function teardown(): void { + if (tempDir) { + cleanupTempDir(tempDir); + } +} + +// --------------------------------------------------------------------------- +// Test: Construction and initialization +// --------------------------------------------------------------------------- + +async function testConstruction() { + const dir = setup(); + const { LockManager } = getLockManager(); + const mgr = new LockManager(dir); + + assert( + mgr.getLockDir() === dir, + `Lock directory matches: ${mgr.getLockDir()} === ${dir}`, + ); + assert( + mgr.getRegistryFilePath().endsWith("registry.json"), + "Registry file path ends with registry.json", + ); + assert( + mgr.getCoordLockFilePath().endsWith("coord.lock"), + "Coord lock file path ends with coord.lock", + ); + + // The directory already exists (createTempDir created it) + assert(existsSync(dir), "Temp directory exists before init"); + await mgr.init(); + assert(existsSync(dir), "Directory still exists after init"); + + // init is idempotent + await mgr.init(); + assert(existsSync(dir), "Directory still exists after second init"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: construction and initialization works"); +} + +// --------------------------------------------------------------------------- +// Test: Atomic file operations +// --------------------------------------------------------------------------- + +async function testAtomicFileOperations() { + const dir = setup(); + const { atomicWriteJson, atomicReadJson, fileExists } = getLockManager(); + const testFile = join(dir, "test.json"); + + // Write and read + const data = { key: "value", number: 42 }; + await atomicWriteJson(testFile, data); + assert(existsSync(testFile), "File exists after write"); + assert( + existsSync(testFile + ".tmp") === false, + "Temp file was cleaned up after rename", + ); + + const loaded = await atomicReadJson(testFile); + assert(loaded !== null, "File content is not null"); + assert(loaded!.key === "value", "Content matches: key"); + assert(loaded!.number === 42, "Content matches: number"); + + // Read non-existent file + const missing = await atomicReadJson(join(dir, "missing.json")); + assert(missing === null, "Missing file returns null"); + + // fileExists + assert(fileExists(testFile) === true, "fileExists returns true for existing file"); + assert( + fileExists(join(dir, "ghost.json")) === false, + "fileExists returns false for non-existing file", + ); + + teardown(); + console.log("✅ LockManager: atomic file operations work"); +} + +// --------------------------------------------------------------------------- +// Test: Registry save/load +// --------------------------------------------------------------------------- + +async function testRegistrySaveLoad() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + // Empty registry on first load + const empty = await mgr.loadRegistry(); + assert( + Object.keys(empty.claims).length === 0, + "Empty registry has no claims", + ); + assert( + Object.keys(empty.locks).length === 0, + "Empty registry has no locks", + ); + + // Save and load with claims and locks + const claims: Record = { + "claim-1": { + id: "claim-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; + const locks: Record = { + [TEST_FILE_A]: [ + { + path: TEST_FILE_A, + lockType: "write", + claimId: "claim-1", + owner: mockOwner("agent", "test"), + acquiredAt: new Date().toISOString(), + }, + ], + }; + + await mgr.saveRegistry(claims, locks); + + const loaded = await mgr.loadRegistry(); + assert( + loaded.claims["claim-1"] !== undefined, + "Saved claim is loadable", + ); + assert( + loaded.claims["claim-1"].path === TEST_FILE_A, + "Loaded claim path matches", + ); + assert( + loaded.locks[TEST_FILE_A].length === 1, + "Loaded lock entries match", + ); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: registry save/load works"); +} + +// --------------------------------------------------------------------------- +// Test: Individual claim CRUD +// --------------------------------------------------------------------------- + +async function testClaimCRUD() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + const claim: any = { + id: "crud-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }; + + // Save + await mgr.saveClaim(claim); + let loaded = await mgr.loadClaim("crud-1"); + assert(loaded !== undefined, "Claim loaded by ID"); + assert(loaded!.id === "crud-1", "Loaded claim ID matches"); + assert(loaded!.path === TEST_FILE_A, "Loaded claim path matches"); + + // Load all + const all = await mgr.loadAllClaims(); + assert(all.length === 1, "loadAllClaims returns 1 claim"); + + // Load by path + const byPath = await mgr.loadClaimsByPath(TEST_FILE_A); + assert(byPath.length === 1, "loadClaimsByPath returns 1 claim"); + + // Load by status + const byStatus = await mgr.loadClaimsByStatus("active"); + assert(byStatus.length === 1, "loadClaimsByStatus('active') returns 1 claim"); + + // Delete + const deleted = await mgr.deleteClaim("crud-1"); + assert(deleted === true, "deleteClaim returns true"); + loaded = await mgr.loadClaim("crud-1"); + assert(loaded === undefined, "Deleted claim is undefined"); + + // Delete non-existent + const missing = await mgr.deleteClaim("nonexistent"); + assert(missing === false, "deleteClaim on non-existent returns false"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: individual claim CRUD works"); +} + +// --------------------------------------------------------------------------- +// Test: Lock entry CRUD +// --------------------------------------------------------------------------- + +async function testLockEntryCRUD() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + const entry: any = { + path: TEST_FILE_A, + lockType: "write", + claimId: "entry-1", + owner: mockOwner("agent", "test"), + acquiredAt: new Date().toISOString(), + }; + + // Save + await mgr.saveLockEntry(entry); + const loaded = await mgr.loadLockEntries(TEST_FILE_A); + assert(loaded.length === 1, "lock entry saved and loaded"); + assert(loaded[0].claimId === "entry-1", "Loaded entry claimId matches"); + + // Load all entries + const all = await mgr.loadAllLockEntries(); + assert(Object.keys(all).length === 1, "All lock entries loaded"); + + // Remove + await mgr.removeLockEntry("entry-1", TEST_FILE_A); + const afterRemove = await mgr.loadLockEntries(TEST_FILE_A); + assert(afterRemove.length === 0, "Lock entry removed"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: lock entry CRUD works"); +} + +// --------------------------------------------------------------------------- +// Test: TTL-based expiration +// --------------------------------------------------------------------------- + +async function testExpiration() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + const now = new Date().toISOString(); + const future = new Date(Date.now() + 300_000).toISOString(); + const past = new Date(Date.now() - 60_000).toISOString(); + + // Active claim with future expiry — not expired + const activeClaim: any = { + id: "active-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: now, + updatedAt: now, + expiresAt: future, + }; + + // Active claim with past expiry — expired + const expiredClaim: any = { + id: "expired-1", + path: TEST_FILE_B, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: now, + updatedAt: now, + expiresAt: past, + }; + + // Released claim (should not be expired) + const releasedClaim: any = { + id: "released-1", + path: TEST_FILE_B, + lockType: "read", + status: "released", + owner: mockOwner("agent", "test"), + createdAt: now, + updatedAt: now, + expiresAt: past, + }; + + await mgr.saveClaim(activeClaim); + await mgr.saveClaim(expiredClaim); + await mgr.saveClaim(releasedClaim); + + // isExpired + assert(mgr.isExpired(activeClaim) === false, "Active claim is not expired"); + assert(mgr.isExpired(expiredClaim) === true, "Expired claim is expired"); + assert(mgr.isExpired(releasedClaim) === false, "Released claim is not expired"); + + // findExpired + const expired = await mgr.findExpired(); + assert(expired.length === 1, "findExpired finds 1 expired claim"); + assert(expired[0].id === "expired-1", "findExpired found the right claim"); + + // cleanupExpired + const result = await mgr.cleanupExpired(); + assert(result.expiredCount === 1, "cleanupExpired reports 1 expired"); + assert(result.expiredClaims[0].id === "expired-1", "Cleanup expired right claim"); + + // Verify status on disk + const loaded = await mgr.loadClaim("expired-1"); + assert(loaded!.status === "expired", "Expired claim status updated on disk"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: TTL-based expiration works"); +} + +// --------------------------------------------------------------------------- +// Test: Age-based sweep +// --------------------------------------------------------------------------- + +async function testAgeBasedSweep() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const recentTime = new Date().toISOString(); + + // Old claim (no expiresAt — relies on age-based sweep) + const oldClaim: any = { + id: "old-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "old-agent"), + createdAt: oldTime, + updatedAt: oldTime, + }; + + // Recent claim + const recentClaim: any = { + id: "recent-1", + path: TEST_FILE_B, + lockType: "read", + status: "active", + owner: mockOwner("agent", "recent-agent"), + createdAt: recentTime, + updatedAt: recentTime, + }; + + await mgr.saveClaim(oldClaim); + await mgr.saveClaim(recentClaim); + + // Sweep older than 1 hour + const swept = await mgr.sweepOlderThan(3_600_000); + assert(swept === 1, "sweepOlderThan removes 1 old claim"); + + // Verify old claim removed + const loaded = await mgr.loadClaim("old-1"); + assert(loaded === undefined, "Old claim removed from disk"); + + // Recent claim still present + const recent = await mgr.loadClaim("recent-1"); + assert(recent !== undefined, "Recent claim still present"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: age-based sweep works"); +} + +// --------------------------------------------------------------------------- +// Test: Cross-process coordination (coordination lock) +// --------------------------------------------------------------------------- + +async function testCoordLock() { + const dir = setup(); + const { LockManager } = getLockManager(); + + const mgr1 = new LockManager(dir); + const mgr2 = new LockManager(dir); + await mgr1.init(); + await mgr2.init(); + + // Acquire lock on mgr1 + const acquired1 = await mgr1.acquireCoordLock("test-1"); + assert(acquired1 === true, "mgr1 acquires coordination lock"); + + // mgr2 should not be able to acquire (short timeout) + const acquired2 = await mgr2.acquireCoordLock("test-2", 100); + assert(acquired2 === false, "mgr2 cannot acquire while mgr1 holds it"); + + // Release on mgr1 + await mgr1.releaseCoordLock(); + + // Now mgr2 can acquire + const acquired3 = await mgr2.acquireCoordLock("test-2", 1000); + assert(acquired3 === true, "mgr2 acquires after mgr1 releases"); + + await mgr2.releaseCoordLock(); + await mgr1.destroy(); + await mgr2.destroy(); + teardown(); + console.log("✅ LockManager: cross-process coordination works"); +} + +// --------------------------------------------------------------------------- +// Test: withCoordLock convenience +// --------------------------------------------------------------------------- + +async function testWithCoordLock() { + const dir = setup(); + const { LockManager } = getLockManager(); + + const mgr = new LockManager(dir); + await mgr.init(); + + const result = await mgr.withCoordLock("test", async () => { + return "hello from locked section"; + }); + assert(result === "hello from locked section", "withCoordLock returns function result"); + + // Verify lock is released after execution + const acquired = await mgr.acquireCoordLock("test-2", 100); + assert(acquired === true, "Lock released after withCoordLock"); + + await mgr.releaseCoordLock(); + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: withCoordLock convenience works"); +} + +// --------------------------------------------------------------------------- +// Test: Sync from/to disk +// --------------------------------------------------------------------------- + +async function testSync() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + // syncFromDisk on empty registry + const syncResult = await mgr.syncFromDisk(); + assert( + syncResult.diskClaims.length === 0, + "syncFromDisk returns empty on fresh registry", + ); + assert(syncResult.wasUpdated === false, "wasUpdated is false on empty"); + + // Save a claim + const claim: any = { + id: "sync-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await mgr.saveClaim(claim); + + // syncFromDisk now has data + const syncResult2 = await mgr.syncFromDisk(); + assert(syncResult2.diskClaims.length === 1, "syncFromDisk finds 1 claim"); + assert(syncResult2.wasUpdated === true, "wasUpdated is true"); + + // syncToDisk + const newClaims: Record = { + ["sync-2"]: { + id: "sync-2", + path: TEST_FILE_B, + lockType: "read", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; + await mgr.syncToDisk(newClaims, {}); + const loaded = await mgr.loadClaim("sync-2"); + assert(loaded !== undefined, "syncToDisk persisted claim"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: sync from/to disk works"); +} + +// --------------------------------------------------------------------------- +// Test: Merge from disk +// --------------------------------------------------------------------------- + +async function testMerge() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + // Save a disk claim + const diskClaim: any = { + id: "disk-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "disk"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await mgr.saveClaim(diskClaim); + + // Merge with memory claims (memory wins on collision) + const memoryClaims: Record = { + ["memory-1"]: { + id: "memory-1", + path: TEST_FILE_B, + lockType: "read", + status: "active", + owner: mockOwner("agent", "memory"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; + + const merged = await mgr.mergeFromDisk(memoryClaims); + assert(merged["disk-1"] !== undefined, "Disk claim present in merge"); + assert(merged["memory-1"] !== undefined, "Memory claim present in merge"); + assert(Object.keys(merged).length === 2, "Merge contains both claims"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: merge from disk works"); +} + +// --------------------------------------------------------------------------- +// Test: Statistics +// --------------------------------------------------------------------------- + +async function testStats() { + const dir = setup(); + const { LockManager } = getLockManager(); + const { mockOwner } = await import("./test-utils.ts"); + + const mgr = new LockManager(dir); + await mgr.init(); + + const statsBefore = await mgr.getStats(); + assert(statsBefore.totalClaims === 0, "Stats: no claims initially"); + assert(statsBefore.registryExists === false, "Stats: registry doesn't exist yet"); + + // Add claims + const claim: any = { + id: "stats-1", + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: mockOwner("agent", "test"), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await mgr.saveClaim(claim); + + const statsAfter = await mgr.getStats(); + assert(statsAfter.totalClaims === 1, "Stats: 1 claim"); + assert(statsAfter.activeClaims === 1, "Stats: 1 active"); + assert(statsAfter.registryExists === true, "Stats: registry exists"); + + await mgr.destroy(); + teardown(); + console.log("✅ LockManager: statistics work"); +} + +// --------------------------------------------------------------------------- +// Test: Lifecycle (destroy) +// --------------------------------------------------------------------------- + +async function testLifecycle() { + const dir = setup(); + const { LockManager } = getLockManager(); + + const mgr = new LockManager(dir); + await mgr.init(); + + // Acquire coord lock + await mgr.acquireCoordLock("lifecycle-test"); + assert(mgr["hasCoordLock"] === true, "Coord lock held"); + + // Destroy releases it + await mgr.destroy(); + assert(mgr["hasCoordLock"] === false, "Coord lock released after destroy"); + + console.log("✅ LockManager: lifecycle (destroy) works"); +} + +// --------------------------------------------------------------------------- +// Test: File locking via LockManager +// --------------------------------------------------------------------------- + +async function testAcquireLockFile() { + const dir = setup(); + const { acquireLockFile, releaseLockFile } = getLockManager(); + + const lockFile = join(dir, "coord.lock"); + + // Acquire + const acquired = await acquireLockFile(lockFile, "test-owner"); + assert(acquired === true, "acquireLockFile returns true"); + assert(existsSync(lockFile), "Lock file exists after acquisition"); + + // Release + await releaseLockFile(lockFile); + assert(existsSync(lockFile) === false, "Lock file removed after release"); + + // Acquire again + const reacquired = await acquireLockFile(lockFile, "test-owner", 100); + assert(reacquired === true, "Re-acquire succeeds after release"); + + await releaseLockFile(lockFile); + teardown(); + console.log("✅ LockManager: acquire/release lock file works"); +} + +// --------------------------------------------------------------------------- +// Test: withLockFile convenience +// --------------------------------------------------------------------------- + +async function testWithLockFile() { + const dir = setup(); + const { withLockFile } = getLockManager(); + + const lockFile = join(dir, "coord.lock"); + + const result = await withLockFile(lockFile, "test", async () => { + return 42; + }); + assert(result === 42, "withLockFile returns function result"); + + // Lock file should be cleaned up + const exists = await import("node:fs").then((fs) => fs.existsSync(lockFile)); + assert(exists === false, "Lock file cleaned up after withLockFile"); + + teardown(); + console.log("✅ LockManager: withLockFile convenience works"); +} + +// --------------------------------------------------------------------------- +// Test: createLockManager factory +// --------------------------------------------------------------------------- + +async function testCreateLockManager() { + const dir = setup(); + + // We need to set config to use our dir + const config = require("../src/config"); + config.setConfig({ lockDir: dir }); + config.getConfig(); // force read + + const { createLockManager } = getLockManager(); + + // Override by passing explicit dir + const mgr = await createLockManager(dir); + assert(mgr.getLockDir() === dir, "Lock manager created with correct dir"); + await mgr.init(); // should be idempotent + assert(mgr.getLockDir() === dir, "Lock manager retains dir after init"); + + await mgr.destroy(); + config.resetConfig(); + teardown(); + console.log("✅ LockManager: createLockManager factory works"); +} + +// --------------------------------------------------------------------------- +// Test: Lock timeout +// --------------------------------------------------------------------------- + +async function testLockTimeout() { + const dir = setup(); + const { acquireLockFile, releaseLockFile } = getLockManager(); + + const lockFile = join(dir, "coord.lock"); + + // Hold lock from first owner + const acquired = await acquireLockFile(lockFile, "owner-1"); + assert(acquired === true, "First owner acquires lock"); + + // Second owner with very short timeout should fail + const failed = await acquireLockFile(lockFile, "owner-2", 50); + assert(failed === false, "Second owner times out"); + + // Release + await releaseLockFile(lockFile); + + // Second owner should succeed now + const success = await acquireLockFile(lockFile, "owner-2", 100); + assert(success === true, "Second owner acquires after release"); + + await releaseLockFile(lockFile); + teardown(); + console.log("✅ LockManager: lock timeout works"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +async function runTests() { + console.log("Running LockManager Unit Tests\n"); + + const tests = [ + testConstruction, + testAtomicFileOperations, + testRegistrySaveLoad, + testClaimCRUD, + testLockEntryCRUD, + testExpiration, + testAgeBasedSweep, + testCoordLock, + testWithCoordLock, + testSync, + testMerge, + testStats, + testLifecycle, + testAcquireLockFile, + testWithLockFile, + testCreateLockManager, + testLockTimeout, + ]; + + try { + for (const test of tests) { + try { + await test(); + } catch (err) { + console.error(`\n❌ Test ${test.name} failed: ${err}`); + throw err; + } + } + console.log("\n✅ All LockManager unit tests passed!"); + } catch (err) { + console.error(`\n❌ Test suite failed: ${err}`); + process.exit(1); + } +} + +runTests(); diff --git a/tests/multi-session.test.ts b/tests/multi-session.test.ts new file mode 100644 index 0000000..0450c55 --- /dev/null +++ b/tests/multi-session.test.ts @@ -0,0 +1,191 @@ +/** + * multi-session.test.ts — Integration tests for multi-session lock coordination. + * + * Uses require() for pi-dependent modules to avoid ESM .d.ts resolution issues. + * + * @module file-claiming/multi-session.test + */ + +import { assert, mockOwner, TEST_FILE_A, TEST_FILE_B, SESSION_A, SESSION_B, SESSION_C } from "./test-utils.ts"; + +// --------------------------------------------------------------------------- +// Module cache (require-based for CJS compat) +// --------------------------------------------------------------------------- + +function getAcq() { return require("../src/lock-acquisition"); } +function getReg() { return require("../index"); } +function getCfg() { return require("../src/config"); } + +function resetAll(): void { + getReg().resetRegistry(); + getCfg().resetConfig(); +} + +// --------------------------------------------------------------------------- +// Simulated session helper +// --------------------------------------------------------------------------- + +class SimulatedSession { + public sessionId: string; + public owner: ReturnType; + public claims: Set = new Set(); + + constructor(sessionId: string, agentId: string) { + this.sessionId = sessionId; + this.owner = mockOwner("agent", agentId, sessionId); + } + + acquire(path: string, lockType: "read" | "write" | "exclusive" = "write"): any { + const { acquireLock } = getAcq(); + const result = acquireLock({ path, lockType, owner: this.owner }); + if (result.success && result.claim) { + this.claims.add(result.claim.id); + } + return result; + } + + shutdown(): void { + getReg().getClaimRegistry().releaseAllByOwner(this.owner); + this.claims.clear(); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +function testDifferentFiles() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + + const resultA = sessionA.acquire(TEST_FILE_A, "write"); + const resultB = sessionB.acquire(TEST_FILE_B, "write"); + + assert(resultA.success === true, "Session A acquires file A"); + assert(resultB.success === true, "Session B acquires file B"); + console.log("✅ Multi-session: different files work independently"); +} + +function testSameFileConflict() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + + const resultA = sessionA.acquire(TEST_FILE_A, "write"); + assert(resultA.success === true, "Session A acquires file A"); + + const resultB = sessionB.acquire(TEST_FILE_A, "write"); + assert(resultB.success === false, "Session B conflicts on same file"); + assert(resultB.conflict !== undefined, "Conflict details present"); + console.log("✅ Multi-session: same file conflict detection works"); +} + +function testCrossSessionRelease() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + + sessionA.acquire(TEST_FILE_A, "write"); + + const resultB = sessionB.acquire(TEST_FILE_A, "write"); + assert(resultB.success === false, "Session B blocked before release"); + + const registry = getReg().getClaimRegistry(); + const claims = registry.getActiveClaims(TEST_FILE_A); + for (const c of claims) { + if (c.owner.sessionId === SESSION_A) registry.release(c.id); + } + + const resultB2 = sessionB.acquire(TEST_FILE_A, "write"); + assert(resultB2.success === true, "Session B acquires after release by A"); + console.log("✅ Multi-session: cross-session release unblocks"); +} + +function testConcurrentReadLocks() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + const sessionC = new SimulatedSession(SESSION_C, "gamma"); + + const resultA = sessionA.acquire(TEST_FILE_A, "read"); + const resultB = sessionB.acquire(TEST_FILE_A, "read"); + const resultC = sessionC.acquire(TEST_FILE_A, "read"); + + assert(resultA.success === true, "Session A gets read lock"); + assert(resultB.success === true, "Session B gets read lock"); + assert(resultC.success === true, "Session C gets read lock"); + + const active = Object.values(getReg().getClaimRegistry().claims).filter((c: any) => c.status === "active"); + const fileAReads = active.filter((c: any) => c.path === TEST_FILE_A); + assert(fileAReads.length === 3, "Three concurrent read claims exist"); + console.log("✅ Multi-session: concurrent read locks work"); +} + +function testExclusiveBlocking() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + + const resultA = sessionA.acquire(TEST_FILE_A, "exclusive"); + assert(resultA.success === true, "Session A gets exclusive lock"); + + const writeTry = sessionB.acquire(TEST_FILE_A, "write"); + assert(writeTry.success === false, "Exclusive blocks write"); + + const readTry = sessionB.acquire(TEST_FILE_A, "read"); + assert(readTry.success === false, "Exclusive blocks read"); + console.log("✅ Multi-session: exclusive lock blocks everything"); +} + +function testSessionShutdownCleanup() { + resetAll(); + const sessionA = new SimulatedSession(SESSION_A, "alpha"); + const sessionB = new SimulatedSession(SESSION_B, "beta"); + + sessionA.acquire(TEST_FILE_A, "write"); + sessionB.acquire(TEST_FILE_B, "write"); + + sessionA.shutdown(); + + const registry = getReg().getClaimRegistry(); + const aClaims = Object.values(registry.claims).filter( + (c: any) => c.owner.sessionId === SESSION_A && c.status === "active", + ); + assert(aClaims.length === 0, "Session A claims released after shutdown"); + + const bClaims = Object.values(registry.claims).filter( + (c: any) => c.owner.sessionId === SESSION_B && c.status === "active", + ); + assert(bClaims.length === 1, "Session B claims remain after A shutdown"); + console.log("✅ Multi-session: session shutdown cleanup works"); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +function runTests() { + console.log("Running Multi-Session Integration Tests\n"); + + const tests = [ + testDifferentFiles, + testSameFileConflict, + testCrossSessionRelease, + testConcurrentReadLocks, + testExclusiveBlocking, + testSessionShutdownCleanup, + ]; + + for (const test of tests) { + try { + test(); + } catch (err) { + console.error(`\n❌ Test ${test.name} failed: ${err}`); + process.exit(1); + } + } + console.log("\n✅ All multi-session integration tests passed!"); +} + +runTests(); diff --git a/tests/performance.test.ts b/tests/performance.test.ts new file mode 100644 index 0000000..074514e --- /dev/null +++ b/tests/performance.test.ts @@ -0,0 +1,263 @@ +/** + * performance.test.ts — Performance tests for lock operations. + * + * Tests measure latency of: + * - Single lock acquisition (median, p95) + * - Single lock release + * - Conflict checking + * - Bulk acquire/release + * - Lock info retrieval + * - Registry operation cycle + * + * Thresholds define acceptable performance bounds. + * + * @module file-claiming/performance.test + */ + +import { performance } from "node:perf_hooks"; +import { + assert, + mockOwner, + TEST_FILE_A, +} from "./test-utils.ts"; + +// Performance thresholds (ms) +const THRESHOLDS = { + lockAcquisition: 10, + lockRelease: 5, + conflictCheck: 5, + bulkAcquire: 100, + bulkRelease: 50, + lockInfo: 5, + registryOperation: 10, + conflictResolution: 5, + cleanup: 20, + minThroughputPerMs: 0.01, +}; + +// Lazy module loading +let _acq: any = null; +let _reg: any = null; +let _cfg: any = null; + +function getAcq() { if (!_acq) _acq = require("../src/lock-acquisition"); return _acq; } +function getReg() { if (!_reg) _reg = require("../index"); return _reg; } +function getCfg() { if (!_cfg) _cfg = require("../src/config"); return _cfg; } + +function resetAll(): void { + getReg().resetRegistry(); + getCfg().resetConfig(); +} + +function measureTimeSync(fn: () => T): { result: T; elapsedMs: number } { + const start = performance.now(); + const result = fn(); + return { result, elapsedMs: performance.now() - start }; +} + +// --------------------------------------------------------------------------- +// Test: Single lock acquisition latency +// --------------------------------------------------------------------------- + +function testSingleAcquisitionLatency() { + resetAll(); + const { acquireLock } = getAcq(); + const owner = mockOwner("agent", "perf-test"); + + const times: number[] = []; + for (let i = 0; i < 100; i++) { + const path = `/tmp/perf-lock-${i}.ts`; + const { elapsedMs } = measureTimeSync(() => { + acquireLock({ path, lockType: "write", owner, autoReleaseTTL: 300_000 }); + }); + times.push(elapsedMs); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + const sorted = [...times].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const p95 = sorted[Math.floor(sorted.length * 0.95)]; + + assert(avg < THRESHOLDS.lockAcquisition, `Avg ${avg.toFixed(3)}ms < ${THRESHOLDS.lockAcquisition}ms`); + console.log(` ✅ Single acquisition: avg=${avg.toFixed(3)}ms median=${median.toFixed(3)}ms p95=${p95.toFixed(3)}ms`); +} + +// --------------------------------------------------------------------------- +// Test: Single lock release latency +// --------------------------------------------------------------------------- + +function testSingleReleaseLatency() { + resetAll(); + const { acquireLock } = getAcq(); + const registry = getReg().getClaimRegistry(); + const owner = mockOwner("agent", "perf-release"); + + const claimIds: string[] = []; + for (let i = 0; i < 100; i++) { + const result = acquireLock({ path: `/tmp/perf-rel-${i}.ts`, lockType: "write", owner }); + if (result.claim) claimIds.push(result.claim.id); + } + + const times: number[] = []; + for (const claimId of claimIds) { + const { elapsedMs } = measureTimeSync(() => registry.release(claimId)); + times.push(elapsedMs); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + assert(avg < THRESHOLDS.lockRelease, `Avg release ${avg.toFixed(3)}ms < ${THRESHOLDS.lockRelease}ms`); + console.log(` ✅ Single release: avg=${avg.toFixed(3)}ms`); +} + +// --------------------------------------------------------------------------- +// Test: Conflict check latency +// --------------------------------------------------------------------------- + +function testConflictCheckLatency() { + resetAll(); + const { acquireLock } = getAcq(); + const registry = getReg().getClaimRegistry(); + const owner = mockOwner("agent", "perf-c"); + const other = mockOwner("agent", "perf-o"); + + acquireLock({ path: TEST_FILE_A, lockType: "write", owner }); + + const times: number[] = []; + for (let i = 0; i < 100; i++) { + const { elapsedMs } = measureTimeSync(() => + registry.checkConflict(TEST_FILE_A, "write", other) + ); + times.push(elapsedMs); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + assert(avg < THRESHOLDS.conflictCheck, `Avg conflict check ${avg.toFixed(3)}ms < ${THRESHOLDS.conflictCheck}ms`); + console.log(` ✅ Conflict check: avg=${avg.toFixed(3)}ms`); +} + +// --------------------------------------------------------------------------- +// Test: Bulk acquisition throughput +// --------------------------------------------------------------------------- + +function testBulkAcquisition() { + resetAll(); + const { acquireLock } = getAcq(); + const owner = mockOwner("agent", "perf-bulk"); + + const count = 500; + const start = performance.now(); + for (let i = 0; i < count; i++) { + acquireLock({ path: `/tmp/perf-bulk-${i}.ts`, lockType: "write", owner, autoReleaseTTL: 300_000 }); + } + const elapsed = performance.now() - start; + + assert(elapsed < THRESHOLDS.bulkAcquire, `Bulk ${count} locks: ${elapsed.toFixed(0)}ms < ${THRESHOLDS.bulkAcquire}ms`); + console.log(` ✅ Bulk acquire ${count} locks: ${elapsed.toFixed(0)}ms (${(count/elapsed).toFixed(2)} ops/ms)`); +} + +// --------------------------------------------------------------------------- +// Test: Bulk release +// --------------------------------------------------------------------------- + +function testBulkRelease() { + resetAll(); + const { acquireLock } = getAcq(); + const registry = getReg().getClaimRegistry(); + const owner = mockOwner("agent", "perf-bulk-rel"); + + for (let i = 0; i < 500; i++) { + acquireLock({ path: `/tmp/perf-bulkr-${i}.ts`, lockType: "write", owner }); + } + + const start = performance.now(); + registry.releaseAllByOwner(owner); + const elapsed = performance.now() - start; + + assert(elapsed < THRESHOLDS.bulkRelease, `Bulk release: ${elapsed.toFixed(0)}ms < ${THRESHOLDS.bulkRelease}ms`); + console.log(` ✅ Bulk release: ${elapsed.toFixed(0)}ms`); +} + +// --------------------------------------------------------------------------- +// Test: Lock info retrieval +// --------------------------------------------------------------------------- + +function testLockInfoLatency() { + resetAll(); + const { acquireLock, getLockInfo } = getAcq(); + const owner = mockOwner("agent", "perf-info"); + + for (let i = 0; i < 50; i++) { + acquireLock({ path: `/tmp/perf-inf-${i}.ts`, lockType: i % 2 === 0 ? "write" : "read", owner }); + } + + const times: number[] = []; + for (let i = 0; i < 50; i++) { + const { elapsedMs } = measureTimeSync(() => getLockInfo(`/tmp/perf-inf-${i}.ts`)); + times.push(elapsedMs); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + assert(avg < THRESHOLDS.lockInfo, `Avg lock info ${avg.toFixed(3)}ms < ${THRESHOLDS.lockInfo}ms`); + console.log(` ✅ Lock info: avg=${avg.toFixed(3)}ms`); +} + +// --------------------------------------------------------------------------- +// Test: Registry operation cycle +// --------------------------------------------------------------------------- + +function testRegistryOperationCycle() { + resetAll(); + const registry = getReg().getClaimRegistry(); + const owner = mockOwner("agent", "perf-cycle"); + + const times: number[] = []; + for (let i = 0; i < 500; i++) { + const claimId = `cycle-${i}`; + const path = `/tmp/perf-cyc-${i}.ts`; + const { elapsedMs } = measureTimeSync(() => { + registry.acquire({ id: claimId, path, lockType: "write", status: "active", owner, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); + registry.checkConflict(path, "write", owner); + registry.release(claimId); + }); + times.push(elapsedMs); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + assert(avg < THRESHOLDS.registryOperation, `Avg cycle ${avg.toFixed(3)}ms < ${THRESHOLDS.registryOperation}ms`); + console.log(` ✅ Registry cycle: avg=${avg.toFixed(3)}ms`); +} + +// --------------------------------------------------------------------------- +// Test runner +// --------------------------------------------------------------------------- + +function runTests() { + console.log("Running Performance Tests\n"); + console.log("Thresholds:"); + for (const [key, val] of Object.entries(THRESHOLDS)) { + console.log(` ${key}: ${val}ms`); + } + console.log(""); + + const tests = [ + testSingleAcquisitionLatency, + testSingleReleaseLatency, + testConflictCheckLatency, + testBulkAcquisition, + testBulkRelease, + testLockInfoLatency, + testRegistryOperationCycle, + ]; + + for (const test of tests) { + try { + test(); + } catch (err) { + console.error(`\n❌ Performance test ${test.name} failed: ${err}`); + process.exit(1); + } + } + console.log("\n✅ All performance tests passed!"); +} + +runTests(); diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 0000000..15462a4 --- /dev/null +++ b/tests/test-utils.ts @@ -0,0 +1,270 @@ +/** + * test-utils.ts — Shared test utilities and mocks for Pi session simulation. + * + * Provides: + * - Mock owners and claims for test scenarios + * - Mock Pi session simulation + * - Temporary directory creation for integration tests + * - Assertion helpers + * + * @module file-claiming/test-utils + */ + +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import type { ClaimOwner, FileClaim } from "../src/lock-types"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default test session ID. */ +export const TEST_SESSION_ID = "test-session-001"; + +/** Default owner for test claims. */ +export const TEST_OWNER: ClaimOwner = { + type: "agent", + id: "test-agent", + sessionId: TEST_SESSION_ID, +}; + +/** Alternative session IDs for multi-session tests. */ +export const SESSION_A = "session-alpha"; +export const SESSION_B = "session-beta"; +export const SESSION_C = "session-gamma"; + +/** Common test file paths. */ +export const TEST_FILE_A = "/tmp/test-claiming/file-a.ts"; +export const TEST_FILE_B = "/tmp/test-claiming/file-b.ts"; +export const TEST_FILE_C = "/tmp/test-claiming/file-c.ts"; + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- + +/** + * Create a mock ClaimOwner for use in tests. + */ +export function mockOwner( + type: ClaimOwner["type"] = "agent", + id: string = "test-agent", + sessionId: string = TEST_SESSION_ID, +): ClaimOwner { + return { type, id, sessionId }; +} + +/** + * Create a FileClaim for use in tests. + */ +export function createTestClaim(overrides: Partial = {}): FileClaim { + const now = new Date().toISOString(); + return { + id: randomUUID(), + path: TEST_FILE_A, + lockType: "write", + status: "active", + owner: TEST_OWNER, + createdAt: now, + updatedAt: now, + expiresAt: new Date(Date.now() + 300_000).toISOString(), + ...overrides, + }; +} + +// Cache for lazy-loaded modules +let _resetRegistryCache: (() => void) | null = null; +let _getActiveClaimsCache: (() => FileClaim[]) | null = null; + +/** + * Reset the registry and config. + * Uses dynamic import for ESM compatibility. + */ +export async function resetRegistry(): Promise { + try { + if (!_resetRegistryCache) { + const mod = await import("../index"); + const cfg = await import("../src/config"); + const fn = () => { + mod.resetRegistry(); + cfg.resetConfig(); + }; + _resetRegistryCache = fn; + fn(); + } else { + _resetRegistryCache(); + } + } catch { + // If index.ts can't be loaded, just try config + try { + const cfg = await import("../src/config"); + cfg.resetConfig(); + } catch { + // ignore + } + } +} + +/** + * Get the current active claims from the registry. + */ +export async function getActiveClaims(): Promise { + try { + if (!_getActiveClaimsCache) { + const mod = await import("../index"); + const fn = () => { + const registry = mod.getClaimRegistry(); + return Object.values(registry.claims).filter( + (c: any) => c.status === "active", + ); + }; + _getActiveClaimsCache = fn; + return fn(); + } + return _getActiveClaimsCache(); + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Mock Pi API +// --------------------------------------------------------------------------- + +/** + * Mock Pi extension API for testing event handlers and tool registration. + */ +export function createMockPi(): any { + const handlers: Record any>> = {}; + const entries: Array<{ type: string; data?: unknown }> = []; + const registeredTools: string[] = []; + + return { + events: { + emit: (type: string, data?: unknown) => { + const h = handlers[type] ?? []; + for (const handler of h) handler(data); + }, + on: (type: string, handler: (...args: any[]) => any) => { + (handlers[type] ??= []).push(handler); + return () => { + const idx = handlers[type]?.indexOf(handler) ?? -1; + if (idx >= 0) handlers[type]!.splice(idx, 1); + }; + }, + }, + registerTool: (_tool: any) => { + registeredTools.push(_tool.name ?? "unknown"); + }, + registerCommand: (_name: string, _def: any) => {}, + appendEntry: (type: string, data?: unknown) => { + entries.push({ type, data }); + }, + getSessionName: () => "test-session", + on: (type: string, handler: (...args: any[]) => any) => { + (handlers[type] ??= []).push(handler); + return () => { + const idx = handlers[type]?.indexOf(handler) ?? -1; + if (idx >= 0) handlers[type]!.splice(idx, 1); + }; + }, + _handlers: handlers, + _entries: entries, + _registeredTools: registeredTools, + }; +} + +/** + * Create a mock ExtensionContext for testing event handlers. + */ +export function createMockContext(overrides: Record = {}): any { + return { + ui: { + setWidget: () => {}, + setStatus: () => {}, + notify: () => {}, + select: async () => "View all claims", + input: async () => "/test/path.ts", + confirm: async () => true, + }, + hasUI: true, + cwd: "/tmp/test-claiming", + sessionManager: { + getSessionFile: () => TEST_SESSION_ID, + }, + modelRegistry: {}, + model: undefined, + isIdle: () => false, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + registerTool: () => {}, + events: { + emit: () => {}, + on: () => () => {}, + }, + appendEntry: () => {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Temporary directory helpers +// --------------------------------------------------------------------------- + +/** + * Create a temporary directory for file-based integration tests. + */ +export function createTempDir(prefix: string = "file-claiming-test-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +/** + * Remove a temporary directory and all its contents. + */ +export function cleanupTempDir(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Assertion helpers +// --------------------------------------------------------------------------- + +/** + * Assert function for test validation. + */ +export function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +/** + * Measure the execution time of a synchronous function. + */ +export function measureTimeSync(fn: () => T): { + result: T; + elapsedMs: number; +} { + const start = performance.now(); + const result = fn(); + const elapsedMs = performance.now() - start; + return { result, elapsedMs }; +} + +/** + * Measure the execution time of an async function. + */ +export async function measureTime( + fn: () => Promise, +): Promise<{ result: T; elapsedMs: number }> { + const start = performance.now(); + const result = await fn(); + const elapsedMs = performance.now() - start; + return { result, elapsedMs }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f842c64 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["index.ts", "src/**/*"], + "exclude": ["node_modules", "dist"] +}