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:
63
container/Dockerfile
Normal file
63
container/Dockerfile
Normal 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"]
|
||||
17
container/agent-runner/package.json
Normal file
17
container/agent-runner/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
139
container/agent-runner/src/index.ts
Normal file
139
container/agent-runner/src/index.ts
Normal 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);
|
||||
});
|
||||
14
container/agent-runner/tsconfig.json
Normal file
14
container/agent-runner/tsconfig.json
Normal 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
10
container/build.sh
Executable 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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
381
src/container-runner.ts
Normal 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
103
src/container-runtime.ts
Normal 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
172
src/credential-proxy.ts
Normal 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';
|
||||
}
|
||||
702
src/db.test.ts
702
src/db.test.ts
@@ -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
774
src/db.ts
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
117
src/types.ts
117
src/types.ts
@@ -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 =
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user