Initial commit

This commit is contained in:
2026-06-19 12:46:02 -04:00
commit f64eeae96c
28 changed files with 11796 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.pi-lens/
package-lock.json

80
AGENTS.md Normal file
View File

@@ -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. |

21
LICENSE Normal file
View File

@@ -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.

87
README.md Normal file
View File

@@ -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 |

678
index.ts Normal file
View File

@@ -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<string, FileClaim> = {};
/** Lock entries indexed by path. */
let locksByPath: Record<string, LockEntry[]> = {};
/** Reference to the interval handle for the expiry sweeper. */
let sweepTimer: ReturnType<typeof setInterval> | 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<string, FileClaim> {
return claimsById;
},
get locks(): Record<string, LockEntry[]> {
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<string, unknown> = {};
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<FileClaimingConfig>);
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;
}

57
package.json Normal file
View File

@@ -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"
}
}

337
src/config.ts Normal file
View File

@@ -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<FileClaimingConfig>): 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<FileClaimingConfig> {
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<FileClaimingConfig>): 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<FileClaimingConfig> {
const filePath = configFilePath ?? getConfigFilePath(currentConfig.lockDir);
if (!existsSync(filePath)) {
return { ...currentConfig };
}
try {
const raw = await readFile(filePath, "utf-8");
const parsed: Record<string, unknown> = 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<FileClaimingConfig>);
if (validation.valid) {
currentConfig = { ...currentConfig, ...(parsed as Partial<FileClaimingConfig>) };
} else {
// Apply only the fields that pass validation
const safe: Partial<FileClaimingConfig> = {};
for (const key of Object.keys(parsed) as Array<keyof FileClaimingConfig>) {
const value = parsed[key];
const fieldPartial = { [key]: value } as unknown as Partial<FileClaimingConfig>;
const fieldVal = validateConfig(fieldPartial);
if (fieldVal.valid) {
(safe as Record<string, unknown>)[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<void> {
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();
}

308
src/diagnostics.ts Normal file
View File

@@ -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<string, DiagnosticItem[]>;
/** Total number of diagnostics. */
count: number;
/** Number of diagnostics per severity. */
bySeverity: Record<DiagnosticSeverity, number>;
}
// ---------------------------------------------------------------------------
// 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<string, DiagnosticItem[]>();
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(),
};
}

1179
src/edge-cases.ts Normal file

File diff suppressed because it is too large Load Diff

501
src/event-handlers.ts Normal file
View File

@@ -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<T>(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<ToolCallEventResult | void> {
return async (event: ToolCallEvent, ctx: ExtensionContext) => {
const result = withErrorHandling("tool_call", () => {
const toolName = event.toolName;
const input = (event as { input?: Record<string, unknown> }).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<void> {
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<void> {
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<BeforeAgentStartEventResult> {
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<ContextEventResult> {
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<void> {
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));
}

872
src/lock-acquisition.ts Normal file
View File

@@ -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<import("./config").FileClaimingConfig>,
): 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");
}

955
src/lock-manager.ts Normal file
View File

@@ -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<string, FileClaim>;
locks: Record<string, LockEntry[]>;
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<void> {
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<T = unknown>(filePath: string): Promise<T | null> {
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<boolean> {
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<void> {
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<T>(
lockFilePath: string,
ownerId: string,
fn: () => Promise<T>,
maxWaitMs?: number,
): Promise<T> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<T>(
ownerId: string,
fn: () => Promise<T>,
maxWaitMs?: number,
): Promise<T> {
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<string, FileClaim>,
locks: Record<string, LockEntry[]>,
): Promise<void> {
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<string, FileClaim>;
locks: Record<string, LockEntry[]>;
}> {
const data = await atomicReadJson<RegistryData>(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<void> {
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<FileClaim | undefined> {
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<boolean> {
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<FileClaim[]> {
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<FileClaim[]> {
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<FileClaim[]> {
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<void> {
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<void> {
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<LockEntry[]> {
const registry = await this.loadRegistry();
return registry.locks[lockPath] ?? [];
}
/**
* Load all lock entries, keyed by file path.
*/
async loadAllLockEntries(): Promise<Record<string, LockEntry[]>> {
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<FileClaim[]> {
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<CleanupResult> {
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<number> {
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<SyncFromDiskResult> {
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<string, FileClaim>,
locks: Record<string, LockEntry[]>,
): Promise<void> {
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<string, FileClaim>,
): Promise<Record<string, FileClaim>> {
const registry = await this.loadRegistry();
const merged = { ...registry.claims, ...memoryClaims };
return merged;
}
// -----------------------------------------------------------------------
// Statistics
// -----------------------------------------------------------------------
/**
* Gather statistics about the current persisted registry state.
*/
async getStats(): Promise<LockManagerStats> {
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<void> {
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<LockManager> {
const { getConfig } = await import("./config");
const config = getConfig();
const dir = lockDir ?? config.lockDir;
const mgr = new LockManager(dir);
await mgr.init();
return mgr;
}

270
src/lock-types.ts Normal file
View File

@@ -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<string, FileClaim>;
/**
* 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<string, LockEntry[]>;
/**
* 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;
}

317
src/notifications.ts Normal file
View File

@@ -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<typeof createDiagEvent>,
): 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<typeof createDiagEvent>): 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<typeof createDiagEvent>) {
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<string, LockNotification[]>();
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");
}

173
src/system-prompt.ts Normal file
View File

@@ -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 = '<file_claiming>\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' +
'</file_claiming>';
/**
* 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<string, string> {
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 ?? "" };
};
}

428
src/tools.ts Normal file
View File

@@ -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<typeof claimSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExtensionContext,
): Promise<
AgentToolResult<{ diagnostic: ReturnType<typeof claimToDiagnostic> }>
> => {
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<typeof releaseSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
ctx: ExtensionContext,
): Promise<AgentToolResult<{ released: string[] }>> => {
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<typeof listSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
_ctx: ExtensionContext,
): Promise<
AgentToolResult<{
collection: ReturnType<typeof buildDiagnosticCollection>;
}>
> => {
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<typeof checkSchema>,
_signal: AbortSignal | undefined,
_onUpdate: unknown,
_ctx: ExtensionContext,
): Promise<AgentToolResult<{ checked: boolean }>> => {
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<ExtensionContext, "ui"> & {
registerTool: typeof import("@earendil-works/pi-coding-agent").defineTool;
},
): void {
pi.registerTool(fileClaimingClaimTool);
pi.registerTool(fileClaimingReleaseTool);
pi.registerTool(fileClaimingListTool);
pi.registerTool(fileClaimingCheckTool);
}

335
src/user-interaction.ts Normal file
View File

@@ -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<void> {
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<void> {
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 <path>", "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 <path> — 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<void> {
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`);
}

373
tests/config.test.ts Normal file
View File

@@ -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();

168
tests/e2e.test.ts Normal file
View File

@@ -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<void> {
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();

1061
tests/edge-cases.test.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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();

999
tests/index.test.ts Normal file
View File

@@ -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("<file_claiming>"),
"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();

View File

@@ -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();

810
tests/lock-manager.test.ts Normal file
View File

@@ -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<typeof data>(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<unknown>(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<string, any> = {
"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<string, any[]> = {
[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<string, any> = {
["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<string, any> = {
["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();

191
tests/multi-session.test.ts Normal file
View File

@@ -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<typeof mockOwner>;
public claims: Set<string> = 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();

263
tests/performance.test.ts Normal file
View File

@@ -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<T>(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();

270
tests/test-utils.ts Normal file
View File

@@ -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> = {}): 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<void> {
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<FileClaim[]> {
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<string, Array<(...args: any[]) => 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<string, any> = {}): 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<T>(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<T>(
fn: () => Promise<T>,
): Promise<{ result: T; elapsedMs: number }> {
const start = performance.now();
const result = await fn();
const elapsedMs = performance.now() - start;
return { result, elapsedMs };
}

16
tsconfig.json Normal file
View File

@@ -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"]
}