feat: add paired review workspace flow
This commit is contained in:
258
src/paired-execution-context.ts
Normal file
258
src/paired-execution-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user