feat: Add project lock mechanism for session exclusivity

New MCP tools:
- project_lock: Lock a project for exclusive session access
- project_unlock: Release a project lock
- project_lock_status: Check lock status for project(s)

Features:
- Automatic lock expiration (default 2h)
- Session ownership verification
- Force unlock option for emergencies
- Lock extension on re-lock by same session

Migration 004: project_locks table with expiry tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-10 09:22:26 +02:00
parent fe2cd82587
commit 837fb8060c
4 changed files with 231 additions and 0 deletions

View File

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

View File

@@ -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}`);
}

View File

@@ -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)' },
},
},
},
];

152
src/tools/locks.ts Normal file
View File

@@ -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<string> {
const { project, session_id, duration_minutes = 120, reason } = args;
const projectKey = await getProjectKey(project);
// Check for existing lock
const existing = await queryOne<ProjectLock>(
`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<string> {
const { project, session_id, force = false } = args;
const projectKey = await getProjectKey(project);
// Check existing lock
const existing = await queryOne<ProjectLock>(
`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<string> {
const { project } = args;
let locks: ProjectLock[];
if (project) {
const projectKey = await getProjectKey(project);
locks = await query<ProjectLock>(
`SELECT * FROM project_locks WHERE project = $1`,
[projectKey]
);
} else {
locks = await query<ProjectLock>(
`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');
}