diff --git a/migrations/004_project_locks.sql b/migrations/004_project_locks.sql new file mode 100644 index 0000000..88fd701 --- /dev/null +++ b/migrations/004_project_locks.sql @@ -0,0 +1,17 @@ +-- Migration 004: Project locks for session exclusivity +-- Prevents multiple Claude sessions from working on the same project + +CREATE TABLE IF NOT EXISTS project_locks ( + project TEXT PRIMARY KEY REFERENCES projects(key) ON DELETE CASCADE, + session_id TEXT NOT NULL, + locked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE, + reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_locks_session ON project_locks(session_id); +CREATE INDEX IF NOT EXISTS idx_locks_expires ON project_locks(expires_at); + +-- Record migration +INSERT INTO schema_migrations (version, applied_at) VALUES ('004_project_locks', NOW()) +ON CONFLICT DO NOTHING; diff --git a/src/index.ts b/src/index.ts index 1c7c08e..7395ad4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { taskSimilar, taskContext } from './tools/search.js'; import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js'; import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js'; import { taskDelegations, taskDelegationQuery } from './tools/delegations.js'; +import { projectLock, projectUnlock, projectLockStatus } from './tools/locks.js'; // Create MCP server const server = new Server( @@ -163,6 +164,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); break; + // Project Locks + case 'project_lock': + result = await projectLock({ + project: a.project, + session_id: a.session_id, + duration_minutes: a.duration_minutes, + reason: a.reason, + }); + break; + case 'project_unlock': + result = await projectUnlock({ + project: a.project, + session_id: a.session_id, + force: a.force, + }); + break; + case 'project_lock_status': + result = await projectLockStatus({ + project: a.project, + }); + break; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 9019200..4d6f906 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -233,4 +233,43 @@ export const toolDefinitions = [ }, }, }, + + // Project Lock Tools + { + name: 'project_lock', + description: 'Lock a project for exclusive session access. Prevents other sessions from working on it.', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project key (e.g., VPN, ST)' }, + session_id: { type: 'string', description: 'Unique session identifier' }, + duration_minutes: { type: 'number', description: 'Lock duration in minutes (default: 120)' }, + reason: { type: 'string', description: 'Optional reason for locking' }, + }, + required: ['project', 'session_id'], + }, + }, + { + name: 'project_unlock', + description: 'Release a project lock', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project key to unlock' }, + session_id: { type: 'string', description: 'Session ID (must match lock owner unless force=true)' }, + force: { type: 'boolean', description: 'Force unlock even if owned by different session' }, + }, + required: ['project'], + }, + }, + { + name: 'project_lock_status', + description: 'Check lock status for a project or all projects', + inputSchema: { + type: 'object', + properties: { + project: { type: 'string', description: 'Project key (optional, shows all if omitted)' }, + }, + }, + }, ]; diff --git a/src/tools/locks.ts b/src/tools/locks.ts new file mode 100644 index 0000000..5f86de6 --- /dev/null +++ b/src/tools/locks.ts @@ -0,0 +1,152 @@ +// Project lock operations for session exclusivity + +import { query, queryOne, execute, getProjectKey } from '../db.js'; + +interface ProjectLock { + project: string; + session_id: string; + locked_at: Date; + expires_at: Date | null; + reason: string | null; +} + +interface LockArgs { + project: string; + session_id: string; + duration_minutes?: number; + reason?: string; +} + +interface UnlockArgs { + project: string; + session_id?: string; + force?: boolean; +} + +interface LockStatusArgs { + project?: string; +} + +/** + * Lock a project for exclusive session access + */ +export async function projectLock(args: LockArgs): Promise { + const { project, session_id, duration_minutes = 120, reason } = args; + + const projectKey = await getProjectKey(project); + + // Check for existing lock + const existing = await queryOne( + `SELECT * FROM project_locks WHERE project = $1`, + [projectKey] + ); + + if (existing) { + // Check if lock is expired + if (existing.expires_at && new Date(existing.expires_at) < new Date()) { + // Expired lock, remove it + await execute(`DELETE FROM project_locks WHERE project = $1`, [projectKey]); + } else if (existing.session_id !== session_id) { + // Locked by another session + const lockedFor = Math.round((Date.now() - new Date(existing.locked_at).getTime()) / 60000); + return `LOCKED: ${projectKey} is locked by session ${existing.session_id} (${lockedFor}min ago)${existing.reason ? ` - ${existing.reason}` : ''}`; + } else { + // Already locked by this session, extend it + const expiresAt = new Date(Date.now() + duration_minutes * 60000); + await execute( + `UPDATE project_locks SET expires_at = $1, reason = COALESCE($2, reason) WHERE project = $3`, + [expiresAt, reason, projectKey] + ); + return `Extended: ${projectKey} lock extended to ${duration_minutes}min`; + } + } + + // Create new lock + const expiresAt = new Date(Date.now() + duration_minutes * 60000); + await execute( + `INSERT INTO project_locks (project, session_id, expires_at, reason) + VALUES ($1, $2, $3, $4) + ON CONFLICT (project) DO UPDATE SET + session_id = EXCLUDED.session_id, + locked_at = NOW(), + expires_at = EXCLUDED.expires_at, + reason = EXCLUDED.reason`, + [projectKey, session_id, expiresAt, reason] + ); + + return `Locked: ${projectKey} for ${duration_minutes}min (session: ${session_id.substring(0, 8)}...)`; +} + +/** + * Unlock a project + */ +export async function projectUnlock(args: UnlockArgs): Promise { + const { project, session_id, force = false } = args; + + const projectKey = await getProjectKey(project); + + // Check existing lock + const existing = await queryOne( + `SELECT * FROM project_locks WHERE project = $1`, + [projectKey] + ); + + if (!existing) { + return `Not locked: ${projectKey} is not currently locked`; + } + + // Verify ownership unless force + if (!force && session_id && existing.session_id !== session_id) { + return `Denied: ${projectKey} is locked by different session (${existing.session_id.substring(0, 8)}...). Use force=true to override.`; + } + + await execute(`DELETE FROM project_locks WHERE project = $1`, [projectKey]); + return `Unlocked: ${projectKey}`; +} + +/** + * Get lock status for project(s) + */ +export async function projectLockStatus(args: LockStatusArgs): Promise { + const { project } = args; + + let locks: ProjectLock[]; + + if (project) { + const projectKey = await getProjectKey(project); + locks = await query( + `SELECT * FROM project_locks WHERE project = $1`, + [projectKey] + ); + } else { + locks = await query( + `SELECT * FROM project_locks ORDER BY locked_at DESC` + ); + } + + if (locks.length === 0) { + return project ? `${project}: Not locked` : 'No active project locks'; + } + + const now = new Date(); + const lines = locks.map(lock => { + const lockedFor = Math.round((now.getTime() - new Date(lock.locked_at).getTime()) / 60000); + const expired = lock.expires_at && new Date(lock.expires_at) < now; + const expiresIn = lock.expires_at + ? Math.round((new Date(lock.expires_at).getTime() - now.getTime()) / 60000) + : null; + + let status = `${lock.project}: ${lock.session_id.substring(0, 8)}... (${lockedFor}min)`; + if (expired) { + status += ' [EXPIRED]'; + } else if (expiresIn !== null) { + status += ` [expires in ${expiresIn}min]`; + } + if (lock.reason) { + status += ` - ${lock.reason}`; + } + return status; + }); + + return lines.join('\n'); +}