Files
EJClaw/src/paired-execution-context.ts
2026-03-29 01:35:57 +09:00

308 lines
7.6 KiB
TypeScript

import { execFileSync } from 'child_process';
import crypto from 'crypto';
import {
SERVICE_ID,
normalizeServiceId,
} from './config.js';
import {
createPairedExecution,
createPairedTask,
getLatestOpenPairedTaskForChat,
getPairedExecutionById,
getPairedTaskById,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
} from './db.js';
import { logger } from './logger.js';
import {
markPairedTaskReviewReady,
prepareReviewerWorkspaceForExecution,
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),
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'active',
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 type MarkRoomReviewReadyResult =
| {
status: 'ready';
task: PairedTask;
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
}
| {
status: 'pending';
task: PairedTask;
pendingReason: 'owner-workspace-not-ready';
};
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 {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(task);
workspace = reviewerWorkspace.workspace;
blockMessage = reviewerWorkspace.blockMessage;
const latestTask = getPairedTaskById(task.id) ?? task;
if (workspace && latestTask.status === 'review_ready') {
updatePairedTask(task.id, {
status: 'in_review',
updated_at: now,
});
}
}
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;
}): MarkRoomReviewReadyResult | null {
const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
return null;
}
const task = ensureActiveTask(group, chatJid, roomRoleContext);
if (!task) {
return null;
}
const isOwnerService =
normalizeServiceId(SERVICE_ID) === task.owner_service_id;
if (isOwnerService) {
provisionOwnerWorkspaceForPairedTask(task.id);
}
const reviewReady = markPairedTaskReviewReady(task.id);
const latestTask = getPairedTaskById(task.id) ?? task;
if (!reviewReady) {
return {
status: 'pending',
task: latestTask,
pendingReason: 'owner-workspace-not-ready',
};
}
const { ownerWorkspace, reviewerWorkspace } = reviewReady;
return {
status: 'ready',
task: latestTask,
ownerWorkspace,
reviewerWorkspace,
};
}
export function formatRoomReviewReadyMessage(
result: MarkRoomReviewReadyResult | null,
): string | null {
if (!result) {
return null;
}
if (result.status === 'pending') {
return [
'Review request recorded, but the owner workspace is not ready yet.',
`- Task: ${result.task.id}`,
'The task stays review_pending until the owner workspace is prepared.',
].join('\n');
}
return [
'Review snapshot updated.',
`- Task: ${result.task.id}`,
`- Owner workspace: ${result.ownerWorkspace.workspace_dir}`,
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
].join('\n');
}