Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.pi-lens/
|
||||
package-lock.json
|
||||
80
AGENTS.md
Normal file
80
AGENTS.md
Normal 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
21
LICENSE
Normal 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
87
README.md
Normal 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
678
index.ts
Normal 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
57
package.json
Normal 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
337
src/config.ts
Normal 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
308
src/diagnostics.ts
Normal 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
1179
src/edge-cases.ts
Normal file
File diff suppressed because it is too large
Load Diff
501
src/event-handlers.ts
Normal file
501
src/event-handlers.ts
Normal 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
872
src/lock-acquisition.ts
Normal 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
955
src/lock-manager.ts
Normal 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
270
src/lock-types.ts
Normal 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
317
src/notifications.ts
Normal 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
173
src/system-prompt.ts
Normal 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
428
src/tools.ts
Normal 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
335
src/user-interaction.ts
Normal 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
373
tests/config.test.ts
Normal 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
168
tests/e2e.test.ts
Normal 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
1061
tests/edge-cases.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
720
tests/event-handlers.test.ts
Normal file
720
tests/event-handlers.test.ts
Normal 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
999
tests/index.test.ts
Normal 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();
|
||||
324
tests/lock-acquisition.test.ts
Normal file
324
tests/lock-acquisition.test.ts
Normal 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
810
tests/lock-manager.test.ts
Normal 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
191
tests/multi-session.test.ts
Normal 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
263
tests/performance.test.ts
Normal 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
270
tests/test-utils.ts
Normal 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
16
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user