feat: formalize paired review command flow
This commit is contained in:
@@ -588,6 +588,7 @@ describe('paired task state', () => {
|
|||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired-room/owner',
|
workspace_dir: '/tmp/paired-room/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
|
snapshot_source_fingerprint: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -654,7 +655,7 @@ describe('paired task state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updatePairedTask('paired-task-2', {
|
updatePairedTask('paired-task-2', {
|
||||||
status: 'review_ready',
|
status: 'review_pending',
|
||||||
review_requested_at: '2026-03-28T00:10:00.000Z',
|
review_requested_at: '2026-03-28T00:10:00.000Z',
|
||||||
updated_at: '2026-03-28T00:10:00.000Z',
|
updated_at: '2026-03-28T00:10:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -669,6 +670,7 @@ describe('paired task state', () => {
|
|||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
workspace_dir: '/tmp/reviewer-v1',
|
workspace_dir: '/tmp/reviewer-v1',
|
||||||
snapshot_source_dir: '/tmp/owner',
|
snapshot_source_dir: '/tmp/owner',
|
||||||
|
snapshot_source_fingerprint: 'fingerprint-v1',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
|
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
|
||||||
created_at: '2026-03-28T00:10:00.000Z',
|
created_at: '2026-03-28T00:10:00.000Z',
|
||||||
@@ -680,13 +682,14 @@ describe('paired task state', () => {
|
|||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
workspace_dir: '/tmp/reviewer-v2',
|
workspace_dir: '/tmp/reviewer-v2',
|
||||||
snapshot_source_dir: '/tmp/owner',
|
snapshot_source_dir: '/tmp/owner',
|
||||||
|
snapshot_source_fingerprint: 'fingerprint-v2',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
|
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
|
||||||
created_at: '2026-03-28T00:10:00.000Z',
|
created_at: '2026-03-28T00:10:00.000Z',
|
||||||
updated_at: '2026-03-28T00:12:00.000Z',
|
updated_at: '2026-03-28T00:12:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
|
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
|
||||||
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
|
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
|
||||||
expect(
|
expect(
|
||||||
listPairedWorkspacesForTask('paired-task-2').map(
|
listPairedWorkspacesForTask('paired-task-2').map(
|
||||||
|
|||||||
88
src/db.ts
88
src/db.ts
@@ -260,6 +260,7 @@ function createSchema(database: Database.Database): void {
|
|||||||
CHECK (
|
CHECK (
|
||||||
status IN (
|
status IN (
|
||||||
'draft',
|
'draft',
|
||||||
|
'review_pending',
|
||||||
'review_ready',
|
'review_ready',
|
||||||
'in_review',
|
'in_review',
|
||||||
'changes_requested',
|
'changes_requested',
|
||||||
@@ -294,6 +295,7 @@ function createSchema(database: Database.Database): void {
|
|||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
workspace_dir TEXT NOT NULL,
|
workspace_dir TEXT NOT NULL,
|
||||||
snapshot_source_dir TEXT,
|
snapshot_source_dir TEXT,
|
||||||
|
snapshot_source_fingerprint TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'provisioning',
|
status TEXT NOT NULL DEFAULT 'provisioning',
|
||||||
snapshot_refreshed_at TEXT,
|
snapshot_refreshed_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -418,6 +420,14 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.exec(
|
||||||
|
`ALTER TABLE paired_workspaces ADD COLUMN snapshot_source_fingerprint TEXT`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
database.exec(`
|
database.exec(`
|
||||||
UPDATE scheduled_tasks
|
UPDATE scheduled_tasks
|
||||||
SET agent_type = COALESCE(
|
SET agent_type = COALESCE(
|
||||||
@@ -608,6 +618,79 @@ function createSchema(database: Database.Database): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pairedTasksSqlRow = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
||||||
|
)
|
||||||
|
.get() as { sql?: string } | undefined;
|
||||||
|
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
||||||
|
if (pairedTasksSql && !pairedTasksSql.includes("'review_pending'")) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE paired_tasks_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chat_jid TEXT NOT NULL,
|
||||||
|
group_folder TEXT NOT NULL,
|
||||||
|
owner_service_id TEXT NOT NULL,
|
||||||
|
reviewer_service_id TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
source_ref TEXT,
|
||||||
|
review_requested_at TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (
|
||||||
|
status IN (
|
||||||
|
'draft',
|
||||||
|
'review_pending',
|
||||||
|
'review_ready',
|
||||||
|
'in_review',
|
||||||
|
'changes_requested',
|
||||||
|
'merge_ready',
|
||||||
|
'merged',
|
||||||
|
'discarded',
|
||||||
|
'failed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
database.exec(`
|
||||||
|
INSERT INTO paired_tasks_new (
|
||||||
|
id,
|
||||||
|
chat_jid,
|
||||||
|
group_folder,
|
||||||
|
owner_service_id,
|
||||||
|
reviewer_service_id,
|
||||||
|
title,
|
||||||
|
source_ref,
|
||||||
|
review_requested_at,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
chat_jid,
|
||||||
|
group_folder,
|
||||||
|
owner_service_id,
|
||||||
|
reviewer_service_id,
|
||||||
|
title,
|
||||||
|
source_ref,
|
||||||
|
review_requested_at,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM paired_tasks;
|
||||||
|
`);
|
||||||
|
database.exec(`
|
||||||
|
DROP TABLE paired_tasks;
|
||||||
|
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
|
||||||
|
`);
|
||||||
|
database.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
|
ON paired_tasks(chat_jid, status, updated_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate sessions table to composite PK (group_folder, agent_type)
|
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||||
const sessionCols = database
|
const sessionCols = database
|
||||||
.prepare('PRAGMA table_info(sessions)')
|
.prepare('PRAGMA table_info(sessions)')
|
||||||
@@ -1960,15 +2043,17 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|||||||
role,
|
role,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
snapshot_source_dir,
|
snapshot_source_dir,
|
||||||
|
snapshot_source_fingerprint,
|
||||||
status,
|
status,
|
||||||
snapshot_refreshed_at,
|
snapshot_refreshed_at,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
workspace_dir = excluded.workspace_dir,
|
workspace_dir = excluded.workspace_dir,
|
||||||
snapshot_source_dir = excluded.snapshot_source_dir,
|
snapshot_source_dir = excluded.snapshot_source_dir,
|
||||||
|
snapshot_source_fingerprint = excluded.snapshot_source_fingerprint,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
|
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
@@ -1979,6 +2064,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|||||||
workspace.role,
|
workspace.role,
|
||||||
workspace.workspace_dir,
|
workspace.workspace_dir,
|
||||||
workspace.snapshot_source_dir,
|
workspace.snapshot_source_dir,
|
||||||
|
workspace.snapshot_source_fingerprint,
|
||||||
workspace.status,
|
workspace.status,
|
||||||
workspace.snapshot_refreshed_at,
|
workspace.snapshot_refreshed_at,
|
||||||
workspace.created_at,
|
workspace.created_at,
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired/owner',
|
workspace_dir: '/tmp/paired/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
|
snapshot_source_fingerprint: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -387,7 +388,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks reviewer execution before review-ready and does not spawn the runner', async () => {
|
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
|
||||||
const group = {
|
const group = {
|
||||||
...makeGroup(),
|
...makeGroup(),
|
||||||
folder: 'test-group',
|
folder: 'test-group',
|
||||||
@@ -439,7 +440,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
EJCLAW_REVIEWER_RUNTIME: '1',
|
EJCLAW_REVIEWER_RUNTIME: '1',
|
||||||
},
|
},
|
||||||
blockMessage:
|
blockMessage:
|
||||||
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runAgentForGroup(makeDeps(), {
|
const result = await runAgentForGroup(makeDeps(), {
|
||||||
@@ -462,7 +463,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
|
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
|
||||||
expect(outputs).toEqual([
|
expect(outputs).toEqual([
|
||||||
{
|
{
|
||||||
text: 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
text: 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -472,7 +473,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
executionId: 'run-blocked-reviewer:claude',
|
executionId: 'run-blocked-reviewer:claude',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
summary:
|
summary:
|
||||||
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ vi.mock('./db.js', () => ({
|
|||||||
|
|
||||||
vi.mock('./paired-workspace-manager.js', () => ({
|
vi.mock('./paired-workspace-manager.js', () => ({
|
||||||
markPairedTaskReviewReady: vi.fn(),
|
markPairedTaskReviewReady: vi.fn(),
|
||||||
|
prepareReviewerWorkspaceForExecution: vi.fn(),
|
||||||
provisionOwnerWorkspaceForPairedTask: vi.fn(),
|
provisionOwnerWorkspaceForPairedTask: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./logger.js', () => ({
|
vi.mock('./logger.js', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
@@ -73,11 +75,18 @@ describe('paired execution context', () => {
|
|||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired/task-1/owner',
|
workspace_dir: '/tmp/paired/task-1/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
|
snapshot_source_fingerprint: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates an owner execution with a worktree override', () => {
|
it('creates an owner execution with a worktree override', () => {
|
||||||
@@ -103,7 +112,7 @@ describe('paired execution context', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the reviewer snapshot only after review_ready and marks the task in_review', () => {
|
it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => {
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
@@ -117,14 +126,33 @@ describe('paired execution context', () => {
|
|||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
vi.mocked(db.getPairedWorkspace).mockReturnValue({
|
vi.mocked(
|
||||||
id: 'task-1:reviewer',
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
task_id: 'task-1',
|
).mockReturnValue({
|
||||||
role: 'reviewer',
|
workspace: {
|
||||||
workspace_dir: '/tmp/paired/task-1/reviewer',
|
id: 'task-1:reviewer',
|
||||||
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
task_id: 'task-1',
|
||||||
status: 'ready',
|
role: 'reviewer',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
|
workspace_dir: '/tmp/paired/task-1/reviewer',
|
||||||
|
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
||||||
|
snapshot_source_fingerprint: 'fingerprint-1',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
autoRefreshed: true,
|
||||||
|
});
|
||||||
|
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',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -147,7 +175,7 @@ describe('paired execution context', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks reviewer execution before review_ready instead of falling back to the canonical repo', () => {
|
it('does not change task state for a general reviewer turn before /review', () => {
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
@@ -161,19 +189,65 @@ describe('paired execution context', () => {
|
|||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_at: '2026-03-28T00:00:00.000Z',
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage:
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
|
||||||
|
});
|
||||||
|
|
||||||
const result = preparePairedExecutionContext({
|
const result = preparePairedExecutionContext({
|
||||||
group,
|
group,
|
||||||
chatJid: 'dc:test',
|
chatJid: 'dc:test',
|
||||||
runId: 'run-blocked-reviewer',
|
runId: 'run-general-reviewer',
|
||||||
roomRoleContext: reviewerContext,
|
roomRoleContext: reviewerContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.blockMessage).toBe(
|
expect(result?.blockMessage).toBe(
|
||||||
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
|
||||||
|
);
|
||||||
|
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({ status: 'in_review' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks reviewer execution when an in-review snapshot became stale', () => {
|
||||||
|
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: 'in_review',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage:
|
||||||
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-stale-reviewer',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.blockMessage).toBe(
|
||||||
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
);
|
);
|
||||||
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
|
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
|
||||||
expect(db.getPairedWorkspace).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks the active room task review-ready through the workspace manager', () => {
|
it('marks the active room task review-ready through the workspace manager', () => {
|
||||||
@@ -211,6 +285,7 @@ describe('paired execution context', () => {
|
|||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired/task-1/owner',
|
workspace_dir: '/tmp/paired/task-1/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
|
snapshot_source_fingerprint: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -222,6 +297,7 @@ describe('paired execution context', () => {
|
|||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
workspace_dir: '/tmp/paired/task-1/reviewer',
|
workspace_dir: '/tmp/paired/task-1/reviewer',
|
||||||
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
||||||
|
snapshot_source_fingerprint: 'fingerprint-1',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
|
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -241,4 +317,31 @@ describe('paired execution context', () => {
|
|||||||
).toHaveBeenCalledWith('task-1');
|
).toHaveBeenCalledWith('task-1');
|
||||||
expect(result?.task.status).toBe('review_ready');
|
expect(result?.task.status).toBe('review_ready');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null when review-ready setup cannot resolve an owner workspace', () => {
|
||||||
|
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(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SERVICE_ID,
|
||||||
|
normalizeServiceId,
|
||||||
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
createPairedExecution,
|
createPairedExecution,
|
||||||
createPairedTask,
|
createPairedTask,
|
||||||
getLatestOpenPairedTaskForChat,
|
getLatestOpenPairedTaskForChat,
|
||||||
getPairedExecutionById,
|
getPairedExecutionById,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
getPairedWorkspace,
|
|
||||||
updatePairedExecution,
|
updatePairedExecution,
|
||||||
updatePairedTask,
|
updatePairedTask,
|
||||||
upsertPairedProject,
|
upsertPairedProject,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
markPairedTaskReviewReady,
|
markPairedTaskReviewReady,
|
||||||
|
prepareReviewerWorkspaceForExecution,
|
||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -170,22 +174,17 @@ export function preparePairedExecutionContext(args: {
|
|||||||
|
|
||||||
if (roomRoleContext.role === 'owner') {
|
if (roomRoleContext.role === 'owner') {
|
||||||
workspace = provisionOwnerWorkspaceForPairedTask(task.id);
|
workspace = provisionOwnerWorkspaceForPairedTask(task.id);
|
||||||
} else if (
|
} else {
|
||||||
task.status === 'review_ready' ||
|
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(task);
|
||||||
task.status === 'in_review' ||
|
workspace = reviewerWorkspace.workspace;
|
||||||
task.status === 'merge_ready' ||
|
blockMessage = reviewerWorkspace.blockMessage;
|
||||||
task.status === 'changes_requested'
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
) {
|
if (workspace && latestTask.status === 'review_ready') {
|
||||||
workspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
|
|
||||||
if (workspace && task.status === 'review_ready') {
|
|
||||||
updatePairedTask(task.id, {
|
updatePairedTask(task.id, {
|
||||||
status: 'in_review',
|
status: 'in_review',
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
blockMessage =
|
|
||||||
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const execution = ensureExecutionRecord({
|
const execution = ensureExecutionRecord({
|
||||||
@@ -247,9 +246,18 @@ export function markRoomReviewReady(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ownerWorkspace, reviewerWorkspace } = markPairedTaskReviewReady(
|
const isOwnerService =
|
||||||
task.id,
|
normalizeServiceId(SERVICE_ID) === task.owner_service_id;
|
||||||
);
|
if (isOwnerService) {
|
||||||
|
provisionOwnerWorkspaceForPairedTask(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewReady = markPairedTaskReviewReady(task.id);
|
||||||
|
if (!reviewReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ownerWorkspace, reviewerWorkspace } = reviewReady;
|
||||||
return {
|
return {
|
||||||
task: getPairedTaskById(task.id) ?? task,
|
task: getPairedTaskById(task.id) ?? task,
|
||||||
ownerWorkspace,
|
ownerWorkspace,
|
||||||
|
|||||||
140
src/paired-review-command.test.ts
Normal file
140
src/paired-review-command.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { RegisteredGroup, RoomRoleContext } from './types.js';
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string): string {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModules() {
|
||||||
|
const db = await import('./db.js');
|
||||||
|
const executionContext = await import('./paired-execution-context.js');
|
||||||
|
return { db, executionContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 /review command path', () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
let previousDataDir: string | undefined;
|
||||||
|
let previousGroupsDir: string | undefined;
|
||||||
|
let previousStoreDir: string | undefined;
|
||||||
|
let previousServiceId: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-review-command-'));
|
||||||
|
previousDataDir = process.env.EJCLAW_DATA_DIR;
|
||||||
|
previousGroupsDir = process.env.EJCLAW_GROUPS_DIR;
|
||||||
|
previousStoreDir = process.env.EJCLAW_STORE_DIR;
|
||||||
|
previousServiceId = process.env.SERVICE_ID;
|
||||||
|
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'shared-store');
|
||||||
|
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'shared-groups');
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousDataDir === undefined) delete process.env.EJCLAW_DATA_DIR;
|
||||||
|
else process.env.EJCLAW_DATA_DIR = previousDataDir;
|
||||||
|
if (previousGroupsDir === undefined) delete process.env.EJCLAW_GROUPS_DIR;
|
||||||
|
else process.env.EJCLAW_GROUPS_DIR = previousGroupsDir;
|
||||||
|
if (previousStoreDir === undefined) delete process.env.EJCLAW_STORE_DIR;
|
||||||
|
else process.env.EJCLAW_STORE_DIR = previousStoreDir;
|
||||||
|
if (previousServiceId === undefined) delete process.env.SERVICE_ID;
|
||||||
|
else process.env.SERVICE_ID = previousServiceId;
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the db owner workspace when reviewer service handles /review', async () => {
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const group: RegisteredGroup = {
|
||||||
|
name: 'Paired Room',
|
||||||
|
folder: 'paired-room',
|
||||||
|
trigger: '@codex',
|
||||||
|
added_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
workDir: canonicalDir,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
|
||||||
|
process.env.SERVICE_ID = 'codex-main';
|
||||||
|
vi.resetModules();
|
||||||
|
let { db, executionContext } = await loadModules();
|
||||||
|
db.initDatabase();
|
||||||
|
|
||||||
|
const ownerResult = executionContext.preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-owner',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ownerResult?.workspace?.workspace_dir).toBeTruthy();
|
||||||
|
const ownerWorkspaceDir = ownerResult!.workspace!.workspace_dir;
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspaceDir, 'README.md'), 'owner change\n');
|
||||||
|
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
|
||||||
|
process.env.SERVICE_ID = 'codex-review';
|
||||||
|
vi.resetModules();
|
||||||
|
({ db, executionContext } = await loadModules());
|
||||||
|
db.initDatabase();
|
||||||
|
|
||||||
|
const result = executionContext.markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewerLocalOwnerDir = path.join(
|
||||||
|
tempRoot,
|
||||||
|
'data-review',
|
||||||
|
'workspaces',
|
||||||
|
'paired-room',
|
||||||
|
'tasks',
|
||||||
|
ownerResult!.task.id,
|
||||||
|
'owner',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
|
||||||
|
expect(result!.reviewerWorkspace.snapshot_source_dir).toBe(
|
||||||
|
ownerWorkspaceDir,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(result!.reviewerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('owner change\n');
|
||||||
|
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,8 +96,9 @@ describe('paired workspace manager', () => {
|
|||||||
'review me\n',
|
'review me\n',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { reviewerWorkspace } =
|
const reviewReady = manager.markPairedTaskReviewReady('paired-task-1');
|
||||||
manager.markPairedTaskReviewReady('paired-task-1');
|
expect(reviewReady).not.toBeNull();
|
||||||
|
const reviewerWorkspace = reviewReady!.reviewerWorkspace;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fs.readFileSync(
|
fs.readFileSync(
|
||||||
@@ -121,9 +122,92 @@ describe('paired workspace manager', () => {
|
|||||||
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
|
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
|
||||||
).toContain('M README.md');
|
).toContain('M README.md');
|
||||||
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
|
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
|
||||||
|
expect(db.getPairedTaskById('paired-task-1')?.review_requested_at).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
|
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
db.getPairedWorkspace('paired-task-1', 'reviewer')
|
||||||
|
?.snapshot_source_fingerprint,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shared DB owner workspace across service-local data dirs', async () => {
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'shared-store');
|
||||||
|
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'shared-groups');
|
||||||
|
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
|
||||||
|
vi.resetModules();
|
||||||
|
let { db, manager } = await loadModules();
|
||||||
|
db.initDatabase();
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-cross-service',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: 'cross service review',
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-cross-service');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'owner modified\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
|
||||||
|
vi.resetModules();
|
||||||
|
({ db, manager } = await loadModules());
|
||||||
|
db.initDatabase();
|
||||||
|
|
||||||
|
const reviewerWorkspace = manager.refreshReviewerSnapshotForPairedTask(
|
||||||
|
'paired-task-cross-service',
|
||||||
|
);
|
||||||
|
const reviewServiceOwnerDir = path.join(
|
||||||
|
tempRoot,
|
||||||
|
'data-review',
|
||||||
|
'workspaces',
|
||||||
|
'paired-room',
|
||||||
|
'tasks',
|
||||||
|
'paired-task-cross-service',
|
||||||
|
'owner',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(reviewerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('owner modified\n');
|
||||||
|
expect(reviewerWorkspace.snapshot_source_dir).toBe(
|
||||||
|
ownerWorkspace.workspace_dir,
|
||||||
|
);
|
||||||
|
expect(fs.existsSync(reviewServiceOwnerDir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces stale reviewer files on snapshot refresh', async () => {
|
it('replaces stale reviewer files on snapshot refresh', async () => {
|
||||||
@@ -377,6 +461,7 @@ describe('paired workspace manager', () => {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-4');
|
||||||
const reviewerWorkspace =
|
const reviewerWorkspace =
|
||||||
manager.refreshReviewerSnapshotForPairedTask('paired-task-4');
|
manager.refreshReviewerSnapshotForPairedTask('paired-task-4');
|
||||||
|
|
||||||
@@ -400,4 +485,203 @@ describe('paired workspace manager', () => {
|
|||||||
),
|
),
|
||||||
).toBe('EXAMPLE=1\n');
|
).toBe('EXAMPLE=1\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not auto-refresh a missing reviewer snapshot while the task is still draft', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-5',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-5');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'owner change\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = manager.prepareReviewerWorkspaceForExecution(
|
||||||
|
db.getPairedTaskById('paired-task-5')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.workspace).toBeNull();
|
||||||
|
expect(result.autoRefreshed).toBe(false);
|
||||||
|
expect(result.blockMessage).toBe(
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
|
||||||
|
);
|
||||||
|
expect(db.getPairedTaskById('paired-task-5')?.status).toBe('draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-refreshes a missing reviewer snapshot after an explicit review request', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-5b',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||||
|
status: 'review_pending',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: '2026-03-28T00:01:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-5b');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'owner change\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = manager.prepareReviewerWorkspaceForExecution(
|
||||||
|
db.getPairedTaskById('paired-task-5b')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.autoRefreshed).toBe(true);
|
||||||
|
expect(result.blockMessage).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(result.workspace!.workspace_dir, 'README.md'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('owner change\n');
|
||||||
|
expect(db.getPairedTaskById('paired-task-5b')?.status).toBe('review_ready');
|
||||||
|
expect(db.getPairedTaskById('paired-task-5b')?.review_requested_at).toBe(
|
||||||
|
'2026-03-28T00:01:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks once when an in-review snapshot becomes stale, then refreshes on retry', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
|
runGit(['init'], canonicalDir);
|
||||||
|
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
|
||||||
|
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
|
||||||
|
runGit(['add', 'README.md'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-6',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-6');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'first review\n',
|
||||||
|
);
|
||||||
|
manager.markPairedTaskReviewReady('paired-task-6');
|
||||||
|
db.updatePairedTask('paired-task-6', {
|
||||||
|
status: 'in_review',
|
||||||
|
updated_at: '2026-03-28T00:05:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'owner changed again\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocked = manager.prepareReviewerWorkspaceForExecution(
|
||||||
|
db.getPairedTaskById('paired-task-6')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(blocked.workspace).toBeNull();
|
||||||
|
expect(blocked.autoRefreshed).toBe(false);
|
||||||
|
expect(blocked.blockMessage).toBe(
|
||||||
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
||||||
|
);
|
||||||
|
expect(db.getPairedTaskById('paired-task-6')?.status).toBe('review_pending');
|
||||||
|
expect(db.getPairedTaskById('paired-task-6')?.review_requested_at).toBeTruthy();
|
||||||
|
expect(db.getPairedWorkspace('paired-task-6', 'reviewer')?.status).toBe(
|
||||||
|
'stale',
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshed = manager.prepareReviewerWorkspaceForExecution(
|
||||||
|
db.getPairedTaskById('paired-task-6')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshed.autoRefreshed).toBe(true);
|
||||||
|
expect(refreshed.blockMessage).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(refreshed.workspace!.workspace_dir, 'README.md'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('owner changed again\n');
|
||||||
|
expect(db.getPairedTaskById('paired-task-6')?.review_requested_at).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
|
import crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { PairedTask, PairedWorkspace } from './types.js';
|
import type { PairedTask, PairedWorkspace } from './types.js';
|
||||||
|
|
||||||
|
const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE =
|
||||||
|
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.';
|
||||||
|
const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE =
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.';
|
||||||
|
|
||||||
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
'.claude',
|
'.claude',
|
||||||
@@ -115,14 +121,6 @@ function ensureCleanDirectory(targetDir: string): void {
|
|||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDirectoryExceptGit(targetDir: string): void {
|
|
||||||
if (!fs.existsSync(targetDir)) return;
|
|
||||||
for (const entry of fs.readdirSync(targetDir)) {
|
|
||||||
if (entry === '.git') continue;
|
|
||||||
fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listGitPaths(repoDir: string, args: string[]): string[] {
|
function listGitPaths(repoDir: string, args: string[]): string[] {
|
||||||
const output = execFileSync('git', args, {
|
const output = execFileSync('git', args, {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
@@ -221,6 +219,34 @@ function removeSnapshotPaths(targetDir: string, relativePaths: string[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReviewerSnapshotFingerprint(args: {
|
||||||
|
sourceDir: string;
|
||||||
|
allowedTrackedFiles: string[];
|
||||||
|
deletedTrackedFiles: string[];
|
||||||
|
allowedUntrackedFiles: string[];
|
||||||
|
}): string {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const appendFile = (kind: 'tracked' | 'untracked', relativePath: string) => {
|
||||||
|
const sourcePath = path.join(args.sourceDir, relativePath);
|
||||||
|
if (!fs.existsSync(sourcePath)) return;
|
||||||
|
hash.update(`${kind}\0${relativePath}\0`);
|
||||||
|
hash.update(fs.readFileSync(sourcePath));
|
||||||
|
hash.update('\0');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const relativePath of [...new Set(args.allowedTrackedFiles)].sort()) {
|
||||||
|
appendFile('tracked', relativePath);
|
||||||
|
}
|
||||||
|
for (const relativePath of [...new Set(args.deletedTrackedFiles)].sort()) {
|
||||||
|
hash.update(`deleted\0${relativePath}\0`);
|
||||||
|
}
|
||||||
|
for (const relativePath of [...new Set(args.allowedUntrackedFiles)].sort()) {
|
||||||
|
appendFile('untracked', relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
function applyReviewerSparseCheckout(
|
function applyReviewerSparseCheckout(
|
||||||
reviewerDir: string,
|
reviewerDir: string,
|
||||||
allowedTrackedFiles: string[],
|
allowedTrackedFiles: string[],
|
||||||
@@ -275,7 +301,9 @@ function makeWorkspaceRecord(args: {
|
|||||||
role: PairedWorkspace['role'];
|
role: PairedWorkspace['role'];
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
snapshotSourceDir?: string | null;
|
snapshotSourceDir?: string | null;
|
||||||
|
snapshotSourceFingerprint?: string | null;
|
||||||
snapshotRefreshedAt?: string | null;
|
snapshotRefreshedAt?: string | null;
|
||||||
|
status?: PairedWorkspace['status'];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}): PairedWorkspace {
|
}): PairedWorkspace {
|
||||||
const existing = getPairedWorkspace(args.taskId, args.role);
|
const existing = getPairedWorkspace(args.taskId, args.role);
|
||||||
@@ -287,7 +315,11 @@ function makeWorkspaceRecord(args: {
|
|||||||
workspace_dir: args.workspaceDir,
|
workspace_dir: args.workspaceDir,
|
||||||
snapshot_source_dir:
|
snapshot_source_dir:
|
||||||
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
|
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
|
||||||
status: 'ready',
|
snapshot_source_fingerprint:
|
||||||
|
args.snapshotSourceFingerprint ??
|
||||||
|
existing?.snapshot_source_fingerprint ??
|
||||||
|
null,
|
||||||
|
status: args.status ?? 'ready',
|
||||||
snapshot_refreshed_at:
|
snapshot_refreshed_at:
|
||||||
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
|
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
|
||||||
created_at: existing?.created_at || args.createdAt || now,
|
created_at: existing?.created_at || args.createdAt || now,
|
||||||
@@ -334,7 +366,12 @@ export function provisionOwnerWorkspaceForPairedTask(
|
|||||||
export function refreshReviewerSnapshotForPairedTask(
|
export function refreshReviewerSnapshotForPairedTask(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
): PairedWorkspace {
|
): PairedWorkspace {
|
||||||
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
|
const ownerWorkspace = getPairedWorkspace(taskId, 'owner');
|
||||||
|
if (!ownerWorkspace) {
|
||||||
|
throw new Error(
|
||||||
|
`Owner workspace is not available for paired task ${taskId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
ensureGitRepository(ownerWorkspace.workspace_dir);
|
ensureGitRepository(ownerWorkspace.workspace_dir);
|
||||||
|
|
||||||
const { task } = getTaskAndProject(taskId);
|
const { task } = getTaskAndProject(taskId);
|
||||||
@@ -359,6 +396,12 @@ export function refreshReviewerSnapshotForPairedTask(
|
|||||||
const allowedUntrackedFiles = listAllowedUntrackedFiles(
|
const allowedUntrackedFiles = listAllowedUntrackedFiles(
|
||||||
ownerWorkspace.workspace_dir,
|
ownerWorkspace.workspace_dir,
|
||||||
);
|
);
|
||||||
|
const snapshotFingerprint = buildReviewerSnapshotFingerprint({
|
||||||
|
sourceDir: ownerWorkspace.workspace_dir,
|
||||||
|
allowedTrackedFiles,
|
||||||
|
deletedTrackedFiles,
|
||||||
|
allowedUntrackedFiles,
|
||||||
|
});
|
||||||
|
|
||||||
runGit(['reset', '--hard', 'HEAD'], reviewerDir);
|
runGit(['reset', '--hard', 'HEAD'], reviewerDir);
|
||||||
runGit(['clean', '-fdx'], reviewerDir);
|
runGit(['clean', '-fdx'], reviewerDir);
|
||||||
@@ -382,6 +425,7 @@ export function refreshReviewerSnapshotForPairedTask(
|
|||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
workspaceDir: reviewerDir,
|
workspaceDir: reviewerDir,
|
||||||
snapshotSourceDir: ownerWorkspace.workspace_dir,
|
snapshotSourceDir: ownerWorkspace.workspace_dir,
|
||||||
|
snapshotSourceFingerprint: snapshotFingerprint,
|
||||||
snapshotRefreshedAt: refreshedAt,
|
snapshotRefreshedAt: refreshedAt,
|
||||||
});
|
});
|
||||||
upsertPairedWorkspace(workspace);
|
upsertPairedWorkspace(workspace);
|
||||||
@@ -395,16 +439,122 @@ export function refreshReviewerSnapshotForPairedTask(
|
|||||||
export function markPairedTaskReviewReady(taskId: string): {
|
export function markPairedTaskReviewReady(taskId: string): {
|
||||||
ownerWorkspace: PairedWorkspace;
|
ownerWorkspace: PairedWorkspace;
|
||||||
reviewerWorkspace: PairedWorkspace;
|
reviewerWorkspace: PairedWorkspace;
|
||||||
} {
|
} | null {
|
||||||
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
|
const requestedAt = new Date().toISOString();
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'review_pending',
|
||||||
|
review_requested_at: requestedAt,
|
||||||
|
updated_at: requestedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace = getPairedWorkspace(taskId, 'owner');
|
||||||
|
if (!ownerWorkspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId);
|
const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
updatePairedTask(taskId, {
|
updatePairedTask(taskId, {
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
review_requested_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ownerWorkspace, reviewerWorkspace };
|
return { ownerWorkspace, reviewerWorkspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreparedReviewerWorkspace {
|
||||||
|
workspace: PairedWorkspace | null;
|
||||||
|
blockMessage?: string;
|
||||||
|
autoRefreshed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareReviewerWorkspaceForExecution(
|
||||||
|
task: PairedTask,
|
||||||
|
): PreparedReviewerWorkspace {
|
||||||
|
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
|
||||||
|
if (!ownerWorkspace) {
|
||||||
|
return {
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage: REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reviewerWorkspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
|
||||||
|
const allowedTrackedFiles = listAllowedTrackedFiles(
|
||||||
|
ownerWorkspace.workspace_dir,
|
||||||
|
);
|
||||||
|
const deletedTrackedFiles = listDeletedTrackedFiles(
|
||||||
|
ownerWorkspace.workspace_dir,
|
||||||
|
);
|
||||||
|
const allowedUntrackedFiles = listAllowedUntrackedFiles(
|
||||||
|
ownerWorkspace.workspace_dir,
|
||||||
|
);
|
||||||
|
const currentFingerprint = buildReviewerSnapshotFingerprint({
|
||||||
|
sourceDir: ownerWorkspace.workspace_dir,
|
||||||
|
allowedTrackedFiles,
|
||||||
|
deletedTrackedFiles,
|
||||||
|
allowedUntrackedFiles,
|
||||||
|
});
|
||||||
|
const snapshotMissing =
|
||||||
|
!reviewerWorkspace?.snapshot_refreshed_at || !reviewerWorkspace;
|
||||||
|
const snapshotStale =
|
||||||
|
!!reviewerWorkspace &&
|
||||||
|
(reviewerWorkspace.status === 'stale' ||
|
||||||
|
reviewerWorkspace.snapshot_source_fingerprint !== currentFingerprint);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (snapshotMissing || snapshotStale) {
|
||||||
|
if (
|
||||||
|
task.status === 'review_pending' ||
|
||||||
|
task.status === 'review_ready'
|
||||||
|
) {
|
||||||
|
const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id);
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
status: 'review_ready',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workspace: refreshedWorkspace,
|
||||||
|
autoRefreshed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshotMissing) {
|
||||||
|
return {
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage: REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewerWorkspace) {
|
||||||
|
upsertPairedWorkspace(
|
||||||
|
makeWorkspaceRecord({
|
||||||
|
taskId: task.id,
|
||||||
|
role: 'reviewer',
|
||||||
|
workspaceDir: reviewerWorkspace.workspace_dir,
|
||||||
|
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
|
||||||
|
snapshotSourceFingerprint:
|
||||||
|
reviewerWorkspace.snapshot_source_fingerprint,
|
||||||
|
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
|
||||||
|
status: 'stale',
|
||||||
|
createdAt: reviewerWorkspace.created_at,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
status: 'review_pending',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workspace: null,
|
||||||
|
autoRefreshed: false,
|
||||||
|
blockMessage: REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: reviewerWorkspace,
|
||||||
|
autoRefreshed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects bare /review-ready', () => {
|
it('detects bare /review', () => {
|
||||||
expect(extractSessionCommand('/review-ready', trigger)).toBe(
|
expect(extractSessionCommand('/review', trigger)).toBe('/review');
|
||||||
'/review-ready',
|
});
|
||||||
);
|
|
||||||
|
it('normalizes /review-ready to /review', () => {
|
||||||
|
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects /compact with extra text', () => {
|
it('rejects /compact with extra text', () => {
|
||||||
@@ -185,7 +187,32 @@ describe('handleSessionCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles authorized /review-ready without invoking the agent', async () => {
|
it('handles authorized /review without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
markReviewReady: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('Review snapshot updated.\n- Task: paired-task-1'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/review')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Review snapshot updated.\n- Task: paired-task-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles authorized /review-ready as an alias without invoking the agent', async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
markReviewReady: vi
|
markReviewReady: vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
|
|||||||
/^\/compact failed\. The session is unchanged\.$/,
|
/^\/compact failed\. The session is unchanged\.$/,
|
||||||
/^Conversation compacted\.$/,
|
/^Conversation compacted\.$/,
|
||||||
/^Review snapshot updated\.$/,
|
/^Review snapshot updated\.$/,
|
||||||
/^Review-ready is unavailable for this room\./,
|
/^Review is unavailable for this room\./,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +26,7 @@ export function extractSessionCommand(
|
|||||||
text = text.replace(triggerPattern, '').trim();
|
text = text.replace(triggerPattern, '').trim();
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
if (text === '/clear') return '/clear';
|
if (text === '/clear') return '/clear';
|
||||||
if (text === '/review-ready') return '/review-ready';
|
if (text === '/review' || text === '/review-ready') return '/review';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +153,12 @@ export async function handleSessionCommand(opts: {
|
|||||||
return { handled: true, success: true };
|
return { handled: true, success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === '/review-ready') {
|
if (command === '/review') {
|
||||||
const result = await deps.markReviewReady();
|
const result = await deps.markReviewReady();
|
||||||
deps.advanceCursor(cmdMsg.timestamp);
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
await deps.sendMessage(
|
await deps.sendMessage(
|
||||||
result ??
|
result ??
|
||||||
'Review-ready is unavailable for this room. Paired workspaces require a configured project workDir.',
|
'Review is unavailable for this room. Paired workspaces require a configured project workDir.',
|
||||||
);
|
);
|
||||||
return { handled: true, success: true };
|
return { handled: true, success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
|
|||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
|
| 'review_pending'
|
||||||
| 'review_ready'
|
| 'review_ready'
|
||||||
| 'in_review'
|
| 'in_review'
|
||||||
| 'changes_requested'
|
| 'changes_requested'
|
||||||
@@ -107,6 +108,7 @@ export interface PairedWorkspace {
|
|||||||
role: PairedWorkspaceRole;
|
role: PairedWorkspaceRole;
|
||||||
workspace_dir: string;
|
workspace_dir: string;
|
||||||
snapshot_source_dir: string | null;
|
snapshot_source_dir: string | null;
|
||||||
|
snapshot_source_fingerprint: string | null;
|
||||||
status: PairedWorkspaceStatus;
|
status: PairedWorkspaceStatus;
|
||||||
snapshot_refreshed_at: string | null;
|
snapshot_refreshed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user