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:
17
migrations/004_project_locks.sql
Normal file
17
migrations/004_project_locks.sql
Normal 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;
|
||||||
23
src/index.ts
23
src/index.ts
@@ -23,6 +23,7 @@ import { taskSimilar, taskContext } from './tools/search.js';
|
|||||||
import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js';
|
import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js';
|
||||||
import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js';
|
import { epicAdd, epicList, epicShow, epicAssign, epicClose } from './tools/epics.js';
|
||||||
import { taskDelegations, taskDelegationQuery } from './tools/delegations.js';
|
import { taskDelegations, taskDelegationQuery } from './tools/delegations.js';
|
||||||
|
import { projectLock, projectUnlock, projectLockStatus } from './tools/locks.js';
|
||||||
|
|
||||||
// Create MCP server
|
// Create MCP server
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
@@ -163,6 +164,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
});
|
});
|
||||||
break;
|
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:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
152
src/tools/locks.ts
Normal 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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user