Initial commit
This commit is contained in:
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user