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,258 @@
import { execFileSync } from 'child_process';
import crypto from 'crypto';
import {
createPairedExecution,
createPairedTask,
getLatestOpenPairedTaskForChat,
getPairedExecutionById,
getPairedTaskById,
getPairedWorkspace,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
} from './db.js';
import { logger } from './logger.js';
import {
markPairedTaskReviewReady,
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type {
PairedExecution,
PairedTask,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
} from './types.js';
function resolveCanonicalSourceRef(workDir: string): string {
try {
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: workDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
return head || 'HEAD';
} catch {
return 'HEAD';
}
}
function ensurePairedProject(
group: RegisteredGroup,
chatJid: string,
): string | null {
if (!group.workDir) {
return null;
}
const now = new Date().toISOString();
upsertPairedProject({
chat_jid: chatJid,
group_folder: group.folder,
canonical_work_dir: group.workDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
return group.workDir;
}
function ensureActiveTask(
group: RegisteredGroup,
chatJid: string,
roomRoleContext: RoomRoleContext,
): PairedTask | null {
const canonicalWorkDir = ensurePairedProject(group, chatJid);
if (!canonicalWorkDir) {
return null;
}
const existing = getLatestOpenPairedTaskForChat(chatJid);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const task: PairedTask = {
id: crypto.randomUUID(),
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: roomRoleContext.ownerServiceId,
reviewer_service_id: roomRoleContext.reviewerServiceId,
title: null,
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
};
createPairedTask(task);
logger.info(
{
chatJid,
groupFolder: group.folder,
taskId: task.id,
sourceRef: task.source_ref,
},
'Created active paired task for room',
);
return task;
}
function ensureExecutionRecord(args: {
runId: string;
roomRoleContext: RoomRoleContext;
task: PairedTask;
workspace?: PairedWorkspace;
}): PairedExecution {
const executionId = `${args.runId}:${args.roomRoleContext.serviceId}`;
const existing = getPairedExecutionById(executionId);
const now = new Date().toISOString();
if (existing) {
updatePairedExecution(existing.id, {
workspace_id: args.workspace?.id ?? existing.workspace_id,
status: 'running',
started_at: existing.started_at ?? now,
});
return {
...existing,
workspace_id: args.workspace?.id ?? existing.workspace_id,
status: 'running',
started_at: existing.started_at ?? now,
};
}
const execution: PairedExecution = {
id: executionId,
task_id: args.task.id,
service_id: args.roomRoleContext.serviceId,
role: args.roomRoleContext.role,
workspace_id: args.workspace?.id ?? null,
status: 'running',
summary: null,
created_at: now,
started_at: now,
completed_at: null,
};
createPairedExecution(execution);
return execution;
}
export interface PreparedPairedExecutionContext {
task: PairedTask;
execution: PairedExecution;
workspace: PairedWorkspace | null;
envOverrides: Record<string, string>;
blockMessage?: string;
}
export function preparePairedExecutionContext(args: {
group: RegisteredGroup;
chatJid: string;
runId: string;
roomRoleContext?: RoomRoleContext;
}): PreparedPairedExecutionContext | undefined {
const { group, chatJid, runId, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
return undefined;
}
const task = ensureActiveTask(group, chatJid, roomRoleContext);
if (!task) {
return undefined;
}
let workspace: PairedWorkspace | null = null;
let blockMessage: string | undefined;
const now = new Date().toISOString();
if (roomRoleContext.role === 'owner') {
workspace = provisionOwnerWorkspaceForPairedTask(task.id);
} else if (
task.status === 'review_ready' ||
task.status === 'in_review' ||
task.status === 'merge_ready' ||
task.status === 'changes_requested'
) {
workspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
if (workspace && task.status === 'review_ready') {
updatePairedTask(task.id, {
status: 'in_review',
updated_at: now,
});
}
} else {
blockMessage =
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.';
}
const execution = ensureExecutionRecord({
runId,
roomRoleContext,
task,
workspace: workspace ?? undefined,
});
const envOverrides: Record<string, string> = {
EJCLAW_PAIRED_TASK_ID: task.id,
EJCLAW_PAIRED_EXECUTION_ID: execution.id,
EJCLAW_PAIRED_ROLE: roomRoleContext.role,
};
if (workspace?.workspace_dir) {
envOverrides.EJCLAW_WORK_DIR = workspace.workspace_dir;
}
if (roomRoleContext.role === 'reviewer') {
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
}
return {
task: getPairedTaskById(task.id) ?? task,
execution,
workspace,
envOverrides,
blockMessage,
};
}
export function completePairedExecutionContext(args: {
executionId: string;
status: 'succeeded' | 'failed';
summary?: string | null;
}): void {
updatePairedExecution(args.executionId, {
status: args.status,
summary: args.summary ?? null,
completed_at: new Date().toISOString(),
});
}
export function markRoomReviewReady(args: {
group: RegisteredGroup;
chatJid: string;
roomRoleContext?: RoomRoleContext;
}): {
task: PairedTask;
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
} | null {
const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
return null;
}
const task = ensureActiveTask(group, chatJid, roomRoleContext);
if (!task) {
return null;
}
const { ownerWorkspace, reviewerWorkspace } = markPairedTaskReviewReady(
task.id,
);
return {
task: getPairedTaskById(task.id) ?? task,
ownerWorkspace,
reviewerWorkspace,
};
}