feat: add paired review workspace flow

This commit is contained in:
Eyejoker
2026-03-28 21:14:25 +09:00
parent 79a8a2639e
commit e7f49d77da
19 changed files with 2719 additions and 20 deletions

View File

@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({
createPairedExecution: vi.fn(),
createPairedTask: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedExecutionById: vi.fn(),
getPairedTaskById: vi.fn(),
getPairedWorkspace: vi.fn(),
updatePairedExecution: vi.fn(),
updatePairedTask: vi.fn(),
upsertPairedProject: vi.fn(),
}));
vi.mock('./paired-workspace-manager.js', () => ({
markPairedTaskReviewReady: vi.fn(),
provisionOwnerWorkspaceForPairedTask: vi.fn(),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import * as db from './db.js';
import {
markRoomReviewReady,
preparePairedExecutionContext,
} from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type { RegisteredGroup, RoomRoleContext } from './types.js';
const group: RegisteredGroup = {
name: 'Paired Room',
folder: 'paired-room',
trigger: '@codex',
added_at: '2026-03-28T00:00:00.000Z',
agentType: 'codex',
workDir: '/repo/canonical',
};
const ownerContext: RoomRoleContext = {
serviceId: 'codex-main',
role: 'owner',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
};
const reviewerContext: RoomRoleContext = {
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
};
describe('paired execution context', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
vi.mocked(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).mockReturnValue({
id: 'task-1:owner',
task_id: 'task-1',
role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
});
it('creates an owner execution with a worktree override', () => {
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-1',
roomRoleContext: ownerContext,
});
expect(db.upsertPairedProject).toHaveBeenCalled();
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(db.createPairedExecution).toHaveBeenCalledWith(
expect.objectContaining({
id: 'run-1:codex-main',
role: 'owner',
workspace_id: 'task-1:owner',
}),
);
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/owner',
EJCLAW_PAIRED_ROLE: 'owner',
});
});
it('uses the reviewer snapshot only after review_ready and marks the task in_review', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(db.getPairedWorkspace).mockReturnValue({
id: 'task-1:reviewer',
task_id: 'task-1',
role: 'reviewer',
workspace_dir: '/tmp/paired/task-1/reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-2',
roomRoleContext: reviewerContext,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
EJCLAW_REVIEWER_RUNTIME: '1',
EJCLAW_PAIRED_ROLE: 'reviewer',
});
});
it('blocks reviewer execution before review_ready instead of falling back to the canonical repo', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-blocked-reviewer',
roomRoleContext: reviewerContext,
});
expect(result?.blockMessage).toBe(
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
);
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
expect(db.getPairedWorkspace).not.toHaveBeenCalled();
});
it('marks the active room task review-ready through the workspace manager', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: {
id: 'task-1:owner',
task_id: 'task-1',
role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
reviewerWorkspace: {
id: 'task-1:reviewer',
task_id: 'task-1',
role: 'reviewer',
workspace_dir: '/tmp/paired/task-1/reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
},
);
const result = markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(pairedWorkspaceManager.markPairedTaskReviewReady).toHaveBeenCalledWith(
'task-1',
);
expect(result?.task.status).toBe('review_ready');
});
});