Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,99 @@ addWorkspaceOption(
)
);

addWorkspaceOption(
threadCmd
.command('participants <threadPath>')
.description('List thread participants and roles')
.option('--json', 'Emit structured JSON output')
).action((threadPath, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
const participants = workgraph.thread.listThreadParticipants(workspacePath, threadPath);
return { threadPath, participants, count: participants.length };
},
(result) => {
if (result.participants.length === 0) {
return [`No participants recorded for ${result.threadPath}.`];
}
return [
`Participants for ${result.threadPath}:`,
...result.participants.map((participant) =>
`- ${participant.actor} [${participant.role}] joined=${participant.joined_at}`),
];
},
)
);

addWorkspaceOption(
threadCmd
.command('invite <threadPath>')
.description('Invite or update a participant role on a thread')
.requiredOption('--participant <name>', 'Participant actor name')
.option('--role <role>', 'owner | contributor | reviewer | observer', 'contributor')
.option('-a, --actor <name>', 'Agent name', DEFAULT_ACTOR)
.option('--json', 'Emit structured JSON output')
).action((threadPath, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return {
thread: workgraph.thread.inviteThreadParticipant(
workspacePath,
threadPath,
opts.actor,
opts.participant,
opts.role,
),
};
},
(result) => [`Invited participant on: ${result.thread.path}`],
)
);

addWorkspaceOption(
threadCmd
.command('join <threadPath>')
.description('Join a thread as participant')
.option('-a, --actor <name>', 'Agent name', DEFAULT_ACTOR)
.option('--role <role>', 'contributor | reviewer | observer', 'contributor')
.option('--json', 'Emit structured JSON output')
).action((threadPath, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return {
thread: workgraph.thread.joinThread(workspacePath, threadPath, opts.actor, opts.role),
};
},
(result) => [`Joined thread: ${result.thread.path}`],
)
);

addWorkspaceOption(
threadCmd
.command('leave <threadPath>')
.description('Leave a thread (or remove another participant if authorized)')
.option('-a, --actor <name>', 'Agent name', DEFAULT_ACTOR)
.option('--participant <name>', 'Participant actor to remove (defaults to --actor)')
.option('--json', 'Emit structured JSON output')
).action((threadPath, opts) =>
runCommand(
opts,
() => {
const workspacePath = resolveWorkspacePath(opts);
return {
thread: workgraph.thread.leaveThread(workspacePath, threadPath, opts.actor, opts.participant),
};
},
(result) => [`Updated participants on: ${result.thread.path}`],
)
);

addWorkspaceOption(
threadCmd
.command('claim <threadPath>')
Expand Down
6 changes: 6 additions & 0 deletions packages/kernel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export * as auth from './auth.js';
export * as store from './store.js';
export * as thread from './thread.js';
export * as capability from './capability.js';
export {
inviteThreadParticipant,
joinThread,
leaveThread,
listThreadParticipants,
} from './thread.js';
export * as conversation from './conversation.js';
export * as workspace from './workspace.js';
export * as serverConfig from './server-config.js';
Expand Down
1 change: 1 addition & 0 deletions packages/kernel/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const BUILT_IN_TYPES: PrimitiveTypeDefinition[] = [
description: 'open | active | blocked | done | cancelled',
},
owner: { type: 'string', description: 'Agent that claimed this thread' },
participants:{ type: 'list', default: [], description: 'Thread participants with roles (owner/contributor/reviewer/observer)' },
priority: {
type: 'string',
default: 'medium',
Expand Down
11 changes: 10 additions & 1 deletion packages/kernel/src/schema-drift-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { spawnSync } from 'node:child_process';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createWorkgraphMcpServer } from '@versatly/workgraph-mcp-server';
import { loadRegistry, saveRegistry } from './registry.js';
import { ensureCliBuiltForTests } from '../../../tests/helpers/cli-build.js';

Expand Down Expand Up @@ -43,6 +42,16 @@ describe('schema drift regression', () => {
});

it('locks MCP tool metadata and input schemas', async () => {
const mcpModulePath = '../../mcp-server/src/index.js';
const mcpModule = await import(mcpModulePath);
const createWorkgraphMcpServer = (
mcpModule as {
createWorkgraphMcpServer: (options: { workspacePath: string; defaultActor: string }) => {
connect: (transport: unknown) => Promise<void>;
close: () => Promise<void>;
};
}
).createWorkgraphMcpServer;
const server = createWorkgraphMcpServer({
workspacePath,
defaultActor: 'agent-schema',
Expand Down
108 changes: 108 additions & 0 deletions packages/kernel/src/thread-participants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
block,
claim,
createThread,
done,
inviteThreadParticipant,
joinThread,
leaveThread,
listThreadParticipants,
} from './thread.js';
import { loadRegistry, saveRegistry } from './registry.js';

let workspacePath: string;

beforeEach(() => {
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'wg-thread-participants-'));
saveRegistry(workspacePath, loadRegistry(workspacePath));
});

afterEach(() => {
fs.rmSync(workspacePath, { recursive: true, force: true });
});

describe('thread participants', () => {
it('seeds thread creator as owner participant', () => {
const thread = createThread(workspacePath, 'Participant Seed', 'seed owners', 'agent-owner');
const participants = listThreadParticipants(workspacePath, thread.path);

expect(participants).toHaveLength(1);
expect(participants[0].actor).toBe('agent-owner');
expect(participants[0].role).toBe('owner');
});

it('supports inviting participants and self-join', () => {
const thread = createThread(workspacePath, 'Invite Flow', 'test participant changes', 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-reviewer', 'reviewer');
joinThread(workspacePath, thread.path, 'agent-observer', 'observer');
const participants = listThreadParticipants(workspacePath, thread.path);

expect(participants.map((entry) => `${entry.actor}:${entry.role}`)).toEqual([
'agent-observer:observer',
'agent-owner:owner',
'agent-reviewer:reviewer',
]);
});

it('blocks observer claim attempts', () => {
const thread = createThread(workspacePath, 'Observer cannot claim', 'permissions', 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-observer', 'observer');

expect(() => claim(workspacePath, thread.path, 'agent-observer')).toThrow('cannot perform "thread claims"');
});

it('allows reviewer to claim and complete a thread', () => {
const thread = createThread(workspacePath, 'Reviewer completion', 'permissions', 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-reviewer', 'reviewer');

const claimed = claim(workspacePath, thread.path, 'agent-reviewer');
expect(claimed.fields.owner).toBe('agent-reviewer');

const completed = done(
workspacePath,
thread.path,
'agent-reviewer',
'reviewed and approved https://github.com/versatly/workgraph/pull/99',
);
expect(completed.fields.status).toBe('done');
});

it('prevents contributors from managing participants', () => {
const thread = createThread(workspacePath, 'Contributor limits', 'permissions', 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-contrib', 'contributor');

expect(() =>
inviteThreadParticipant(workspacePath, thread.path, 'agent-contrib', 'agent-extra', 'observer'),
).toThrow('participant management');
});

it('rejects leaving when it would remove last owner', () => {
const thread = createThread(workspacePath, 'Owner retention', 'permissions', 'agent-owner');

expect(() => leaveThread(workspacePath, thread.path, 'agent-owner')).toThrow('at least one owner');
});

it('rejects removing an actively owning participant', () => {
const thread = createThread(workspacePath, 'Active owner retention', 'permissions', 'agent-owner');
claim(workspacePath, thread.path, 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-secondary', 'owner');

expect(() =>
leaveThread(workspacePath, thread.path, 'agent-secondary', 'agent-owner'),
).toThrow('Release or handoff first');
});

it('enforces role permissions on lifecycle mutations', () => {
const thread = createThread(workspacePath, 'Role mutation control', 'permissions', 'agent-owner');
inviteThreadParticipant(workspacePath, thread.path, 'agent-owner', 'agent-reviewer', 'reviewer');
claim(workspacePath, thread.path, 'agent-reviewer');

expect(() => block(workspacePath, thread.path, 'agent-reviewer', 'threads/dep.md')).toThrow(
'thread lifecycle mutations',
);
});
});
3 changes: 3 additions & 0 deletions packages/kernel/src/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
inferThreadDependenciesFromText,
heartbeat,
handoff,
joinThread,
recoverThreadState,
} from './thread.js';
import { loadRegistry, saveRegistry } from './registry.js';
Expand Down Expand Up @@ -89,6 +90,7 @@ describe('thread lifecycle', () => {
it('release by non-owner fails', () => {
createThread(workspacePath, 'Owned', 'test', 'agent-a');
claim(workspacePath, 'threads/owned.md', 'agent-b');
joinThread(workspacePath, 'threads/owned.md', 'agent-c');

expect(() => release(workspacePath, 'threads/owned.md', 'agent-c'))
.toThrow('owned by "agent-b"');
Expand Down Expand Up @@ -138,6 +140,7 @@ describe('thread lifecycle', () => {
it('done by non-owner fails', () => {
createThread(workspacePath, 'NotYours', 'test', 'agent-a');
claim(workspacePath, 'threads/notyours.md', 'agent-a');
joinThread(workspacePath, 'threads/notyours.md', 'agent-b');

expect(() => done(workspacePath, 'threads/notyours.md', 'agent-b'))
.toThrow('owned by "agent-a"');
Expand Down
Loading