refactor: simplify paired review system and add reviewer container isolation

Phase 1 — Strip over-engineering (-3,665 lines):
- DB tables: 7 → 3 (remove executions, approvals, artifacts, events)
- Task statuses: 11 → 5 (active, review_ready, in_review, merge_ready, completed)
- Remove plan governance, event sourcing, gate/verdict, freshness guards
- paired-execution-context: 980 → 299 lines
- Session commands: remove /risk, /plan, /approve-plan, /request-plan-changes

Phase 2 — Container isolation for reviewers:
- Add container-runtime.ts (Docker abstraction)
- Add credential-proxy.ts (API key injection without container exposure)
- Add container-runner.ts (reviewer-specific read-only mount + tmpfs)
- Add container/Dockerfile + agent-runner (Chromium, Claude Code, Codex)
- agent-runner.ts: auto-route reviewer execution to container mode
This commit is contained in:
Eyejoker
2026-03-29 18:16:28 +09:00
parent 3dd41f749e
commit fc9f2867b9
24 changed files with 1174 additions and 4900 deletions

63
container/Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# EJClaw Reviewer Container
# Runs Claude/Codex agent in read-only isolated environment for code review.
FROM node:22-slim
# System dependencies for Chromium (E2E tests, screenshots)
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
libatk-bridge2.0-0 \
libgtk-3-0 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libasound2 \
libpangocairo-1.0-0 \
libcups2 \
libdrm2 \
libxshmfence1 \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Chromium paths for Playwright/agent-browser
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
# Install Claude Code and Codex CLI globally
RUN npm install -g @anthropic-ai/claude-code @openai/codex
# Create app directory
WORKDIR /app
# Copy and install agent runner
COPY agent-runner/package*.json ./
RUN npm install
COPY agent-runner/ ./
RUN npm run build
# Create workspace directories
RUN mkdir -p /workspace/project /workspace/group /workspace/global \
/workspace/ipc/messages /workspace/ipc/input
# Create entrypoint script
RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nnode /app/dist/index.js < /tmp/input.json\n' \
> /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# Set ownership to node user (non-root)
RUN chown -R node:node /workspace /home/node
# Switch to non-root user
USER node
# Working directory is the read-only project mount
WORKDIR /workspace/project
# Entry point reads JSON from stdin, outputs JSON to stdout
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -0,0 +1,17 @@
{
"name": "ejclaw-reviewer-runner",
"version": "1.0.0",
"type": "module",
"description": "Reviewer agent runner for EJClaw containers",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,139 @@
/**
* EJClaw Reviewer Container Agent Runner
*
* Runs inside a Docker container. Reads input from stdin,
* invokes Claude Code or Codex, writes output to stdout
* via marker-delimited JSON.
*
* The project directory is mounted read-only — the reviewer
* can read/test code but cannot modify it.
*/
import { claude } from '@anthropic-ai/claude-code';
import fs from 'fs';
import path from 'path';
// ── Protocol markers (must match host's agent-protocol.ts) ────────
const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
// ── Types ─────────────────────────────────────────────────────────
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
runId: string;
isMain: boolean;
assistantName?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
phase: 'final';
}
// ── Helpers ───────────────────────────────────────────────────────
function emitOutput(output: ContainerOutput): void {
const json = JSON.stringify(output);
process.stdout.write(`${OUTPUT_START_MARKER}${json}${OUTPUT_END_MARKER}\n`);
}
function readStdinJson(): Promise<ContainerInput> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()));
} catch (err) {
reject(new Error(`Failed to parse stdin JSON: ${err}`));
}
});
process.stdin.on('error', reject);
});
}
// ── Main ──────────────────────────────────────────────────────────
async function main(): Promise<void> {
const input = await readStdinJson();
// Load group CLAUDE.md if present
const groupClaudeMd = path.join('/workspace/group', 'CLAUDE.md');
const systemPromptParts: string[] = [];
if (fs.existsSync(groupClaudeMd)) {
systemPromptParts.push(fs.readFileSync(groupClaudeMd, 'utf-8'));
}
// Reviewer-specific instructions
systemPromptParts.push(
'You are a code reviewer. The project directory is mounted read-only.',
'You can read files, run tests, and analyze code, but you cannot modify any source files.',
'Focus on: correctness, security, performance, and test coverage.',
);
const systemPrompt = systemPromptParts.join('\n\n');
try {
const result = await claude({
prompt: input.prompt,
systemPrompt,
options: {
allowedTools: [
'Read',
'Bash',
'Glob',
'Grep',
'LS',
'Agent',
'WebSearch',
'WebFetch',
],
maxTurns: 30,
cwd: '/workspace/project',
},
});
// Extract text from result
const text =
typeof result === 'string'
? result
: Array.isArray(result)
? result
.filter(
(b: { type: string; text?: string }) => b.type === 'text',
)
.map((b: { text?: string }) => b.text || '')
.join('\n')
: String(result);
emitOutput({
status: 'success',
result: text,
phase: 'final',
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
emitOutput({
status: 'error',
result: null,
error: errorMsg,
phase: 'final',
});
}
}
main().catch((err) => {
console.error('Fatal container error:', err);
emitOutput({
status: 'error',
result: null,
error: `Fatal: ${err instanceof Error ? err.message : String(err)}`,
phase: 'final',
});
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}

10
container/build.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Build the EJClaw reviewer container image
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Building ejclaw-reviewer container image..."
docker build -t ejclaw-reviewer:latest .
echo "Done. Image: ejclaw-reviewer:latest"

View File

@@ -20,6 +20,7 @@ export {
} from './agent-runner-snapshot.js';
import { logger } from './logger.js';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
import { runReviewerContainer } from './container-runner.js';
import {
AgentOutputPhase,
AgentType,
@@ -67,6 +68,37 @@ export async function runAgentProcess(
onOutput?: (output: AgentOutput) => Promise<void>,
envOverrides?: Record<string, string>,
): Promise<AgentOutput> {
// ── Reviewer container mode ─────────────────────────────────────
// When EJCLAW_REVIEWER_RUNTIME is set in envOverrides, run the reviewer
// inside a Docker container with read-only source mount instead of
// as a host process. This provides kernel-level write protection.
const isReviewerContainer =
envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1' &&
process.env.REVIEWER_CONTAINER_ENABLED !== '0';
if (isReviewerContainer) {
const ownerWorkspaceDir =
envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd();
return runReviewerContainer({
group,
input: {
prompt: input.prompt,
sessionId: input.sessionId,
groupFolder: input.groupFolder,
chatJid: input.chatJid,
runId: input.runId || `${Date.now()}`,
isMain: input.isMain,
assistantName: input.assistantName,
},
ownerWorkspaceDir,
envOverrides,
onOutput,
onProcess: (proc, containerName) => {
onProcess(proc, containerName, '');
},
});
}
// ── Host process mode (owner) ───────────────────────────────────
const startTime = Date.now();
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
group,

View File

@@ -87,6 +87,14 @@ export const RECOVERY_CONCURRENT_AGENTS = parseInt(
getEnv('RECOVERY_CONCURRENT_AGENTS') || '3',
10,
);
// ── Container isolation ───────────────────────────────────────────
export const REVIEWER_CONTAINER_IMAGE =
getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest';
export const CREDENTIAL_PROXY_PORT = parseInt(
getEnv('CREDENTIAL_PROXY_PORT') || '3001',
10,
);
export const RECOVERY_STAGGER_MS = parseInt(
getEnv('RECOVERY_STAGGER_MS') || '2000',
10,

381
src/container-runner.ts Normal file
View File

@@ -0,0 +1,381 @@
/**
* Container runner for EJClaw reviewer agents.
*
* Spawns reviewer execution inside a Docker container with:
* - Source code mounted read-only (kernel-level write protection)
* - tmpfs overlays for test runner caches (vitest, coverage)
* - IPC via filesystem (input/ directory for follow-up messages)
* - Credentials injected by the credential proxy (never exposed to container)
*/
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
import {
AGENT_MAX_OUTPUT_SIZE,
AGENT_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
TIMEZONE,
} from './config.js';
import {
CONTAINER_HOST_GATEWAY,
CONTAINER_RUNTIME_BIN,
hostGatewayArgs,
readonlyMountArgs,
stopContainer,
tmpfsMountArgs,
writableMountArgs,
} from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import type { AgentOutput } from './agent-runner.js';
import type { RegisteredGroup } from './types.js';
// ── Config ────────────────────────────────────────────────────────
const CONTAINER_IMAGE =
process.env.REVIEWER_CONTAINER_IMAGE || 'ejclaw-reviewer:latest';
const CREDENTIAL_PROXY_PORT = parseInt(
process.env.CREDENTIAL_PROXY_PORT || '3001',
10,
);
// ── Types ─────────────────────────────────────────────────────────
export interface ReviewerContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
runId: string;
isMain: boolean;
assistantName?: string;
}
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
// ── Mount builder ─────────────────────────────────────────────────
export function buildReviewerMounts(
group: RegisteredGroup,
ownerWorkspaceDir: string,
): VolumeMount[] {
const mounts: VolumeMount[] = [];
const groupDir = resolveGroupFolderPath(group.folder);
const groupIpcDir = resolveGroupIpcPath(group.folder);
// Source code: READ-ONLY (kernel-level protection)
mounts.push({
hostPath: ownerWorkspaceDir,
containerPath: '/workspace/project',
readonly: true,
});
// Shadow .env so reviewer cannot read secrets from mounted project
const envFile = path.join(ownerWorkspaceDir, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Group folder: writable (logs, session data)
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// IPC directory: writable (output messages, task results)
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
mounts.push({
hostPath: groupIpcDir,
containerPath: '/workspace/ipc',
readonly: false,
});
// Global memory: read-only
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
readonly: true,
});
}
// Session directory for Claude/Codex state
const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder);
fs.mkdirSync(groupSessionsDir, { recursive: true });
mounts.push({
hostPath: groupSessionsDir,
containerPath: '/home/node/.claude',
readonly: false,
});
return mounts;
}
// ── Container args builder ────────────────────────────────────────
function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
envOverrides?: Record<string, string>,
): string[] {
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
// Timezone
args.push('-e', `TZ=${TIMEZONE}`);
// Credential proxy — containers never see real API keys
args.push(
'-e',
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
);
const authMode = detectAuthMode();
if (authMode === 'api-key') {
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
} else {
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
}
// Reviewer runtime flag
args.push('-e', 'EJCLAW_REVIEWER_RUNTIME=1');
// Extra env overrides from paired-execution-context
if (envOverrides) {
for (const [key, value] of Object.entries(envOverrides)) {
if (
key === 'ANTHROPIC_BASE_URL' ||
key === 'ANTHROPIC_API_KEY' ||
key === 'CLAUDE_CODE_OAUTH_TOKEN'
)
continue;
args.push('-e', `${key}=${value}`);
}
}
// Host gateway
args.push(...hostGatewayArgs());
// Run as host user for bind-mount compatibility
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
args.push('--user', `${hostUid}:${hostGid}`);
args.push('-e', 'HOME=/home/node');
}
// Volume mounts
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
} else {
args.push(...writableMountArgs(mount.hostPath, mount.containerPath));
}
}
// tmpfs overlays for test runner caches (writable, ephemeral)
args.push(...tmpfsMountArgs('/workspace/project/node_modules/.vitest'));
args.push(...tmpfsMountArgs('/workspace/project/node_modules/.cache'));
args.push(...tmpfsMountArgs('/workspace/project/coverage'));
args.push(...tmpfsMountArgs('/tmp'));
args.push(CONTAINER_IMAGE);
return args;
}
// ── Main runner ───────────────────────────────────────────────────
export async function runReviewerContainer(args: {
group: RegisteredGroup;
input: ReviewerContainerInput;
ownerWorkspaceDir: string;
envOverrides?: Record<string, string>;
onOutput?: (output: AgentOutput) => Promise<void>;
onProcess?: (proc: ChildProcess, containerName: string) => void;
}): Promise<AgentOutput> {
const { group, input, ownerWorkspaceDir, envOverrides, onOutput, onProcess } =
args;
const startTime = Date.now();
const containerName = `ejclaw-reviewer-${group.folder}-${Date.now()}`;
const mounts = buildReviewerMounts(group, ownerWorkspaceDir);
const containerArgs = buildContainerArgs(
mounts,
containerName,
envOverrides,
);
logger.info(
{
containerName,
group: group.name,
groupFolder: group.folder,
chatJid: input.chatJid,
runId: input.runId,
image: CONTAINER_IMAGE,
mountCount: mounts.length,
},
'Spawning reviewer container',
);
return new Promise<AgentOutput>((resolve) => {
const proc = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
});
onProcess?.(proc, containerName);
// Send input via stdin
const stdinPayload = JSON.stringify(input);
proc.stdin.write(stdinPayload);
proc.stdin.end();
let stdout = '';
let stderr = '';
let lastOutput: AgentOutput | null = null;
let totalOutputSize = 0;
let parseBuffer = '';
// Streaming output: parse OUTPUT_START/END marker pairs
proc.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
totalOutputSize += text.length;
if (totalOutputSize > AGENT_MAX_OUTPUT_SIZE) {
logger.warn(
{ containerName, size: totalOutputSize },
'Container output exceeds max size, killing',
);
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
return;
}
stdout += text;
parseBuffer += text;
// Parse streamed output markers
let startIdx: number;
while (
(startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1
) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break;
const json = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed = JSON.parse(json) as AgentOutput;
lastOutput = parsed;
void onOutput?.(parsed);
} catch (err) {
logger.warn(
{ containerName, err },
'Failed to parse container output JSON',
);
}
}
});
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
// Timeout
const timeout = setTimeout(() => {
logger.warn(
{ containerName, timeoutMs: AGENT_TIMEOUT },
'Reviewer container timed out, stopping',
);
try {
proc.kill('SIGTERM');
} catch {
/* ignore */
}
}, AGENT_TIMEOUT);
proc.on('close', (code, signal) => {
clearTimeout(timeout);
const durationMs = Date.now() - startTime;
logger.info(
{
containerName,
group: group.name,
runId: input.runId,
exitCode: code,
signal,
durationMs,
stdoutLen: stdout.length,
stderrLen: stderr.length,
},
'Reviewer container exited',
);
if (lastOutput) {
resolve(lastOutput);
return;
}
// Fallback: try to parse from full stdout
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
if (startIdx !== -1 && endIdx !== -1) {
try {
const json = stdout
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
.trim();
resolve(JSON.parse(json) as AgentOutput);
return;
} catch {
/* fall through */
}
}
// No valid output
const errorMsg = stderr.trim() || `Container exited with code ${code}`;
resolve({
status: code === 0 ? 'success' : 'error',
result: null,
error: errorMsg,
phase: 'final',
});
});
proc.on('error', (err) => {
clearTimeout(timeout);
logger.error(
{ containerName, err },
'Failed to spawn reviewer container',
);
resolve({
status: 'error',
result: null,
error: `Container spawn error: ${err.message}`,
phase: 'final',
});
});
});
}

103
src/container-runtime.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Container runtime abstraction for EJClaw.
* Reviewer agents run inside Docker containers for read-only isolation.
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import { logger } from './logger.js';
export const CONTAINER_RUNTIME_BIN = 'docker';
export const CONTAINER_HOST_GATEWAY = 'host.docker.internal';
/**
* Address the credential proxy binds to.
* Docker Desktop (macOS): 127.0.0.1
* Docker (Linux): docker0 bridge IP (containers can reach it via host.docker.internal)
*/
export const PROXY_BIND_HOST =
process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost();
function detectProxyBindHost(): string {
if (os.platform() === 'darwin') return '127.0.0.1';
if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1';
const ifaces = os.networkInterfaces();
const docker0 = ifaces['docker0'];
if (docker0) {
const ipv4 = docker0.find((a) => a.family === 'IPv4');
if (ipv4) return ipv4.address;
}
return '0.0.0.0';
}
export function hostGatewayArgs(): string[] {
if (os.platform() === 'linux') {
return ['--add-host=host.docker.internal:host-gateway'];
}
return [];
}
export function readonlyMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
}
export function writableMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return ['-v', `${hostPath}:${containerPath}`];
}
export function tmpfsMountArgs(containerPath: string): string[] {
return ['--tmpfs', containerPath];
}
export function stopContainer(name: string): string {
return `${CONTAINER_RUNTIME_BIN} stop ${name}`;
}
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running');
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
throw new Error(
'Container runtime (Docker) is required for reviewer isolation but failed to start. Run: docker info',
);
}
}
export function cleanupOrphans(): void {
try {
const output = execSync(
`${CONTAINER_RUNTIME_BIN} ps --filter name=ejclaw-reviewer- --format '{{.Names}}'`,
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
);
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });
} catch {
/* already stopped */
}
}
if (orphans.length > 0) {
logger.info(
{ count: orphans.length, names: orphans },
'Stopped orphaned reviewer containers',
);
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}

172
src/credential-proxy.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Credential proxy for reviewer container isolation.
* Containers connect here instead of directly to AI APIs.
* The proxy injects real credentials so containers never see them.
*
* Supports both Claude (Anthropic) and Codex (OpenAI) APIs:
* Claude API key: Proxy injects x-api-key on every request.
* Claude OAuth: Proxy replaces placeholder Bearer token with real one.
* Codex: Proxy injects Authorization: Bearer {OPENAI_API_KEY}.
*/
import { createServer, Server } from 'http';
import { request as httpsRequest } from 'https';
import { request as httpRequest, RequestOptions } from 'http';
import { getEnv } from './env.js';
import { logger } from './logger.js';
export type AuthMode = 'api-key' | 'oauth';
export function startCredentialProxy(
port: number,
host = '127.0.0.1',
): Promise<Server> {
const anthropicApiKey = getEnv('ANTHROPIC_API_KEY') || '';
const oauthToken =
getEnv('CLAUDE_CODE_OAUTH_TOKEN') || getEnv('ANTHROPIC_AUTH_TOKEN') || '';
const anthropicBaseUrl =
getEnv('ANTHROPIC_BASE_URL') || 'https://api.anthropic.com';
const openaiApiKey = getEnv('OPENAI_API_KEY') || '';
const authMode: AuthMode = anthropicApiKey ? 'api-key' : 'oauth';
const anthropicUrl = new URL(anthropicBaseUrl);
const anthropicIsHttps = anthropicUrl.protocol === 'https:';
const makeAnthropicRequest = anthropicIsHttps ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
// Route: Codex/OpenAI requests go to OpenAI API
const isOpenAI =
req.headers['x-ejclaw-provider'] === 'openai' ||
(req.url || '').startsWith('/v1/chat/completions');
if (isOpenAI) {
proxyToOpenAI(req, res, body, openaiApiKey);
return;
}
// Default: Anthropic/Claude API
const headers: Record<
string,
string | number | string[] | undefined
> = {
...(req.headers as Record<string, string>),
host: anthropicUrl.host,
'content-length': body.length,
};
delete headers['connection'];
delete headers['keep-alive'];
delete headers['transfer-encoding'];
delete headers['x-ejclaw-provider'];
if (authMode === 'api-key') {
delete headers['x-api-key'];
headers['x-api-key'] = anthropicApiKey;
} else if (headers['authorization']) {
delete headers['authorization'];
if (oauthToken) {
headers['authorization'] = `Bearer ${oauthToken}`;
}
}
const upstream = makeAnthropicRequest(
{
hostname: anthropicUrl.hostname,
port: anthropicUrl.port || (anthropicIsHttps ? 443 : 80),
path: req.url,
method: req.method,
headers,
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
},
);
upstream.on('error', (err) => {
logger.error(
{ err, url: req.url },
'Credential proxy upstream error (Anthropic)',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
}
});
upstream.write(body);
upstream.end();
});
});
server.listen(port, host, () => {
logger.info({ port, host, authMode }, 'Credential proxy started');
resolve(server);
});
server.on('error', reject);
});
}
function proxyToOpenAI(
req: import('http').IncomingMessage,
res: import('http').ServerResponse,
body: Buffer,
apiKey: string,
): void {
const headers: Record<string, string | number | string[] | undefined> = {
...(req.headers as Record<string, string>),
host: 'api.openai.com',
'content-length': body.length,
};
delete headers['connection'];
delete headers['keep-alive'];
delete headers['transfer-encoding'];
delete headers['x-ejclaw-provider'];
// Inject real OpenAI API key
delete headers['authorization'];
if (apiKey) {
headers['authorization'] = `Bearer ${apiKey}`;
}
const upstream = httpsRequest(
{
hostname: 'api.openai.com',
port: 443,
path: req.url,
method: req.method,
headers,
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
},
);
upstream.on('error', (err) => {
logger.error(
{ err, url: req.url },
'Credential proxy upstream error (OpenAI)',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
}
});
upstream.write(body);
upstream.end();
}
export function detectAuthMode(): AuthMode {
return getEnv('ANTHROPIC_API_KEY') ? 'api-key' : 'oauth';
}

View File

@@ -1,19 +1,11 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
_initTestDatabaseFromFile,
applyPairedEvent,
cancelSupersededPairedExecutions,
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
createPairedApproval,
createPairedArtifact,
createPairedExecution,
createPairedTask,
createTask,
createServiceHandoff,
@@ -32,8 +24,6 @@ import {
getRegisteredAgentTypesForJid,
getMessagesSince,
getNewMessages,
getPairedExecutionById,
getPairedEventByDedupeKey,
getPairedProject,
getPairedTaskById,
getPairedWorkspace,
@@ -41,10 +31,6 @@ import {
isPairedRoomJid,
getSession,
getTaskById,
listPairedApprovalsForTask,
listPairedArtifactsForTask,
listPairedEventsForTask,
listPairedExecutionsForTask,
listPairedWorkspacesForTask,
markWorkItemDelivered,
markWorkItemDeliveryRetry,
@@ -53,7 +39,6 @@ import {
setRouterStateForService,
storeChatMetadata,
storeMessage,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
upsertPairedWorkspace,
@@ -554,12 +539,11 @@ Check the run.
});
describe('paired task state', () => {
it('stores project, task, execution, workspace, approval, and artifact state', () => {
it('stores project, task, and workspace state', () => {
upsertPairedProject({
chat_jid: 'dc:paired',
group_folder: 'paired-room',
canonical_work_dir: '/tmp/paired-room',
workspace_topology: 'shadow-snapshot',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
@@ -572,604 +556,36 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: 'wire up workspaces',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
createPairedExecution({
id: 'paired-exec-1',
task_id: 'paired-task-1',
service_id: 'codex-main',
role: 'owner',
workspace_id: null,
status: 'pending',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: null,
completed_at: null,
});
upsertPairedWorkspace({
id: 'paired-task-1:owner',
task_id: 'paired-task-1',
role: 'owner',
workspace_dir: '/tmp/paired-room/owner',
snapshot_source_dir: null,
snapshot_source_fingerprint: null,
snapshot_ref: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const approvalId = createPairedApproval({
task_id: 'paired-task-1',
service_id: 'codex-review',
role: 'reviewer',
status: 'pending',
note: 'needs snapshot',
created_at: '2026-03-28T00:00:01.000Z',
});
const artifactId = createPairedArtifact({
task_id: 'paired-task-1',
execution_id: 'paired-exec-1',
service_id: 'codex-review',
artifact_type: 'plan_brief',
title: 'review notes',
content: 'looks fine',
file_path: null,
created_at: '2026-03-28T00:00:02.000Z',
});
expect(getPairedProject('dc:paired')?.canonical_work_dir).toBe(
'/tmp/paired-room',
);
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
expect(getPairedTaskById('paired-task-1')?.task_policy).toBe('autonomous');
expect(getPairedTaskById('paired-task-1')?.risk_level).toBe('low');
expect(getPairedTaskById('paired-task-1')?.plan_status).toBe(
'not_requested',
);
expect(getPairedTaskById('paired-task-1')?.gate_turn_kind ?? null).toBe(
null,
);
expect(
getPairedTaskById('paired-task-1')?.last_finalized_checkpoint ?? null,
).toBe(null);
expect(getPairedTaskById('paired-task-1')?.reviewer_verdict ?? null).toBe(
null,
);
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
expect(getPairedTaskById('paired-task-1')?.status).toBe('active');
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
'/tmp/paired-room/owner',
);
expect(listPairedApprovalsForTask('paired-task-1')[0]?.id).toBe(approvalId);
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
});
it('dedupes paired events and keeps request_review replay-safe', () => {
createPairedTask({
id: 'paired-task-events-1',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
const first = applyPairedEvent({
event: {
task_id: 'paired-task-events-1',
event_type: 'request_review',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'fingerprint-v1',
dedupe_key: 'msg-review-1',
payload_json: '{"request":"review"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-1', {
status: 'review_pending',
review_requested_at: '2026-03-29T00:01:00.000Z',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
const second = applyPairedEvent({
event: {
task_id: 'paired-task-events-1',
event_type: 'request_review',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'fingerprint-v2',
dedupe_key: 'msg-review-1',
payload_json: '{"request":"review-again"}',
created_at: '2026-03-29T00:02:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-1', {
status: 'review_ready',
review_requested_at: '2026-03-29T00:02:00.000Z',
updated_at: '2026-03-29T00:02:00.000Z',
});
},
});
expect(first.applied).toBe(true);
expect(second.applied).toBe(false);
expect(listPairedEventsForTask('paired-task-events-1')).toHaveLength(1);
expect(
getPairedEventByDedupeKey({
taskId: 'paired-task-events-1',
eventType: 'request_review',
dedupeKey: 'msg-review-1',
})?.source_fingerprint,
).toBe('fingerprint-v1');
expect(getPairedTaskById('paired-task-events-1')?.status).toBe(
'review_pending',
);
expect(getPairedTaskById('paired-task-events-1')?.review_requested_at).toBe(
'2026-03-29T00:01:00.000Z',
);
});
it('dedupes deploy_complete and atomically finalizes the checkpoint once', () => {
createPairedTask({
id: 'paired-task-events-deploy',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-29T00:00:00.000Z',
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: 'done',
reviewer_verdict_at: '2026-03-29T00:00:00.000Z',
reviewer_verdict_note: '**DONE** ready',
status: 'merge_ready',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
const first = applyPairedEvent({
event: {
task_id: 'paired-task-events-deploy',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-deploy', {
status: 'merged',
last_finalized_checkpoint: 'canonical-v1',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
const second = applyPairedEvent({
event: {
task_id: 'paired-task-events-deploy',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:02:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-deploy', {
status: 'failed',
last_finalized_checkpoint: 'should-not-change',
updated_at: '2026-03-29T00:02:00.000Z',
});
},
});
expect(first.applied).toBe(true);
expect(second.applied).toBe(false);
expect(
listPairedEventsForTask('paired-task-events-deploy').filter(
(event) => event.event_type === 'deploy_complete',
),
).toHaveLength(1);
expect(
getPairedTaskById('paired-task-events-deploy')?.last_finalized_checkpoint,
).toBe('canonical-v1');
expect(getLatestPairedTaskForChat('dc:paired')?.status).toBe('merged');
expect(getPairedTaskById('paired-task-events-deploy')?.status).toBe(
'merged',
);
});
it('rolls back paired event insert when the coupled state update fails', () => {
createPairedTask({
id: 'paired-task-events-2',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
expect(() =>
applyPairedEvent({
event: {
task_id: 'paired-task-events-2',
event_type: 'set_risk',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'fingerprint-v1',
dedupe_key: 'msg-risk-1',
payload_json: '{"riskLevel":"high"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-2', {
risk_level: 'high',
updated_at: '2026-03-29T00:01:00.000Z',
});
throw new Error('boom');
},
}),
).toThrow('boom');
expect(listPairedEventsForTask('paired-task-events-2')).toHaveLength(0);
expect(getPairedTaskById('paired-task-events-2')?.risk_level).toBe('low');
});
it('keeps paired event audit history after reopening the database', () => {
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-events-'));
const dbPath = path.join(tempRoot, 'messages.db');
_initTestDatabaseFromFile(dbPath);
createPairedTask({
id: 'paired-task-events-3',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
applyPairedEvent({
event: {
task_id: 'paired-task-events-3',
event_type: 'request_review',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'fingerprint-v1',
dedupe_key: 'msg-review-3',
payload_json: '{"request":"review"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-task-events-3', {
status: 'review_pending',
review_requested_at: '2026-03-29T00:01:00.000Z',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
_initTestDatabaseFromFile(dbPath);
expect(listPairedEventsForTask('paired-task-events-3')).toHaveLength(1);
expect(listPairedEventsForTask('paired-task-events-3')[0]?.dedupe_key).toBe(
'msg-review-3',
);
expect(
listPairedEventsForTask('paired-task-events-3')[0]?.source_fingerprint,
).toBe('fingerprint-v1');
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('backfills governance plan_status by legacy task status during migration', () => {
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-migration-'));
const dbPath = path.join(tempRoot, 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE paired_tasks (
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
);
`);
const insertLegacyTask = legacyDb.prepare(`
INSERT INTO paired_tasks (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
review_requested_at,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertLegacyTask.run(
'paired-active',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
null,
'active',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
insertLegacyTask.run(
'paired-draft',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
null,
'draft',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
insertLegacyTask.run(
'paired-in-review',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'2026-03-29T00:01:00.000Z',
'in_review',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:01:00.000Z',
);
insertLegacyTask.run(
'paired-merge-ready',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'2026-03-29T00:02:00.000Z',
'merge_ready',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:02:00.000Z',
);
legacyDb.close();
_initTestDatabaseFromFile(dbPath);
expect(getPairedTaskById('paired-in-review')?.plan_status).toBe('approved');
expect(getPairedTaskById('paired-merge-ready')?.plan_status).toBe(
'approved',
);
expect(getPairedTaskById('paired-active')?.plan_status).toBe(
'not_requested',
);
expect(getPairedTaskById('paired-draft')?.plan_status).toBe(
'not_requested',
);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('migrates paired task finalization fields and deploy_complete events', () => {
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-finalize-'));
const dbPath = path.join(tempRoot, 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE paired_tasks (
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,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE paired_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_role TEXT NOT NULL,
source_service_id TEXT NOT NULL,
source_fingerprint TEXT,
dedupe_key TEXT NOT NULL,
payload_json TEXT,
created_at TEXT NOT NULL
);
`);
legacyDb
.prepare(
`
INSERT INTO paired_tasks (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'paired-finalized',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'autonomous',
'low',
'approved',
'2026-03-29T00:00:00.000Z',
null,
'done',
'2026-03-29T00:00:00.000Z',
'**DONE**',
'merge_ready',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
legacyDb
.prepare(
`
INSERT INTO paired_events (
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'paired-finalized',
'request_review',
'owner',
'codex-main',
'fingerprint-v1',
'msg-review-legacy',
null,
'2026-03-29T00:00:00.000Z',
);
legacyDb.close();
_initTestDatabaseFromFile(dbPath);
expect(
getPairedTaskById('paired-finalized')?.last_finalized_checkpoint ?? null,
).toBe(null);
applyPairedEvent({
event: {
task_id: 'paired-finalized',
event_type: 'deploy_complete',
actor_role: 'owner',
source_service_id: 'codex-main',
source_fingerprint: 'canonical-v1',
dedupe_key: 'deploy-complete:canonical-v1',
payload_json: '{"checkpoint":"canonical-v1"}',
created_at: '2026-03-29T00:01:00.000Z',
},
onApply: () => {
updatePairedTask('paired-finalized', {
status: 'merged',
last_finalized_checkpoint: 'canonical-v1',
updated_at: '2026-03-29T00:01:00.000Z',
});
},
});
expect(getPairedTaskById('paired-finalized')?.status).toBe('merged');
expect(
getPairedTaskById('paired-finalized')?.last_finalized_checkpoint,
).toBe('canonical-v1');
expect(
getPairedEventByDedupeKey({
taskId: 'paired-finalized',
eventType: 'deploy_complete',
dedupeKey: 'deploy-complete:canonical-v1',
})?.source_fingerprint,
).toBe('canonical-v1');
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('updates task and execution state and keeps one workspace per role', () => {
it('updates task state and keeps one workspace per role', () => {
createPairedTask({
id: 'paired-task-2',
chat_jid: 'dc:paired',
@@ -1178,43 +594,18 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
task_policy: 'user_signoff_required',
risk_level: 'high',
plan_status: 'pending',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
createPairedExecution({
id: 'paired-exec-2',
task_id: 'paired-task-2',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: null,
status: 'pending',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: null,
completed_at: null,
});
updatePairedTask('paired-task-2', {
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
gate_turn_kind: 'implementation_start',
reviewer_verdict: 'done_with_concerns',
reviewer_verdict_at: '2026-03-28T00:09:00.000Z',
reviewer_verdict_note: '**DONE_WITH_CONCERNS** okay to proceed',
status: 'review_pending',
status: 'review_ready',
review_requested_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:10:00.000Z',
});
updatePairedExecution('paired-exec-2', {
status: 'running',
started_at: '2026-03-28T00:11:00.000Z',
});
expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe(
'implementation_start',
@@ -1229,7 +620,7 @@ describe('paired task state', () => {
role: 'reviewer',
workspace_dir: '/tmp/reviewer-v1',
snapshot_source_dir: '/tmp/owner',
snapshot_source_fingerprint: 'fingerprint-v1',
snapshot_ref: 'fingerprint-v1',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
created_at: '2026-03-28T00:10:00.000Z',
@@ -1241,89 +632,20 @@ describe('paired task state', () => {
role: 'reviewer',
workspace_dir: '/tmp/reviewer-v2',
snapshot_source_dir: '/tmp/owner',
snapshot_source_fingerprint: 'fingerprint-v2',
snapshot_ref: 'fingerprint-v2',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
created_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:12:00.000Z',
});
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
expect(getPairedTaskById('paired-task-2')?.task_policy).toBe('autonomous');
expect(getPairedTaskById('paired-task-2')?.risk_level).toBe('low');
expect(getPairedTaskById('paired-task-2')?.plan_status).toBe('approved');
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
expect(
listPairedWorkspacesForTask('paired-task-2').map(
(workspace) => workspace.workspace_dir,
),
).toEqual(['/tmp/reviewer-v2']);
});
it('persists execution checkpoints and cancels only superseded running executions', () => {
createPairedTask({
id: 'paired-task-3',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: null,
status: 'active',
created_at: '2026-03-29T01:00:00.000Z',
updated_at: '2026-03-29T01:00:00.000Z',
});
createPairedExecution({
id: 'paired-exec-3a',
task_id: 'paired-task-3',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: 'paired-task-3:reviewer',
checkpoint_fingerprint: 'fingerprint-v1',
status: 'running',
summary: null,
created_at: '2026-03-29T01:01:00.000Z',
started_at: '2026-03-29T01:01:00.000Z',
completed_at: null,
});
createPairedExecution({
id: 'paired-exec-3b',
task_id: 'paired-task-3',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: 'paired-task-3:reviewer',
checkpoint_fingerprint: 'fingerprint-v2',
status: 'running',
summary: null,
created_at: '2026-03-29T01:02:00.000Z',
started_at: '2026-03-29T01:02:00.000Z',
completed_at: null,
});
const cancelled = cancelSupersededPairedExecutions({
taskId: 'paired-task-3',
role: 'reviewer',
exceptExecutionId: 'paired-exec-3b',
note: 'Superseded by a newer review checkpoint.',
});
expect(cancelled).toBe(1);
expect(getPairedExecutionById('paired-exec-3a')).toMatchObject({
status: 'cancelled',
checkpoint_fingerprint: 'fingerprint-v1',
});
expect(getPairedExecutionById('paired-exec-3b')).toMatchObject({
status: 'running',
checkpoint_fingerprint: 'fingerprint-v2',
});
expect(
listPairedExecutionsForTask('paired-task-3').map((execution) => execution.id),
).toEqual(['paired-exec-3a', 'paired-exec-3b']);
});
});
// --- LIMIT behavior ---

774
src/db.ts
View File

@@ -25,10 +25,6 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js';
import {
NewMessage,
AgentType,
PairedApproval,
PairedArtifact,
PairedEvent,
PairedExecution,
PairedProject,
PairedTask,
PairedWorkspace,
@@ -241,10 +237,8 @@ function createSchema(database: Database.Database): void {
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
workspace_topology TEXT NOT NULL DEFAULT 'shadow-snapshot',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (workspace_topology IN ('shadow-snapshot', 'reviewer-cow'))
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
@@ -254,9 +248,7 @@ function createSchema(database: Database.Database): void {
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
plan_notes TEXT,
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
@@ -266,137 +258,26 @@ function createSchema(database: Database.Database): void {
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
CHECK (risk_level IN ('low', 'high')),
CHECK (
plan_status IN (
'not_requested',
'pending',
'approved',
'changes_requested'
)
),
CHECK (
gate_turn_kind IS NULL OR
gate_turn_kind IN ('implementation_start', 'commit', 'push')
),
CHECK (
reviewer_verdict IS NULL OR
reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent')
),
CHECK (
status IN (
'active',
'draft',
'plan_review_pending',
'review_pending',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged',
'discarded',
'failed'
)
)
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
);
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
CREATE TABLE IF NOT EXISTS paired_executions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
service_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_id TEXT,
checkpoint_fingerprint TEXT,
status TEXT NOT NULL DEFAULT 'pending',
summary TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_paired_executions_task
ON paired_executions(task_id, created_at);
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
snapshot_source_fingerprint TEXT,
status TEXT NOT NULL DEFAULT 'provisioning',
snapshot_ref TEXT,
status TEXT NOT NULL DEFAULT 'ready',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('provisioning', 'ready', 'stale', 'failed', 'archived'))
CHECK (status IN ('ready', 'stale'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
CREATE TABLE IF NOT EXISTS paired_approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
service_id TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('pending', 'approved', 'rejected'))
);
CREATE INDEX IF NOT EXISTS idx_paired_approvals_task
ON paired_approvals(task_id, created_at);
CREATE TABLE IF NOT EXISTS paired_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
execution_id TEXT,
service_id TEXT NOT NULL,
artifact_type TEXT NOT NULL,
title TEXT,
content TEXT,
file_path TEXT,
created_at TEXT NOT NULL,
CHECK (
artifact_type IN (
'comment',
'report',
'patch',
'plan_brief',
'acceptance_criteria',
'risk_summary'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at);
CREATE TABLE IF NOT EXISTS paired_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_role TEXT NOT NULL,
source_service_id TEXT NOT NULL,
source_fingerprint TEXT,
dedupe_key TEXT NOT NULL,
payload_json TEXT,
created_at TEXT NOT NULL,
CHECK (
event_type IN (
'set_risk',
'submit_plan',
'approve_plan',
'request_plan_changes',
'request_review',
'deploy_complete'
)
),
CHECK (actor_role IN ('owner', 'reviewer', 'system'))
);
CREATE INDEX IF NOT EXISTS idx_paired_events_task
ON paired_events(task_id, created_at, id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe
ON paired_events(task_id, event_type, dedupe_key);
CREATE TABLE IF NOT EXISTS channel_owner (
chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL,
@@ -485,22 +366,6 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
try {
database.exec(
`ALTER TABLE paired_workspaces ADD COLUMN snapshot_source_fingerprint TEXT`,
);
} catch {
/* column already exists */
}
try {
database.exec(
`ALTER TABLE paired_executions ADD COLUMN checkpoint_fingerprint TEXT`,
);
} catch {
/* column already exists */
}
database.exec(`
UPDATE scheduled_tasks
SET agent_type = COALESCE(
@@ -691,26 +556,21 @@ function createSchema(database: Database.Database): void {
);
}
// Migration: drop legacy paired tables and rebuild simplified schema.
// Old tables (paired_executions, paired_approvals, paired_artifacts, paired_events)
// are no longer used. paired_tasks is rebuilt with simplified columns.
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 || '';
const pairedTasksNeedsMigration =
pairedTasksSql &&
(!pairedTasksSql.includes("'review_pending'") ||
!pairedTasksSql.includes("'active'") ||
!pairedTasksSql.includes("'plan_review_pending'") ||
!pairedTasksSql.includes('task_policy TEXT') ||
!pairedTasksSql.includes('risk_level TEXT') ||
!pairedTasksSql.includes('plan_status TEXT') ||
!pairedTasksSql.includes('last_finalized_checkpoint TEXT') ||
!pairedTasksSql.includes('gate_turn_kind TEXT') ||
!pairedTasksSql.includes('reviewer_verdict TEXT'));
if (pairedTasksNeedsMigration) {
const pairedTasksNeedsRebuild =
pairedTasksSql && pairedTasksSql.includes('task_policy');
if (pairedTasksNeedsRebuild) {
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
database.exec(`
CREATE TABLE paired_tasks_new (
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
@@ -718,9 +578,7 @@ function createSchema(database: Database.Database): void {
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
plan_notes TEXT,
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
@@ -730,240 +588,70 @@ function createSchema(database: Database.Database): void {
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
CHECK (risk_level IN ('low', 'high')),
CHECK (
plan_status IN (
'not_requested',
'pending',
'approved',
'changes_requested'
)
),
CHECK (
gate_turn_kind IS NULL OR
gate_turn_kind IN ('implementation_start', 'commit', 'push')
),
CHECK (
reviewer_verdict IS NULL OR
reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent')
),
CHECK (
status IN (
'active',
'draft',
'plan_review_pending',
'review_pending',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged',
'discarded',
'failed'
)
)
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
);
`);
database.exec(`
INSERT INTO paired_tasks_new (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at,
last_finalized_checkpoint,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status,
created_at,
updated_at
)
SELECT
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
'autonomous',
'low',
CASE
WHEN status IN (
'review_pending',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged'
) THEN 'approved'
ELSE 'not_requested'
END,
review_requested_at,
NULL,
NULL,
NULL,
NULL,
NULL,
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);
`);
}
const pairedEventsSqlRow = database
// Drop legacy tables that are no longer used
for (const table of [
'paired_executions',
'paired_approvals',
'paired_artifacts',
'paired_events',
]) {
database.exec(`DROP TABLE IF EXISTS ${table}`);
}
// Rebuild paired_workspaces if it has old schema
const pairedWsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_events'`,
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`,
)
.get() as { sql?: string } | undefined;
const pairedEventsSql = pairedEventsSqlRow?.sql || '';
const pairedEventsNeedsMigration =
pairedEventsSql && !pairedEventsSql.includes("'deploy_complete'");
if (pairedEventsNeedsMigration) {
const pairedWsSql = pairedWsSqlRow?.sql || '';
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
database.exec(`
CREATE TABLE paired_events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
actor_role TEXT NOT NULL,
source_service_id TEXT NOT NULL,
source_fingerprint TEXT,
dedupe_key TEXT NOT NULL,
payload_json TEXT,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
snapshot_ref TEXT,
status TEXT NOT NULL DEFAULT 'ready',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
CHECK (
event_type IN (
'set_risk',
'submit_plan',
'approve_plan',
'request_plan_changes',
'request_review',
'deploy_complete'
)
),
CHECK (actor_role IN ('owner', 'reviewer', 'system'))
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('ready', 'stale'))
);
`);
database.exec(`
INSERT INTO paired_events_new (
id,
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
)
SELECT
id,
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
FROM paired_events;
`);
database.exec(`
DROP TABLE paired_events;
ALTER TABLE paired_events_new RENAME TO paired_events;
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_events_task
ON paired_events(task_id, created_at, id);
`);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe
ON paired_events(task_id, event_type, dedupe_key);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
`);
}
const pairedArtifactsSqlRow = database
// Rebuild paired_projects if it has old schema
const pairedProjSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`,
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`,
)
.get() as { sql?: string } | undefined;
const pairedArtifactsSql = pairedArtifactsSqlRow?.sql || '';
const pairedArtifactsNeedsMigration =
pairedArtifactsSql && !pairedArtifactsSql.includes("'plan_brief'");
if (pairedArtifactsNeedsMigration) {
const pairedProjSql = pairedProjSqlRow?.sql || '';
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
database.exec(`DROP TABLE IF EXISTS paired_projects`);
database.exec(`
CREATE TABLE paired_artifacts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
execution_id TEXT,
service_id TEXT NOT NULL,
artifact_type TEXT NOT NULL,
title TEXT,
content TEXT,
file_path TEXT,
CREATE TABLE IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
CHECK (
artifact_type IN (
'comment',
'report',
'patch',
'plan_brief',
'acceptance_criteria',
'risk_summary'
)
)
updated_at TEXT NOT NULL
);
`);
database.exec(`
INSERT INTO paired_artifacts_new (
id,
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
)
SELECT
id,
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
FROM paired_artifacts;
`);
database.exec(`
DROP TABLE paired_artifacts;
ALTER TABLE paired_artifacts_new RENAME TO paired_artifacts;
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at);
`);
}
// Migrate sessions table to composite PK (group_folder, agent_type)
@@ -2111,22 +1799,19 @@ export function upsertPairedProject(project: PairedProject): void {
chat_jid,
group_folder,
canonical_work_dir,
workspace_topology,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(chat_jid) DO UPDATE SET
group_folder = excluded.group_folder,
canonical_work_dir = excluded.canonical_work_dir,
workspace_topology = excluded.workspace_topology,
updated_at = excluded.updated_at
`,
).run(
project.chat_jid,
project.group_folder,
project.canonical_work_dir,
project.workspace_topology,
project.created_at,
project.updated_at,
);
@@ -2149,9 +1834,7 @@ export function createPairedTask(task: PairedTask): void {
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
plan_notes,
review_requested_at,
last_finalized_checkpoint,
gate_turn_kind,
@@ -2162,7 +1845,7 @@ export function createPairedTask(task: PairedTask): void {
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
@@ -2172,9 +1855,7 @@ export function createPairedTask(task: PairedTask): void {
task.reviewer_service_id,
task.title,
task.source_ref,
task.task_policy,
task.risk_level,
task.plan_status,
task.plan_notes,
task.review_requested_at,
task.last_finalized_checkpoint ?? null,
task.gate_turn_kind ?? null,
@@ -2218,7 +1899,7 @@ export function getLatestOpenPairedTaskForChat(
SELECT *
FROM paired_tasks
WHERE chat_jid = ?
AND status NOT IN ('merged', 'discarded', 'failed')
AND status NOT IN ('completed')
ORDER BY updated_at DESC
LIMIT 1
`,
@@ -2233,9 +1914,7 @@ export function updatePairedTask(
PairedTask,
| 'title'
| 'source_ref'
| 'task_policy'
| 'risk_level'
| 'plan_status'
| 'plan_notes'
| 'review_requested_at'
| 'last_finalized_checkpoint'
| 'gate_turn_kind'
@@ -2258,17 +1937,9 @@ export function updatePairedTask(
fields.push('source_ref = ?');
values.push(updates.source_ref);
}
if (updates.task_policy !== undefined) {
fields.push('task_policy = ?');
values.push(updates.task_policy);
}
if (updates.risk_level !== undefined) {
fields.push('risk_level = ?');
values.push(updates.risk_level);
}
if (updates.plan_status !== undefined) {
fields.push('plan_status = ?');
values.push(updates.plan_status);
if (updates.plan_notes !== undefined) {
fields.push('plan_notes = ?');
values.push(updates.plan_notes);
}
if (updates.review_requested_at !== undefined) {
fields.push('review_requested_at = ?');
@@ -2311,97 +1982,6 @@ export function updatePairedTask(
);
}
export function createPairedExecution(execution: PairedExecution): void {
db.prepare(
`
INSERT INTO paired_executions (
id,
task_id,
service_id,
role,
workspace_id,
checkpoint_fingerprint,
status,
summary,
created_at,
started_at,
completed_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
execution.id,
execution.task_id,
execution.service_id,
execution.role,
execution.workspace_id,
execution.checkpoint_fingerprint ?? null,
execution.status,
execution.summary,
execution.created_at,
execution.started_at,
execution.completed_at,
);
}
export function getPairedExecutionById(
id: string,
): PairedExecution | undefined {
return db.prepare('SELECT * FROM paired_executions WHERE id = ?').get(id) as
| PairedExecution
| undefined;
}
export function updatePairedExecution(
id: string,
updates: Partial<
Pick<
PairedExecution,
| 'workspace_id'
| 'checkpoint_fingerprint'
| 'status'
| 'summary'
| 'started_at'
| 'completed_at'
>
>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.workspace_id !== undefined) {
fields.push('workspace_id = ?');
values.push(updates.workspace_id);
}
if (updates.checkpoint_fingerprint !== undefined) {
fields.push('checkpoint_fingerprint = ?');
values.push(updates.checkpoint_fingerprint);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
}
if (updates.summary !== undefined) {
fields.push('summary = ?');
values.push(updates.summary);
}
if (updates.started_at !== undefined) {
fields.push('started_at = ?');
values.push(updates.started_at);
}
if (updates.completed_at !== undefined) {
fields.push('completed_at = ?');
values.push(updates.completed_at);
}
if (fields.length === 0) return;
values.push(id);
db.prepare(
`UPDATE paired_executions SET ${fields.join(', ')} WHERE id = ?`,
).run(...values);
}
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
db.prepare(
`
@@ -2411,7 +1991,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
role,
workspace_dir,
snapshot_source_dir,
snapshot_source_fingerprint,
snapshot_ref,
status,
snapshot_refreshed_at,
created_at,
@@ -2421,7 +2001,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
ON CONFLICT(id) DO UPDATE SET
workspace_dir = excluded.workspace_dir,
snapshot_source_dir = excluded.snapshot_source_dir,
snapshot_source_fingerprint = excluded.snapshot_source_fingerprint,
snapshot_ref = excluded.snapshot_ref,
status = excluded.status,
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
updated_at = excluded.updated_at
@@ -2432,7 +2012,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
workspace.role,
workspace.workspace_dir,
workspace.snapshot_source_dir,
workspace.snapshot_source_fingerprint,
workspace.snapshot_ref,
workspace.status,
workspace.snapshot_refreshed_at,
workspace.created_at,
@@ -2457,224 +2037,6 @@ export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
.all(taskId) as PairedWorkspace[];
}
export function listPairedExecutionsForTask(taskId: string): PairedExecution[] {
return db
.prepare(
'SELECT * FROM paired_executions WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedExecution[];
}
export function cancelSupersededPairedExecutions(args: {
taskId: string;
role: PairedExecution['role'];
exceptExecutionId?: string;
note?: string | null;
}): number {
const now = new Date().toISOString();
const result = db
.prepare(
`
UPDATE paired_executions
SET status = 'cancelled',
summary = COALESCE(summary, ?),
completed_at = COALESCE(completed_at, ?)
WHERE task_id = ?
AND role = ?
AND status = 'running'
AND (? IS NULL OR id <> ?)
`,
)
.run(
args.note ?? null,
now,
args.taskId,
args.role,
args.exceptExecutionId ?? null,
args.exceptExecutionId ?? null,
);
return result.changes;
}
export function createPairedApproval(
approval: Omit<PairedApproval, 'id'>,
): number {
const result = db
.prepare(
`
INSERT INTO paired_approvals (
task_id,
service_id,
role,
status,
note,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(
approval.task_id,
approval.service_id,
approval.role,
approval.status,
approval.note,
approval.created_at,
);
return Number(result.lastInsertRowid);
}
export function listPairedApprovalsForTask(taskId: string): PairedApproval[] {
return db
.prepare(
'SELECT * FROM paired_approvals WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedApproval[];
}
export function createPairedArtifact(
artifact: Omit<PairedArtifact, 'id'>,
): number {
const result = db
.prepare(
`
INSERT INTO paired_artifacts (
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
artifact.task_id,
artifact.execution_id,
artifact.service_id,
artifact.artifact_type,
artifact.title,
artifact.content,
artifact.file_path,
artifact.created_at,
);
return Number(result.lastInsertRowid);
}
export function listPairedArtifactsForTask(taskId: string): PairedArtifact[] {
return db
.prepare(
'SELECT * FROM paired_artifacts WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedArtifact[];
}
export function getPairedEventById(id: number): PairedEvent | undefined {
return db.prepare('SELECT * FROM paired_events WHERE id = ?').get(id) as
| PairedEvent
| undefined;
}
export function getPairedEventByDedupeKey(args: {
taskId: string;
eventType: PairedEvent['event_type'];
dedupeKey: string;
}): PairedEvent | undefined {
return db
.prepare(
`
SELECT *
FROM paired_events
WHERE task_id = ?
AND event_type = ?
AND dedupe_key = ?
`,
)
.get(args.taskId, args.eventType, args.dedupeKey) as
| PairedEvent
| undefined;
}
export function listPairedEventsForTask(taskId: string): PairedEvent[] {
return db
.prepare(
'SELECT * FROM paired_events WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedEvent[];
}
export function applyPairedEvent<T>(args: {
event: Omit<PairedEvent, 'id'>;
onApply?: () => T;
}): {
applied: boolean;
event: PairedEvent;
result: T | null;
} {
return db.transaction(() => {
const insertResult = db
.prepare(
`
INSERT OR IGNORE INTO paired_events (
task_id,
event_type,
actor_role,
source_service_id,
source_fingerprint,
dedupe_key,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
args.event.task_id,
args.event.event_type,
args.event.actor_role,
args.event.source_service_id,
args.event.source_fingerprint,
args.event.dedupe_key,
args.event.payload_json,
args.event.created_at,
);
if (insertResult.changes === 0) {
const existing = getPairedEventByDedupeKey({
taskId: args.event.task_id,
eventType: args.event.event_type,
dedupeKey: args.event.dedupe_key,
});
if (!existing) {
throw new Error(
`Paired event dedupe lookup failed for ${args.event.task_id}:${args.event.event_type}:${args.event.dedupe_key}`,
);
}
return {
applied: false,
event: existing,
result: null as T | null,
};
}
const inserted = getPairedEventById(Number(insertResult.lastInsertRowid));
if (!inserted) {
throw new Error(
`Paired event insert lookup failed for row ${insertResult.lastInsertRowid}`,
);
}
return {
applied: true,
event: inserted,
result: args.onApply ? args.onApply() : (null as T | null),
};
})();
}
/**
* Get the most recent bot message (is_bot_message=1) in a chat, regardless of which bot sent it.
* Used for duplicate detection in pair rooms.

View File

@@ -660,33 +660,19 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
execution: {
id: 'run-room-role:claude',
task_id: 'paired-task-1',
service_id: 'claude',
role: 'owner',
workspace_id: 'paired-task-1:owner',
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
},
workspace: {
id: 'paired-task-1:owner',
task_id: 'paired-task-1',
role: 'owner',
workspace_dir: '/tmp/paired/owner',
snapshot_source_dir: null,
snapshot_source_fingerprint: null,
snapshot_ref: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
@@ -695,7 +681,6 @@ describe('runAgentForGroup room memory', () => {
envOverrides: {
EJCLAW_WORK_DIR: '/tmp/paired/owner',
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
EJCLAW_PAIRED_ROLE: 'owner',
},
});
@@ -717,19 +702,16 @@ describe('runAgentForGroup room memory', () => {
expect.objectContaining({
EJCLAW_WORK_DIR: '/tmp/paired/owner',
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
EJCLAW_PAIRED_ROLE: 'owner',
}),
);
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
executionId: 'run-room-role:claude',
status: 'succeeded',
summary: 'ok',
}),
);
).toHaveBeenCalledWith({
taskId: 'paired-task-1',
status: 'succeeded',
summary: 'ok',
});
});
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
@@ -759,30 +741,15 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
execution: {
id: 'run-blocked-reviewer:claude',
task_id: 'paired-task-1',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_REVIEWER_RUNTIME: '1',
},
@@ -817,7 +784,7 @@ describe('runAgentForGroup room memory', () => {
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({
executionId: 'run-blocked-reviewer:claude',
taskId: 'paired-task-1',
status: 'failed',
summary:
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',

View File

@@ -240,7 +240,7 @@ export async function runAgentForGroup(
phase: 'final',
});
completePairedExecutionContext({
executionId: pairedExecutionContext.execution.id,
taskId: pairedExecutionContext.task.id,
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});
@@ -940,7 +940,7 @@ export async function runAgentForGroup(
} finally {
if (pairedExecutionContext) {
completePairedExecutionContext({
executionId: pairedExecutionContext.execution.id,
taskId: pairedExecutionContext.task.id,
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
reviewerVerdict: pairedReviewerVerdict,

View File

@@ -38,12 +38,6 @@ vi.mock('./paired-execution-context.js', () => ({
completePairedExecutionContext: vi.fn(),
markRoomReviewReady: vi.fn(() => null),
formatRoomReviewReadyMessage: vi.fn(() => null),
planPairedExecutionRecovery: vi.fn(() => null),
finalizeRoomDeployment: vi.fn(() => null),
setRoomTaskRiskLevel: vi.fn(() => null),
recordRoomPlan: vi.fn(() => null),
approveRoomPlan: vi.fn(() => null),
requestRoomPlanChanges: vi.fn(() => null),
}));
vi.mock('./db.js', () => {
@@ -271,11 +265,9 @@ describe('createMessageRuntime', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
status: 'review_ready',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
@@ -356,7 +348,7 @@ describe('createMessageRuntime', () => {
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
status: 'blocked',
status: 'pending',
task: {
id: 'paired-task-1',
chat_jid: chatJid,
@@ -365,15 +357,13 @@ describe('createMessageRuntime', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
plan_notes: null,
review_requested_at: null,
status: 'plan_review_pending',
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
blockedReason: 'plan-review-required',
pendingReason: 'owner-workspace-not-ready',
});
vi.mocked(
pairedExecutionContext.formatRoomReviewReadyMessage,
@@ -410,228 +400,6 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage);
});
it('surfaces the deploy-complete message through the message-runtime path', async () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
const deployMessage = [
'Deployment finalized.',
'- Task: paired-task-1',
'- Status: merged',
'- Checkpoint: abc123',
].join('\n');
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-deploy',
chat_jid: chatJid,
sender: 'me@test',
sender_name: 'Me',
content: '/deploy-complete',
timestamp: '2026-03-29T00:00:00.000Z',
is_from_me: true,
},
]);
const actualSessionCommands = await vi.importActual<
typeof import('./session-commands.js')
>('./session-commands.js');
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.finalizeRoomDeployment).mockReturnValue(
deployMessage,
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-deploy-complete',
reason: 'messages',
});
expect(result).toBe(true);
expect(sessionCommands.handleSessionCommand).toHaveBeenCalled();
expect(pairedExecutionContext.finalizeRoomDeployment).toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, deployMessage);
});
it('routes /approve-plan through reviewer context in the message-runtime path', async () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-approve-plan',
chat_jid: chatJid,
sender: 'me@test',
sender_name: 'Me',
content: '/approve-plan',
timestamp: '2026-03-29T00:00:00.000Z',
is_from_me: true,
},
]);
const actualSessionCommands = await vi.importActual<
typeof import('./session-commands.js')
>('./session-commands.js');
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.approveRoomPlan).mockReturnValue(
'Plan approved.\n- Task: paired-task-1',
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-approve-plan',
reason: 'messages',
});
expect(result).toBe(true);
expect(pairedExecutionContext.approveRoomPlan).toHaveBeenCalledWith(
expect.objectContaining({
chatJid,
roomRoleContext: expect.objectContaining({
role: 'reviewer',
serviceId: 'codex-main',
}),
}),
);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'Plan approved.\n- Task: paired-task-1',
);
});
it('routes /request-plan-changes through reviewer context in the message-runtime path', async () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-request-plan-changes',
chat_jid: chatJid,
sender: 'me@test',
sender_name: 'Me',
content: '/request-plan-changes tighten scope',
timestamp: '2026-03-29T00:00:00.000Z',
is_from_me: true,
},
]);
const actualSessionCommands = await vi.importActual<
typeof import('./session-commands.js')
>('./session-commands.js');
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.requestRoomPlanChanges).mockReturnValue(
'Plan changes requested.\n- Task: paired-task-1',
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-request-plan-changes',
reason: 'messages',
});
expect(result).toBe(true);
expect(pairedExecutionContext.requestRoomPlanChanges).toHaveBeenCalledWith(
expect.objectContaining({
chatJid,
roomRoleContext: expect.objectContaining({
role: 'reviewer',
serviceId: 'codex-main',
}),
note: 'tighten scope',
}),
);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'Plan changes requested.\n- Task: paired-task-1',
);
});
it('ignores generic failure bot messages in paired rooms', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');

View File

@@ -42,14 +42,8 @@ import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js';
import {
approveRoomPlan,
finalizeRoomDeployment,
formatRoomReviewReadyMessage,
markRoomReviewReady,
planPairedExecutionRecovery,
recordRoomPlan,
requestRoomPlanChanges,
setRoomTaskRiskLevel,
} from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js';
import {
@@ -574,7 +568,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
// Session commands are explicit control events, so owner-only and
// reviewer-only commands resolve against the target role rather than
// whichever service happened to pick up the slash command first.
markReviewReady: async (dedupeKey) => {
markReviewReady: async () => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
@@ -584,77 +578,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
group,
chatJid,
roomRoleContext,
dedupeKey,
});
return formatRoomReviewReadyMessage(result);
},
finalizeDeployment: async () => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.owner_service_id,
);
return finalizeRoomDeployment({
group,
chatJid,
roomRoleContext,
});
},
setTaskRiskLevel: async (riskLevel, dedupeKey) => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.owner_service_id,
);
return setRoomTaskRiskLevel({
group,
chatJid,
roomRoleContext,
riskLevel,
dedupeKey,
});
},
recordPlan: async (plan, dedupeKey) => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.owner_service_id,
);
return recordRoomPlan({
group,
chatJid,
roomRoleContext,
dedupeKey,
...plan,
});
},
approvePlan: async (dedupeKey) => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.reviewer_service_id ?? lease.owner_service_id,
);
return approveRoomPlan({
group,
chatJid,
roomRoleContext,
dedupeKey,
});
},
requestPlanChanges: async (note, dedupeKey) => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.reviewer_service_id ?? lease.owner_service_id,
);
return requestRoomPlanChanges({
group,
chatJid,
roomRoleContext,
note,
dedupeKey,
});
},
},
});
if (cmdResult.handled) return cmdResult.success;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -95,24 +95,13 @@ describe('paired /review command path', () => {
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
dedupeKey: 'msg-review-owner-1',
});
const task = db.getLatestOpenPairedTaskForChat('dc:test');
const events = db.listPairedEventsForTask(task!.id);
expect(result?.status).toBe('ready');
expect(events).toHaveLength(1);
expect(events[0]?.event_type).toBe('request_review');
expect(events[0]?.dedupe_key).toBe('msg-review-owner-1');
expect(events[0]?.source_fingerprint).toBeTruthy();
expect(events[0]?.source_fingerprint).not.toBe(task?.source_ref);
if (!result || result.status !== 'ready') {
throw new Error('expected owner /review to prepare a ready snapshot');
}
expect(events[0]?.source_fingerprint).toBe(
result.reviewerWorkspace.snapshot_source_fingerprint,
);
expect(result.reviewerWorkspace.snapshot_ref).toBeTruthy();
});
it('reuses the db owner workspace when reviewer service handles /review', async () => {
@@ -240,134 +229,12 @@ describe('paired /review command path', () => {
status: 'pending',
task: expect.objectContaining({
id: task!.id,
status: 'review_pending',
status: 'review_ready',
}),
pendingReason: 'owner-workspace-not-ready',
});
expect(task?.status).toBe('review_pending');
expect(task?.status).toBe('review_ready');
expect(task?.review_requested_at).toBeTruthy();
expect(db.listPairedEventsForTask(task!.id)).toEqual([]);
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
});
it('creates the first checkpointed request_review event only after owner workspace exists', 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-review');
process.env.SERVICE_ID = 'codex-review';
vi.resetModules();
let { db, executionContext } = await loadModules();
db.initDatabase();
const firstResult = executionContext.markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: reviewerContext,
dedupeKey: 'msg-review-pending-1',
});
const task = db.getLatestOpenPairedTaskForChat('dc:test');
expect(firstResult?.status).toBe('pending');
expect(db.listPairedEventsForTask(task!.id)).toEqual([]);
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
process.env.SERVICE_ID = 'codex-main';
vi.resetModules();
({ db, executionContext } = await loadModules());
db.initDatabase();
executionContext.preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-owner',
roomRoleContext: ownerContext,
});
const secondResult = executionContext.markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
dedupeKey: 'msg-review-owner-2',
});
const events = db.listPairedEventsForTask(task!.id);
expect(secondResult?.status).toBe('ready');
expect(events).toHaveLength(1);
expect(events[0]?.event_type).toBe('request_review');
expect(events[0]?.dedupe_key).toBe('msg-review-owner-2');
});
it('blocks /review when a high-risk task plan is not approved yet', 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();
const { db, executionContext } = await loadModules();
db.initDatabase();
const task = executionContext.preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-owner',
roomRoleContext: ownerContext,
})?.task;
expect(task).toBeTruthy();
db.updatePairedTask(task!.id, {
risk_level: 'high',
plan_status: 'pending',
status: 'plan_review_pending',
updated_at: '2026-03-29T00:00:00.000Z',
});
const result = executionContext.markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(result).toEqual({
status: 'blocked',
task: expect.objectContaining({
id: task!.id,
risk_level: 'high',
plan_status: 'pending',
status: 'plan_review_pending',
}),
blockedReason: 'plan-review-required',
});
});
});

View File

@@ -63,8 +63,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -75,11 +74,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'review the owner changes',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -133,7 +130,7 @@ describe('paired workspace manager', () => {
).toBeTruthy();
expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')
?.snapshot_source_fingerprint,
?.snapshot_ref,
).toBeTruthy();
});
@@ -155,8 +152,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -167,11 +163,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -210,8 +204,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -222,11 +215,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'cross service review',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -288,8 +279,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -300,11 +290,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -365,8 +353,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -377,11 +364,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -508,8 +493,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -520,11 +504,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -572,8 +554,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -584,11 +565,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -630,8 +609,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -642,11 +620,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'pending',
plan_notes: null,
review_requested_at: '2026-03-28T00:01:00.000Z',
status: 'review_pending',
status: 'review_ready',
created_at: now,
updated_at: '2026-03-28T00:01:00.000Z',
});
@@ -694,8 +670,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -706,11 +681,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -760,8 +733,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -772,11 +744,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
plan_notes: null,
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
});
@@ -851,8 +821,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
created_at: now,
updated_at: now,
});
db.createPairedTask({
@@ -863,11 +832,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
plan_notes: null,
review_requested_at: null,
status: 'plan_review_pending',
status: 'active',
created_at: now,
updated_at: now,
});

View File

@@ -18,9 +18,6 @@ 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.';
export const PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE =
'Plan review is required before formal review for this high-risk task.';
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
'.git',
'.claude',
@@ -325,9 +322,9 @@ function makeWorkspaceRecord(args: {
workspace_dir: args.workspaceDir,
snapshot_source_dir:
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
snapshot_source_fingerprint:
snapshot_ref:
args.snapshotSourceFingerprint ??
existing?.snapshot_source_fingerprint ??
existing?.snapshot_ref ??
null,
status: args.status ?? 'ready',
snapshot_refreshed_at:
@@ -489,7 +486,6 @@ export function markPairedTaskReviewReady(taskId: string): {
} | null {
const requestedAt = new Date().toISOString();
updatePairedTask(taskId, {
status: 'review_pending',
review_requested_at: requestedAt,
updated_at: requestedAt,
});
@@ -518,14 +514,6 @@ export interface PreparedReviewerWorkspace {
export function prepareReviewerWorkspaceForExecution(
task: PairedTask,
): PreparedReviewerWorkspace {
if (task.risk_level === 'high' && task.plan_status !== 'approved') {
return {
workspace: null,
autoRefreshed: false,
blockMessage: PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE,
};
}
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
if (!ownerWorkspace) {
return {
@@ -555,11 +543,11 @@ export function prepareReviewerWorkspaceForExecution(
const snapshotStale =
!!reviewerWorkspace &&
(reviewerWorkspace.status === 'stale' ||
reviewerWorkspace.snapshot_source_fingerprint !== currentFingerprint);
reviewerWorkspace.snapshot_ref !== currentFingerprint);
const now = new Date().toISOString();
if (snapshotMissing || snapshotStale) {
if (task.status === 'review_pending' || task.status === 'review_ready') {
if (task.status === 'review_ready') {
const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id);
updatePairedTask(task.id, {
status: 'review_ready',
@@ -587,7 +575,7 @@ export function prepareReviewerWorkspaceForExecution(
workspaceDir: reviewerWorkspace.workspace_dir,
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
snapshotSourceFingerprint:
reviewerWorkspace.snapshot_source_fingerprint,
reviewerWorkspace.snapshot_ref,
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
status: 'stale',
createdAt: reviewerWorkspace.created_at,
@@ -595,7 +583,6 @@ export function prepareReviewerWorkspaceForExecution(
);
}
updatePairedTask(task.id, {
status: 'review_pending',
updated_at: now,
});
return {

View File

@@ -37,28 +37,6 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
});
it('detects /risk with an argument payload', () => {
expect(extractSessionCommand('/risk high', trigger)).toBe('/risk');
});
it('detects /plan with structured payload', () => {
expect(
extractSessionCommand('/plan brief || criteria || risk', trigger),
).toBe('/plan');
});
it('detects /approve-plan', () => {
expect(extractSessionCommand('/approve-plan', trigger)).toBe(
'/approve-plan',
);
});
it('detects /request-plan-changes with an optional note', () => {
expect(
extractSessionCommand('/request-plan-changes tighten scope', trigger),
).toBe('/request-plan-changes');
});
it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
@@ -198,11 +176,6 @@ function makeDeps(
isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
finalizeDeployment: vi.fn().mockResolvedValue('Deployment finalized.'),
setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'),
recordPlan: vi.fn().mockResolvedValue('Plan recorded.'),
approvePlan: vi.fn().mockResolvedValue('Plan approved.'),
requestPlanChanges: vi.fn().mockResolvedValue('Plan changes requested.'),
...overrides,
};
}
@@ -361,127 +334,6 @@ describe('handleSessionCommand', () => {
);
});
it('handles authorized /risk without invoking the agent', async () => {
const deps = makeDeps({
setTaskRiskLevel: vi
.fn()
.mockResolvedValue('Task risk updated.\n- Task: paired-task-1'),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/risk high')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.setTaskRiskLevel).toHaveBeenCalledWith('high', 'msg-1');
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith(
'Task risk updated.\n- Task: paired-task-1',
);
});
it('handles authorized /plan without invoking the agent', async () => {
const deps = makeDeps({
recordPlan: vi
.fn()
.mockResolvedValue('Plan recorded.\n- Task: paired-task-1'),
});
const result = await handleSessionCommand({
missedMessages: [
makeMsg('/plan brief || acceptance criteria || rollout risk'),
],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.recordPlan).toHaveBeenCalledWith({
planBrief: 'brief',
acceptanceCriteria: 'acceptance criteria',
riskSummary: 'rollout risk',
}, 'msg-1');
expect(deps.sendMessage).toHaveBeenCalledWith(
'Plan recorded.\n- Task: paired-task-1',
);
});
it('sends /plan usage when structured payload is incomplete', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/plan only brief')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.recordPlan).not.toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith(
'Usage: /plan <plan brief> || <acceptance criteria> || <risk summary>',
);
});
it('handles /approve-plan without invoking the agent', async () => {
const deps = makeDeps({
approvePlan: vi
.fn()
.mockResolvedValue('Plan approved.\n- Task: paired-task-1'),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/approve-plan')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.approvePlan).toHaveBeenCalledWith('msg-1');
expect(deps.sendMessage).toHaveBeenCalledWith(
'Plan approved.\n- Task: paired-task-1',
);
});
it('handles /request-plan-changes without invoking the agent', async () => {
const deps = makeDeps({
requestPlanChanges: vi
.fn()
.mockResolvedValue('Plan changes requested.\n- Task: paired-task-1'),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/request-plan-changes tighten scope')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.requestPlanChanges).toHaveBeenCalledWith(
'tighten scope',
'msg-1',
);
expect(deps.sendMessage).toHaveBeenCalledWith(
'Plan changes requested.\n- Task: paired-task-1',
);
});
it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({

View File

@@ -12,20 +12,6 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Conversation compacted\.$/,
/^Review snapshot updated\.(?:\n|$)/,
/^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/,
/^Plan review is required before formal review for this high-risk task\.(?:\n|$)/,
/^Task risk updated\.(?:\n|$)/,
/^Plan recorded\.(?:\n|$)/,
/^Plan approved\.(?:\n|$)/,
/^Plan changes requested\.(?:\n|$)/,
/^Risk updates must be handled by the owner service\.$/,
/^Plan recording must be handled by the owner service\.$/,
/^Plan approval must be handled by the reviewer service\.$/,
/^Plan review commands are only required for high-risk tasks\.$/,
/^Plan artifacts are incomplete\.(?:\n|$)/,
/^Deployment finalized\.(?:\n|$)/,
/^Deployment finalization must be handled by the owner service\.$/,
/^Deploy completion requires a merge-ready task or the same already-finalized checkpoint\.(?:\n|$)/,
/^Deploy completion requires a canonical workDir with a readable HEAD\.$/,
/^Review is unavailable for this room\./,
];
@@ -48,13 +34,6 @@ export function extractSessionCommand(
if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear';
if (text === '/review' || text === '/review-ready') return '/review';
if (text === '/deploy-complete') return '/deploy-complete';
if (/^\/risk(?:\s|$)/.test(text)) return '/risk';
if (/^\/plan(?:\s|$)/.test(text)) return '/plan';
if (text === '/approve-plan') return '/approve-plan';
if (/^\/request-plan-changes(?:\s|$)/.test(text)) {
return '/request-plan-changes';
}
return null;
}
@@ -99,22 +78,7 @@ export interface SessionCommandDeps {
isAdminSender: (msg: NewMessage) => boolean;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
markReviewReady: (dedupeKey: string) => Promise<string | null>;
setTaskRiskLevel: (
riskLevel: 'low' | 'high',
dedupeKey: string,
) => Promise<string | null>;
recordPlan: (plan: {
planBrief: string;
acceptanceCriteria: string;
riskSummary: string;
}, dedupeKey: string) => Promise<string | null>;
approvePlan: (dedupeKey: string) => Promise<string | null>;
requestPlanChanges: (
note: string | undefined,
dedupeKey: string,
) => Promise<string | null>;
finalizeDeployment: () => Promise<string | null>;
markReviewReady: () => Promise<string | null>;
}
function resultToText(result: string | object | null | undefined): string {
@@ -200,7 +164,7 @@ export async function handleSessionCommand(opts: {
}
if (command === '/review') {
const result = await deps.markReviewReady(cmdMsg.id);
const result = await deps.markReviewReady();
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
result ??
@@ -209,73 +173,6 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true };
}
if (command === '/deploy-complete') {
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
(await deps.finalizeDeployment()) ??
'Deploy finalization is unavailable for this room.',
);
return { handled: true, success: true };
}
if (command === '/risk') {
const riskArg = normalizedCommandText
.slice('/risk'.length)
.trim()
.toLowerCase();
const riskLevel =
riskArg === 'high' ? 'high' : riskArg === 'low' ? 'low' : null;
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
riskLevel
? ((await deps.setTaskRiskLevel(riskLevel, cmdMsg.id)) ??
'Risk is unavailable for this room.')
: 'Usage: /risk <low|high>',
);
return { handled: true, success: true };
}
if (command === '/plan') {
const payload = normalizedCommandText.slice('/plan'.length).trim();
const parts = payload.split('||').map((part) => part.trim());
deps.advanceCursor(cmdMsg.timestamp);
if (parts.length !== 3 || parts.some((part) => !part)) {
await deps.sendMessage(
'Usage: /plan <plan brief> || <acceptance criteria> || <risk summary>',
);
return { handled: true, success: true };
}
await deps.sendMessage(
(await deps.recordPlan({
planBrief: parts[0],
acceptanceCriteria: parts[1],
riskSummary: parts[2],
}, cmdMsg.id)) ?? 'Plan recording is unavailable for this room.',
);
return { handled: true, success: true };
}
if (command === '/approve-plan') {
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
(await deps.approvePlan(cmdMsg.id)) ??
'Plan approval is unavailable for this room.',
);
return { handled: true, success: true };
}
if (command === '/request-plan-changes') {
const note = normalizedCommandText
.slice('/request-plan-changes'.length)
.trim();
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
(await deps.requestPlanChanges(note || undefined, cmdMsg.id)) ??
'Plan change requests are unavailable for this room.',
);
return { handled: true, success: true };
}
const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex);

View File

@@ -25,74 +25,16 @@ export type AgentVisibility = 'public' | 'silent';
export type PairedRoomRole = 'owner' | 'reviewer';
export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
export type PairedTaskStatus =
| 'active'
| 'draft'
| 'plan_review_pending'
| 'review_pending'
| 'review_ready'
| 'in_review'
| 'changes_requested'
| 'merge_ready'
| 'merged'
| 'discarded'
| 'failed';
export type PairedTaskPolicy = 'autonomous' | 'user_signoff_required';
export type PairedRiskLevel = 'low' | 'high';
export type PairedPlanStatus =
| 'not_requested'
| 'pending'
| 'approved'
| 'changes_requested';
export type PairedGateTurnKind = 'implementation_start' | 'commit' | 'push';
export type PairedReviewerVerdict =
| 'done'
| 'done_with_concerns'
| 'blocked'
| 'silent';
export type PairedExecutionStatus =
| 'pending'
| 'running'
| 'succeeded'
| 'failed'
| 'cancelled';
| 'completed';
export type PairedWorkspaceRole = 'owner' | 'reviewer';
export type PairedWorkspaceStatus =
| 'provisioning'
| 'ready'
| 'stale'
| 'failed'
| 'archived';
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
export type PairedArtifactType =
| 'comment'
| 'report'
| 'patch'
| 'plan_brief'
| 'acceptance_criteria'
| 'risk_summary';
export type PairedEventActorRole = PairedRoomRole | 'system';
export type PairedEventType =
| 'set_risk'
| 'submit_plan'
| 'approve_plan'
| 'request_plan_changes'
| 'request_review'
| 'deploy_complete';
export type PairedWorkspaceStatus = 'ready' | 'stale';
export interface RoomRoleContext {
serviceId: string;
@@ -106,7 +48,6 @@ export interface PairedProject {
chat_jid: string;
group_folder: string;
canonical_work_dir: string;
workspace_topology: PairedWorkspaceTopology;
created_at: string;
updated_at: string;
}
@@ -119,9 +60,7 @@ export interface PairedTask {
reviewer_service_id: string;
title: string | null;
source_ref: string | null;
task_policy: PairedTaskPolicy;
risk_level: PairedRiskLevel;
plan_status: PairedPlanStatus;
plan_notes: string | null;
review_requested_at: string | null;
last_finalized_checkpoint?: string | null;
gate_turn_kind?: PairedGateTurnKind | null;
@@ -133,66 +72,20 @@ export interface PairedTask {
updated_at: string;
}
export interface PairedExecution {
id: string;
task_id: string;
service_id: string;
role: PairedWorkspaceRole;
workspace_id: string | null;
checkpoint_fingerprint?: string | null;
status: PairedExecutionStatus;
summary: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
}
export interface PairedWorkspace {
id: string;
task_id: string;
role: PairedWorkspaceRole;
workspace_dir: string;
snapshot_source_dir: string | null;
snapshot_source_fingerprint: string | null;
snapshot_ref: string | null;
status: PairedWorkspaceStatus;
snapshot_refreshed_at: string | null;
created_at: string;
updated_at: string;
}
export interface PairedApproval {
id: number;
task_id: string;
service_id: string;
role: PairedWorkspaceRole;
status: PairedApprovalStatus;
note: string | null;
created_at: string;
}
export interface PairedArtifact {
id: number;
task_id: string;
execution_id: string | null;
service_id: string;
artifact_type: PairedArtifactType;
title: string | null;
content: string | null;
file_path: string | null;
created_at: string;
}
export interface PairedEvent {
id: number;
task_id: string;
event_type: PairedEventType;
actor_role: PairedEventActorRole;
source_service_id: string;
source_fingerprint: string | null;
dedupe_key: string;
payload_json: string | null;
created_at: string;
}
export type AgentResponsePolicy = 'normal' | 'silent-unless-addressed';
export type StructuredAgentOutput =
| {