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';
|
} from './agent-runner-snapshot.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
|
||||||
|
import { runReviewerContainer } from './container-runner.js';
|
||||||
import {
|
import {
|
||||||
AgentOutputPhase,
|
AgentOutputPhase,
|
||||||
AgentType,
|
AgentType,
|
||||||
@@ -67,6 +68,37 @@ export async function runAgentProcess(
|
|||||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||||
envOverrides?: Record<string, string>,
|
envOverrides?: Record<string, string>,
|
||||||
): Promise<AgentOutput> {
|
): 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 startTime = Date.now();
|
||||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||||
group,
|
group,
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ export const RECOVERY_CONCURRENT_AGENTS = parseInt(
|
|||||||
getEnv('RECOVERY_CONCURRENT_AGENTS') || '3',
|
getEnv('RECOVERY_CONCURRENT_AGENTS') || '3',
|
||||||
10,
|
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(
|
export const RECOVERY_STAGGER_MS = parseInt(
|
||||||
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
getEnv('RECOVERY_STAGGER_MS') || '2000',
|
||||||
10,
|
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 fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
_initTestDatabaseFromFile,
|
|
||||||
applyPairedEvent,
|
|
||||||
cancelSupersededPairedExecutions,
|
|
||||||
claimServiceHandoff,
|
claimServiceHandoff,
|
||||||
completeServiceHandoffAndAdvanceTargetCursor,
|
completeServiceHandoffAndAdvanceTargetCursor,
|
||||||
createPairedApproval,
|
|
||||||
createPairedArtifact,
|
|
||||||
createPairedExecution,
|
|
||||||
createPairedTask,
|
createPairedTask,
|
||||||
createTask,
|
createTask,
|
||||||
createServiceHandoff,
|
createServiceHandoff,
|
||||||
@@ -32,8 +24,6 @@ import {
|
|||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
getPairedExecutionById,
|
|
||||||
getPairedEventByDedupeKey,
|
|
||||||
getPairedProject,
|
getPairedProject,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
getPairedWorkspace,
|
getPairedWorkspace,
|
||||||
@@ -41,10 +31,6 @@ import {
|
|||||||
isPairedRoomJid,
|
isPairedRoomJid,
|
||||||
getSession,
|
getSession,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
listPairedApprovalsForTask,
|
|
||||||
listPairedArtifactsForTask,
|
|
||||||
listPairedEventsForTask,
|
|
||||||
listPairedExecutionsForTask,
|
|
||||||
listPairedWorkspacesForTask,
|
listPairedWorkspacesForTask,
|
||||||
markWorkItemDelivered,
|
markWorkItemDelivered,
|
||||||
markWorkItemDeliveryRetry,
|
markWorkItemDeliveryRetry,
|
||||||
@@ -53,7 +39,6 @@ import {
|
|||||||
setRouterStateForService,
|
setRouterStateForService,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
updatePairedExecution,
|
|
||||||
updatePairedTask,
|
updatePairedTask,
|
||||||
upsertPairedProject,
|
upsertPairedProject,
|
||||||
upsertPairedWorkspace,
|
upsertPairedWorkspace,
|
||||||
@@ -554,12 +539,11 @@ Check the run.
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('paired task state', () => {
|
describe('paired task state', () => {
|
||||||
it('stores project, task, execution, workspace, approval, and artifact state', () => {
|
it('stores project, task, and workspace state', () => {
|
||||||
upsertPairedProject({
|
upsertPairedProject({
|
||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: '/tmp/paired-room',
|
canonical_work_dir: '/tmp/paired-room',
|
||||||
workspace_topology: 'shadow-snapshot',
|
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'wire up workspaces',
|
title: 'wire up workspaces',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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({
|
upsertPairedWorkspace({
|
||||||
id: 'paired-task-1:owner',
|
id: 'paired-task-1:owner',
|
||||||
task_id: 'paired-task-1',
|
task_id: 'paired-task-1',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired-room/owner',
|
workspace_dir: '/tmp/paired-room/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
snapshot_source_fingerprint: null,
|
snapshot_ref: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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(
|
expect(getPairedProject('dc:paired')?.canonical_work_dir).toBe(
|
||||||
'/tmp/paired-room',
|
'/tmp/paired-room',
|
||||||
);
|
);
|
||||||
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
|
expect(getPairedTaskById('paired-task-1')?.status).toBe('active');
|
||||||
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(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
||||||
'/tmp/paired-room/owner',
|
'/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', () => {
|
it('updates task state and keeps one workspace per role', () => {
|
||||||
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', () => {
|
|
||||||
createPairedTask({
|
createPairedTask({
|
||||||
id: 'paired-task-2',
|
id: 'paired-task-2',
|
||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
@@ -1178,43 +594,18 @@ describe('paired task state', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: null,
|
source_ref: null,
|
||||||
task_policy: 'user_signoff_required',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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', {
|
updatePairedTask('paired-task-2', {
|
||||||
task_policy: 'autonomous',
|
status: 'review_ready',
|
||||||
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',
|
|
||||||
review_requested_at: '2026-03-28T00:10:00.000Z',
|
review_requested_at: '2026-03-28T00:10:00.000Z',
|
||||||
updated_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(
|
expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe(
|
||||||
'implementation_start',
|
'implementation_start',
|
||||||
@@ -1229,7 +620,7 @@ describe('paired task state', () => {
|
|||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
workspace_dir: '/tmp/reviewer-v1',
|
workspace_dir: '/tmp/reviewer-v1',
|
||||||
snapshot_source_dir: '/tmp/owner',
|
snapshot_source_dir: '/tmp/owner',
|
||||||
snapshot_source_fingerprint: 'fingerprint-v1',
|
snapshot_ref: 'fingerprint-v1',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
|
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
|
||||||
created_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',
|
role: 'reviewer',
|
||||||
workspace_dir: '/tmp/reviewer-v2',
|
workspace_dir: '/tmp/reviewer-v2',
|
||||||
snapshot_source_dir: '/tmp/owner',
|
snapshot_source_dir: '/tmp/owner',
|
||||||
snapshot_source_fingerprint: 'fingerprint-v2',
|
snapshot_ref: 'fingerprint-v2',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
|
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
|
||||||
created_at: '2026-03-28T00:10:00.000Z',
|
created_at: '2026-03-28T00:10:00.000Z',
|
||||||
updated_at: '2026-03-28T00:12:00.000Z',
|
updated_at: '2026-03-28T00:12:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
|
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
|
||||||
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(
|
expect(
|
||||||
listPairedWorkspacesForTask('paired-task-2').map(
|
listPairedWorkspacesForTask('paired-task-2').map(
|
||||||
(workspace) => workspace.workspace_dir,
|
(workspace) => workspace.workspace_dir,
|
||||||
),
|
),
|
||||||
).toEqual(['/tmp/reviewer-v2']);
|
).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 ---
|
// --- LIMIT behavior ---
|
||||||
|
|||||||
774
src/db.ts
774
src/db.ts
@@ -25,10 +25,6 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js';
|
|||||||
import {
|
import {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
AgentType,
|
AgentType,
|
||||||
PairedApproval,
|
|
||||||
PairedArtifact,
|
|
||||||
PairedEvent,
|
|
||||||
PairedExecution,
|
|
||||||
PairedProject,
|
PairedProject,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedWorkspace,
|
PairedWorkspace,
|
||||||
@@ -241,10 +237,8 @@ function createSchema(database: Database.Database): void {
|
|||||||
chat_jid TEXT PRIMARY KEY,
|
chat_jid TEXT PRIMARY KEY,
|
||||||
group_folder TEXT NOT NULL,
|
group_folder TEXT NOT NULL,
|
||||||
canonical_work_dir TEXT NOT NULL,
|
canonical_work_dir TEXT NOT NULL,
|
||||||
workspace_topology TEXT NOT NULL DEFAULT 'shadow-snapshot',
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL
|
||||||
CHECK (workspace_topology IN ('shadow-snapshot', 'reviewer-cow'))
|
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS paired_tasks (
|
CREATE TABLE IF NOT EXISTS paired_tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -254,9 +248,7 @@ function createSchema(database: Database.Database): void {
|
|||||||
reviewer_service_id TEXT NOT NULL,
|
reviewer_service_id TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
source_ref TEXT,
|
source_ref TEXT,
|
||||||
task_policy TEXT NOT NULL DEFAULT 'autonomous',
|
plan_notes TEXT,
|
||||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
|
||||||
plan_status TEXT NOT NULL DEFAULT 'not_requested',
|
|
||||||
review_requested_at TEXT,
|
review_requested_at TEXT,
|
||||||
last_finalized_checkpoint TEXT,
|
last_finalized_checkpoint TEXT,
|
||||||
gate_turn_kind TEXT,
|
gate_turn_kind TEXT,
|
||||||
@@ -266,137 +258,26 @@ function createSchema(database: Database.Database): void {
|
|||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
|
||||||
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'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
ON paired_tasks(chat_jid, status, updated_at);
|
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 (
|
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL,
|
task_id TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
workspace_dir TEXT NOT NULL,
|
workspace_dir TEXT NOT NULL,
|
||||||
snapshot_source_dir TEXT,
|
snapshot_source_dir TEXT,
|
||||||
snapshot_source_fingerprint TEXT,
|
snapshot_ref TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'provisioning',
|
status TEXT NOT NULL DEFAULT 'ready',
|
||||||
snapshot_refreshed_at TEXT,
|
snapshot_refreshed_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK (role IN ('owner', 'reviewer')),
|
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
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
||||||
ON paired_workspaces(task_id, 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 (
|
CREATE TABLE IF NOT EXISTS channel_owner (
|
||||||
chat_jid TEXT PRIMARY KEY,
|
chat_jid TEXT PRIMARY KEY,
|
||||||
owner_service_id TEXT NOT NULL,
|
owner_service_id TEXT NOT NULL,
|
||||||
@@ -485,22 +366,6 @@ function createSchema(database: Database.Database): void {
|
|||||||
/* column already exists */
|
/* 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(`
|
database.exec(`
|
||||||
UPDATE scheduled_tasks
|
UPDATE scheduled_tasks
|
||||||
SET agent_type = COALESCE(
|
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
|
const pairedTasksSqlRow = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
||||||
)
|
)
|
||||||
.get() as { sql?: string } | undefined;
|
.get() as { sql?: string } | undefined;
|
||||||
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
||||||
const pairedTasksNeedsMigration =
|
const pairedTasksNeedsRebuild =
|
||||||
pairedTasksSql &&
|
pairedTasksSql && pairedTasksSql.includes('task_policy');
|
||||||
(!pairedTasksSql.includes("'review_pending'") ||
|
if (pairedTasksNeedsRebuild) {
|
||||||
!pairedTasksSql.includes("'active'") ||
|
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
|
||||||
!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) {
|
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE paired_tasks_new (
|
CREATE TABLE IF NOT EXISTS paired_tasks (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
chat_jid TEXT NOT NULL,
|
chat_jid TEXT NOT NULL,
|
||||||
group_folder TEXT NOT NULL,
|
group_folder TEXT NOT NULL,
|
||||||
@@ -718,9 +578,7 @@ function createSchema(database: Database.Database): void {
|
|||||||
reviewer_service_id TEXT NOT NULL,
|
reviewer_service_id TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
source_ref TEXT,
|
source_ref TEXT,
|
||||||
task_policy TEXT NOT NULL DEFAULT 'autonomous',
|
plan_notes TEXT,
|
||||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
|
||||||
plan_status TEXT NOT NULL DEFAULT 'not_requested',
|
|
||||||
review_requested_at TEXT,
|
review_requested_at TEXT,
|
||||||
last_finalized_checkpoint TEXT,
|
last_finalized_checkpoint TEXT,
|
||||||
gate_turn_kind TEXT,
|
gate_turn_kind TEXT,
|
||||||
@@ -730,240 +588,70 @@ function createSchema(database: Database.Database): void {
|
|||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed'))
|
||||||
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'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
`);
|
|
||||||
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
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
ON paired_tasks(chat_jid, status, updated_at);
|
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(
|
.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;
|
.get() as { sql?: string } | undefined;
|
||||||
const pairedEventsSql = pairedEventsSqlRow?.sql || '';
|
const pairedWsSql = pairedWsSqlRow?.sql || '';
|
||||||
const pairedEventsNeedsMigration =
|
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
|
||||||
pairedEventsSql && !pairedEventsSql.includes("'deploy_complete'");
|
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
|
||||||
if (pairedEventsNeedsMigration) {
|
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE paired_events_new (
|
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL,
|
task_id TEXT NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
actor_role TEXT NOT NULL,
|
workspace_dir TEXT NOT NULL,
|
||||||
source_service_id TEXT NOT NULL,
|
snapshot_source_dir TEXT,
|
||||||
source_fingerprint TEXT,
|
snapshot_ref TEXT,
|
||||||
dedupe_key TEXT NOT NULL,
|
status TEXT NOT NULL DEFAULT 'ready',
|
||||||
payload_json TEXT,
|
snapshot_refreshed_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
CHECK (
|
updated_at TEXT NOT NULL,
|
||||||
event_type IN (
|
CHECK (role IN ('owner', 'reviewer')),
|
||||||
'set_risk',
|
CHECK (status IN ('ready', 'stale'))
|
||||||
'submit_plan',
|
|
||||||
'approve_plan',
|
|
||||||
'request_plan_changes',
|
|
||||||
'request_review',
|
|
||||||
'deploy_complete'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
CHECK (actor_role IN ('owner', 'reviewer', 'system'))
|
|
||||||
);
|
);
|
||||||
`);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
||||||
database.exec(`
|
ON paired_workspaces(task_id, role);
|
||||||
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);
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pairedArtifactsSqlRow = database
|
// Rebuild paired_projects if it has old schema
|
||||||
|
const pairedProjSqlRow = database
|
||||||
.prepare(
|
.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;
|
.get() as { sql?: string } | undefined;
|
||||||
const pairedArtifactsSql = pairedArtifactsSqlRow?.sql || '';
|
const pairedProjSql = pairedProjSqlRow?.sql || '';
|
||||||
const pairedArtifactsNeedsMigration =
|
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
|
||||||
pairedArtifactsSql && !pairedArtifactsSql.includes("'plan_brief'");
|
database.exec(`DROP TABLE IF EXISTS paired_projects`);
|
||||||
if (pairedArtifactsNeedsMigration) {
|
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE paired_artifacts_new (
|
CREATE TABLE IF NOT EXISTS paired_projects (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
chat_jid TEXT PRIMARY KEY,
|
||||||
task_id TEXT NOT NULL,
|
group_folder TEXT NOT NULL,
|
||||||
execution_id TEXT,
|
canonical_work_dir TEXT NOT NULL,
|
||||||
service_id TEXT NOT NULL,
|
|
||||||
artifact_type TEXT NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
content TEXT,
|
|
||||||
file_path TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
CHECK (
|
updated_at TEXT NOT NULL
|
||||||
artifact_type IN (
|
|
||||||
'comment',
|
|
||||||
'report',
|
|
||||||
'patch',
|
|
||||||
'plan_brief',
|
|
||||||
'acceptance_criteria',
|
|
||||||
'risk_summary'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
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)
|
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||||
@@ -2111,22 +1799,19 @@ export function upsertPairedProject(project: PairedProject): void {
|
|||||||
chat_jid,
|
chat_jid,
|
||||||
group_folder,
|
group_folder,
|
||||||
canonical_work_dir,
|
canonical_work_dir,
|
||||||
workspace_topology,
|
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(chat_jid) DO UPDATE SET
|
ON CONFLICT(chat_jid) DO UPDATE SET
|
||||||
group_folder = excluded.group_folder,
|
group_folder = excluded.group_folder,
|
||||||
canonical_work_dir = excluded.canonical_work_dir,
|
canonical_work_dir = excluded.canonical_work_dir,
|
||||||
workspace_topology = excluded.workspace_topology,
|
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
project.chat_jid,
|
project.chat_jid,
|
||||||
project.group_folder,
|
project.group_folder,
|
||||||
project.canonical_work_dir,
|
project.canonical_work_dir,
|
||||||
project.workspace_topology,
|
|
||||||
project.created_at,
|
project.created_at,
|
||||||
project.updated_at,
|
project.updated_at,
|
||||||
);
|
);
|
||||||
@@ -2149,9 +1834,7 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
reviewer_service_id,
|
reviewer_service_id,
|
||||||
title,
|
title,
|
||||||
source_ref,
|
source_ref,
|
||||||
task_policy,
|
plan_notes,
|
||||||
risk_level,
|
|
||||||
plan_status,
|
|
||||||
review_requested_at,
|
review_requested_at,
|
||||||
last_finalized_checkpoint,
|
last_finalized_checkpoint,
|
||||||
gate_turn_kind,
|
gate_turn_kind,
|
||||||
@@ -2162,7 +1845,7 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -2172,9 +1855,7 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
task.reviewer_service_id,
|
task.reviewer_service_id,
|
||||||
task.title,
|
task.title,
|
||||||
task.source_ref,
|
task.source_ref,
|
||||||
task.task_policy,
|
task.plan_notes,
|
||||||
task.risk_level,
|
|
||||||
task.plan_status,
|
|
||||||
task.review_requested_at,
|
task.review_requested_at,
|
||||||
task.last_finalized_checkpoint ?? null,
|
task.last_finalized_checkpoint ?? null,
|
||||||
task.gate_turn_kind ?? null,
|
task.gate_turn_kind ?? null,
|
||||||
@@ -2218,7 +1899,7 @@ export function getLatestOpenPairedTaskForChat(
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM paired_tasks
|
FROM paired_tasks
|
||||||
WHERE chat_jid = ?
|
WHERE chat_jid = ?
|
||||||
AND status NOT IN ('merged', 'discarded', 'failed')
|
AND status NOT IN ('completed')
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -2233,9 +1914,7 @@ export function updatePairedTask(
|
|||||||
PairedTask,
|
PairedTask,
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'source_ref'
|
| 'source_ref'
|
||||||
| 'task_policy'
|
| 'plan_notes'
|
||||||
| 'risk_level'
|
|
||||||
| 'plan_status'
|
|
||||||
| 'review_requested_at'
|
| 'review_requested_at'
|
||||||
| 'last_finalized_checkpoint'
|
| 'last_finalized_checkpoint'
|
||||||
| 'gate_turn_kind'
|
| 'gate_turn_kind'
|
||||||
@@ -2258,17 +1937,9 @@ export function updatePairedTask(
|
|||||||
fields.push('source_ref = ?');
|
fields.push('source_ref = ?');
|
||||||
values.push(updates.source_ref);
|
values.push(updates.source_ref);
|
||||||
}
|
}
|
||||||
if (updates.task_policy !== undefined) {
|
if (updates.plan_notes !== undefined) {
|
||||||
fields.push('task_policy = ?');
|
fields.push('plan_notes = ?');
|
||||||
values.push(updates.task_policy);
|
values.push(updates.plan_notes);
|
||||||
}
|
|
||||||
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.review_requested_at !== undefined) {
|
if (updates.review_requested_at !== undefined) {
|
||||||
fields.push('review_requested_at = ?');
|
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 {
|
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -2411,7 +1991,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|||||||
role,
|
role,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
snapshot_source_dir,
|
snapshot_source_dir,
|
||||||
snapshot_source_fingerprint,
|
snapshot_ref,
|
||||||
status,
|
status,
|
||||||
snapshot_refreshed_at,
|
snapshot_refreshed_at,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -2421,7 +2001,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
workspace_dir = excluded.workspace_dir,
|
workspace_dir = excluded.workspace_dir,
|
||||||
snapshot_source_dir = excluded.snapshot_source_dir,
|
snapshot_source_dir = excluded.snapshot_source_dir,
|
||||||
snapshot_source_fingerprint = excluded.snapshot_source_fingerprint,
|
snapshot_ref = excluded.snapshot_ref,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
|
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
@@ -2432,7 +2012,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
|||||||
workspace.role,
|
workspace.role,
|
||||||
workspace.workspace_dir,
|
workspace.workspace_dir,
|
||||||
workspace.snapshot_source_dir,
|
workspace.snapshot_source_dir,
|
||||||
workspace.snapshot_source_fingerprint,
|
workspace.snapshot_ref,
|
||||||
workspace.status,
|
workspace.status,
|
||||||
workspace.snapshot_refreshed_at,
|
workspace.snapshot_refreshed_at,
|
||||||
workspace.created_at,
|
workspace.created_at,
|
||||||
@@ -2457,224 +2037,6 @@ export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
|
|||||||
.all(taskId) as 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.
|
* 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.
|
* Used for duplicate detection in pair rooms.
|
||||||
|
|||||||
@@ -660,33 +660,19 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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: {
|
workspace: {
|
||||||
id: 'paired-task-1:owner',
|
id: 'paired-task-1:owner',
|
||||||
task_id: 'paired-task-1',
|
task_id: 'paired-task-1',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
workspace_dir: '/tmp/paired/owner',
|
workspace_dir: '/tmp/paired/owner',
|
||||||
snapshot_source_dir: null,
|
snapshot_source_dir: null,
|
||||||
snapshot_source_fingerprint: null,
|
snapshot_ref: null,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
snapshot_refreshed_at: null,
|
snapshot_refreshed_at: null,
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -695,7 +681,6 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
envOverrides: {
|
envOverrides: {
|
||||||
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
||||||
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
|
|
||||||
EJCLAW_PAIRED_ROLE: 'owner',
|
EJCLAW_PAIRED_ROLE: 'owner',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -717,19 +702,16 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
||||||
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
|
|
||||||
EJCLAW_PAIRED_ROLE: 'owner',
|
EJCLAW_PAIRED_ROLE: 'owner',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
pairedExecutionContext.completePairedExecutionContext,
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
).toHaveBeenCalledWith(
|
).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
taskId: 'paired-task-1',
|
||||||
executionId: 'run-room-role:claude',
|
status: 'succeeded',
|
||||||
status: 'succeeded',
|
summary: 'ok',
|
||||||
summary: 'ok',
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
|
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',
|
reviewer_service_id: 'claude',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'approved',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
updated_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,
|
workspace: null,
|
||||||
envOverrides: {
|
envOverrides: {
|
||||||
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude',
|
|
||||||
EJCLAW_PAIRED_ROLE: 'reviewer',
|
EJCLAW_PAIRED_ROLE: 'reviewer',
|
||||||
EJCLAW_REVIEWER_RUNTIME: '1',
|
EJCLAW_REVIEWER_RUNTIME: '1',
|
||||||
},
|
},
|
||||||
@@ -817,7 +784,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
expect(
|
expect(
|
||||||
pairedExecutionContext.completePairedExecutionContext,
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
).toHaveBeenCalledWith({
|
).toHaveBeenCalledWith({
|
||||||
executionId: 'run-blocked-reviewer:claude',
|
taskId: 'paired-task-1',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
summary:
|
summary:
|
||||||
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
|
'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',
|
phase: 'final',
|
||||||
});
|
});
|
||||||
completePairedExecutionContext({
|
completePairedExecutionContext({
|
||||||
executionId: pairedExecutionContext.execution.id,
|
taskId: pairedExecutionContext.task.id,
|
||||||
status: pairedExecutionStatus,
|
status: pairedExecutionStatus,
|
||||||
summary: pairedExecutionSummary,
|
summary: pairedExecutionSummary,
|
||||||
});
|
});
|
||||||
@@ -940,7 +940,7 @@ export async function runAgentForGroup(
|
|||||||
} finally {
|
} finally {
|
||||||
if (pairedExecutionContext) {
|
if (pairedExecutionContext) {
|
||||||
completePairedExecutionContext({
|
completePairedExecutionContext({
|
||||||
executionId: pairedExecutionContext.execution.id,
|
taskId: pairedExecutionContext.task.id,
|
||||||
status: pairedExecutionStatus,
|
status: pairedExecutionStatus,
|
||||||
summary: pairedExecutionSummary,
|
summary: pairedExecutionSummary,
|
||||||
reviewerVerdict: pairedReviewerVerdict,
|
reviewerVerdict: pairedReviewerVerdict,
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ vi.mock('./paired-execution-context.js', () => ({
|
|||||||
completePairedExecutionContext: vi.fn(),
|
completePairedExecutionContext: vi.fn(),
|
||||||
markRoomReviewReady: vi.fn(() => null),
|
markRoomReviewReady: vi.fn(() => null),
|
||||||
formatRoomReviewReadyMessage: 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', () => {
|
vi.mock('./db.js', () => {
|
||||||
@@ -271,11 +265,9 @@ describe('createMessageRuntime', () => {
|
|||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: '2026-03-29T00:00:00.000Z',
|
review_requested_at: '2026-03-29T00:00:00.000Z',
|
||||||
status: 'review_pending',
|
status: 'review_ready',
|
||||||
created_at: '2026-03-29T00:00:00.000Z',
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
updated_at: '2026-03-29T00:00:00.000Z',
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -356,7 +348,7 @@ describe('createMessageRuntime', () => {
|
|||||||
actualSessionCommands.handleSessionCommand(opts),
|
actualSessionCommands.handleSessionCommand(opts),
|
||||||
);
|
);
|
||||||
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
|
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
|
||||||
status: 'blocked',
|
status: 'pending',
|
||||||
task: {
|
task: {
|
||||||
id: 'paired-task-1',
|
id: 'paired-task-1',
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
@@ -365,15 +357,13 @@ describe('createMessageRuntime', () => {
|
|||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'plan_review_pending',
|
status: 'active',
|
||||||
created_at: '2026-03-29T00:00:00.000Z',
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
updated_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(
|
vi.mocked(
|
||||||
pairedExecutionContext.formatRoomReviewReadyMessage,
|
pairedExecutionContext.formatRoomReviewReadyMessage,
|
||||||
@@ -410,228 +400,6 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage);
|
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 () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
@@ -42,14 +42,8 @@ import { runAgentForGroup } from './message-agent-executor.js';
|
|||||||
import { MessageTurnController } from './message-turn-controller.js';
|
import { MessageTurnController } from './message-turn-controller.js';
|
||||||
import { createSuppressToken } from './output-suppression.js';
|
import { createSuppressToken } from './output-suppression.js';
|
||||||
import {
|
import {
|
||||||
approveRoomPlan,
|
|
||||||
finalizeRoomDeployment,
|
|
||||||
formatRoomReviewReadyMessage,
|
formatRoomReviewReadyMessage,
|
||||||
markRoomReviewReady,
|
markRoomReviewReady,
|
||||||
planPairedExecutionRecovery,
|
|
||||||
recordRoomPlan,
|
|
||||||
requestRoomPlanChanges,
|
|
||||||
setRoomTaskRiskLevel,
|
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import { buildRoomRoleContext } from './room-role-context.js';
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import {
|
import {
|
||||||
@@ -574,7 +568,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
// Session commands are explicit control events, so owner-only and
|
// Session commands are explicit control events, so owner-only and
|
||||||
// reviewer-only commands resolve against the target role rather than
|
// reviewer-only commands resolve against the target role rather than
|
||||||
// whichever service happened to pick up the slash command first.
|
// whichever service happened to pick up the slash command first.
|
||||||
markReviewReady: async (dedupeKey) => {
|
markReviewReady: async () => {
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
const roomRoleContext = buildRoomRoleContext(
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
lease,
|
lease,
|
||||||
@@ -584,77 +578,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
group,
|
group,
|
||||||
chatJid,
|
chatJid,
|
||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
dedupeKey,
|
|
||||||
});
|
});
|
||||||
return formatRoomReviewReadyMessage(result);
|
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;
|
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,
|
group,
|
||||||
chatJid: 'dc:test',
|
chatJid: 'dc:test',
|
||||||
roomRoleContext: ownerContext,
|
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(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') {
|
if (!result || result.status !== 'ready') {
|
||||||
throw new Error('expected owner /review to prepare a ready snapshot');
|
throw new Error('expected owner /review to prepare a ready snapshot');
|
||||||
}
|
}
|
||||||
expect(events[0]?.source_fingerprint).toBe(
|
expect(result.reviewerWorkspace.snapshot_ref).toBeTruthy();
|
||||||
result.reviewerWorkspace.snapshot_source_fingerprint,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses the db owner workspace when reviewer service handles /review', async () => {
|
it('reuses the db owner workspace when reviewer service handles /review', async () => {
|
||||||
@@ -240,134 +229,12 @@ describe('paired /review command path', () => {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
task: expect.objectContaining({
|
task: expect.objectContaining({
|
||||||
id: task!.id,
|
id: task!.id,
|
||||||
status: 'review_pending',
|
status: 'review_ready',
|
||||||
}),
|
}),
|
||||||
pendingReason: 'owner-workspace-not-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(task?.review_requested_at).toBeTruthy();
|
||||||
expect(db.listPairedEventsForTask(task!.id)).toEqual([]);
|
|
||||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
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',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -75,11 +74,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'review the owner changes',
|
title: 'review the owner changes',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -133,7 +130,7 @@ describe('paired workspace manager', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
db.getPairedWorkspace('paired-task-1', 'reviewer')
|
db.getPairedWorkspace('paired-task-1', 'reviewer')
|
||||||
?.snapshot_source_fingerprint,
|
?.snapshot_ref,
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,8 +152,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -167,11 +163,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -210,8 +204,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -222,11 +215,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'cross service review',
|
title: 'cross service review',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -288,8 +279,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -300,11 +290,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -365,8 +353,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -377,11 +364,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -508,8 +493,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -520,11 +504,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -572,8 +554,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -584,11 +565,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -630,8 +609,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -642,11 +620,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: '2026-03-28T00:01:00.000Z',
|
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||||
status: 'review_pending',
|
status: 'review_ready',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: '2026-03-28T00:01:00.000Z',
|
updated_at: '2026-03-28T00:01:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -694,8 +670,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -706,11 +681,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'low',
|
|
||||||
plan_status: 'not_requested',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -760,8 +733,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -772,11 +744,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'approved',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -851,8 +821,7 @@ describe('paired workspace manager', () => {
|
|||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
group_folder: 'paired-room',
|
group_folder: 'paired-room',
|
||||||
canonical_work_dir: canonicalDir,
|
canonical_work_dir: canonicalDir,
|
||||||
workspace_topology: 'shadow-snapshot',
|
created_at: now,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
db.createPairedTask({
|
db.createPairedTask({
|
||||||
@@ -863,11 +832,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
task_policy: 'autonomous',
|
plan_notes: null,
|
||||||
risk_level: 'high',
|
|
||||||
plan_status: 'pending',
|
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'plan_review_pending',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_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.';
|
'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 =
|
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.';
|
'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([
|
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
'.claude',
|
'.claude',
|
||||||
@@ -325,9 +322,9 @@ function makeWorkspaceRecord(args: {
|
|||||||
workspace_dir: args.workspaceDir,
|
workspace_dir: args.workspaceDir,
|
||||||
snapshot_source_dir:
|
snapshot_source_dir:
|
||||||
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
|
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
|
||||||
snapshot_source_fingerprint:
|
snapshot_ref:
|
||||||
args.snapshotSourceFingerprint ??
|
args.snapshotSourceFingerprint ??
|
||||||
existing?.snapshot_source_fingerprint ??
|
existing?.snapshot_ref ??
|
||||||
null,
|
null,
|
||||||
status: args.status ?? 'ready',
|
status: args.status ?? 'ready',
|
||||||
snapshot_refreshed_at:
|
snapshot_refreshed_at:
|
||||||
@@ -489,7 +486,6 @@ export function markPairedTaskReviewReady(taskId: string): {
|
|||||||
} | null {
|
} | null {
|
||||||
const requestedAt = new Date().toISOString();
|
const requestedAt = new Date().toISOString();
|
||||||
updatePairedTask(taskId, {
|
updatePairedTask(taskId, {
|
||||||
status: 'review_pending',
|
|
||||||
review_requested_at: requestedAt,
|
review_requested_at: requestedAt,
|
||||||
updated_at: requestedAt,
|
updated_at: requestedAt,
|
||||||
});
|
});
|
||||||
@@ -518,14 +514,6 @@ export interface PreparedReviewerWorkspace {
|
|||||||
export function prepareReviewerWorkspaceForExecution(
|
export function prepareReviewerWorkspaceForExecution(
|
||||||
task: PairedTask,
|
task: PairedTask,
|
||||||
): PreparedReviewerWorkspace {
|
): 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;
|
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
|
||||||
if (!ownerWorkspace) {
|
if (!ownerWorkspace) {
|
||||||
return {
|
return {
|
||||||
@@ -555,11 +543,11 @@ export function prepareReviewerWorkspaceForExecution(
|
|||||||
const snapshotStale =
|
const snapshotStale =
|
||||||
!!reviewerWorkspace &&
|
!!reviewerWorkspace &&
|
||||||
(reviewerWorkspace.status === 'stale' ||
|
(reviewerWorkspace.status === 'stale' ||
|
||||||
reviewerWorkspace.snapshot_source_fingerprint !== currentFingerprint);
|
reviewerWorkspace.snapshot_ref !== currentFingerprint);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (snapshotMissing || snapshotStale) {
|
if (snapshotMissing || snapshotStale) {
|
||||||
if (task.status === 'review_pending' || task.status === 'review_ready') {
|
if (task.status === 'review_ready') {
|
||||||
const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id);
|
const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id);
|
||||||
updatePairedTask(task.id, {
|
updatePairedTask(task.id, {
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
@@ -587,7 +575,7 @@ export function prepareReviewerWorkspaceForExecution(
|
|||||||
workspaceDir: reviewerWorkspace.workspace_dir,
|
workspaceDir: reviewerWorkspace.workspace_dir,
|
||||||
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
|
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
|
||||||
snapshotSourceFingerprint:
|
snapshotSourceFingerprint:
|
||||||
reviewerWorkspace.snapshot_source_fingerprint,
|
reviewerWorkspace.snapshot_ref,
|
||||||
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
|
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
|
||||||
status: 'stale',
|
status: 'stale',
|
||||||
createdAt: reviewerWorkspace.created_at,
|
createdAt: reviewerWorkspace.created_at,
|
||||||
@@ -595,7 +583,6 @@ export function prepareReviewerWorkspaceForExecution(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
updatePairedTask(task.id, {
|
updatePairedTask(task.id, {
|
||||||
status: 'review_pending',
|
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -37,28 +37,6 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
|
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', () => {
|
it('rejects /compact with extra text', () => {
|
||||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -198,11 +176,6 @@ function makeDeps(
|
|||||||
isAdminSender: vi.fn().mockReturnValue(false),
|
isAdminSender: vi.fn().mockReturnValue(false),
|
||||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||||
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
|
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,
|
...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 () => {
|
it('sends denial to interactable sender in non-main group', async () => {
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
const result = await handleSessionCommand({
|
const result = await handleSessionCommand({
|
||||||
|
|||||||
@@ -12,20 +12,6 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
|
|||||||
/^Conversation compacted\.$/,
|
/^Conversation compacted\.$/,
|
||||||
/^Review snapshot updated\.(?:\n|$)/,
|
/^Review snapshot updated\.(?:\n|$)/,
|
||||||
/^Review request recorded, but the owner workspace is not ready yet\.(?:\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\./,
|
/^Review is unavailable for this room\./,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -48,13 +34,6 @@ export function extractSessionCommand(
|
|||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
if (text === '/clear') return '/clear';
|
if (text === '/clear') return '/clear';
|
||||||
if (text === '/review' || text === '/review-ready') return '/review';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,22 +78,7 @@ export interface SessionCommandDeps {
|
|||||||
isAdminSender: (msg: NewMessage) => boolean;
|
isAdminSender: (msg: NewMessage) => boolean;
|
||||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||||
canSenderInteract: (msg: NewMessage) => boolean;
|
canSenderInteract: (msg: NewMessage) => boolean;
|
||||||
markReviewReady: (dedupeKey: string) => Promise<string | null>;
|
markReviewReady: () => 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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultToText(result: string | object | null | undefined): string {
|
function resultToText(result: string | object | null | undefined): string {
|
||||||
@@ -200,7 +164,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command === '/review') {
|
if (command === '/review') {
|
||||||
const result = await deps.markReviewReady(cmdMsg.id);
|
const result = await deps.markReviewReady();
|
||||||
deps.advanceCursor(cmdMsg.timestamp);
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
await deps.sendMessage(
|
await deps.sendMessage(
|
||||||
result ??
|
result ??
|
||||||
@@ -209,73 +173,6 @@ export async function handleSessionCommand(opts: {
|
|||||||
return { handled: true, success: true };
|
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 cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
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 PairedRoomRole = 'owner' | 'reviewer';
|
||||||
|
|
||||||
export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
|
|
||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
| 'active'
|
| 'active'
|
||||||
| 'draft'
|
|
||||||
| 'plan_review_pending'
|
|
||||||
| 'review_pending'
|
|
||||||
| 'review_ready'
|
| 'review_ready'
|
||||||
| 'in_review'
|
| 'in_review'
|
||||||
| 'changes_requested'
|
|
||||||
| 'merge_ready'
|
| 'merge_ready'
|
||||||
| 'merged'
|
| 'completed';
|
||||||
| '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';
|
|
||||||
|
|
||||||
export type PairedWorkspaceRole = 'owner' | 'reviewer';
|
export type PairedWorkspaceRole = 'owner' | 'reviewer';
|
||||||
|
|
||||||
export type PairedWorkspaceStatus =
|
export type PairedWorkspaceStatus = 'ready' | 'stale';
|
||||||
| '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 interface RoomRoleContext {
|
export interface RoomRoleContext {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -106,7 +48,6 @@ export interface PairedProject {
|
|||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
group_folder: string;
|
group_folder: string;
|
||||||
canonical_work_dir: string;
|
canonical_work_dir: string;
|
||||||
workspace_topology: PairedWorkspaceTopology;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -119,9 +60,7 @@ export interface PairedTask {
|
|||||||
reviewer_service_id: string;
|
reviewer_service_id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
source_ref: string | null;
|
source_ref: string | null;
|
||||||
task_policy: PairedTaskPolicy;
|
plan_notes: string | null;
|
||||||
risk_level: PairedRiskLevel;
|
|
||||||
plan_status: PairedPlanStatus;
|
|
||||||
review_requested_at: string | null;
|
review_requested_at: string | null;
|
||||||
last_finalized_checkpoint?: string | null;
|
last_finalized_checkpoint?: string | null;
|
||||||
gate_turn_kind?: PairedGateTurnKind | null;
|
gate_turn_kind?: PairedGateTurnKind | null;
|
||||||
@@ -133,66 +72,20 @@ export interface PairedTask {
|
|||||||
updated_at: string;
|
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 {
|
export interface PairedWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
role: PairedWorkspaceRole;
|
role: PairedWorkspaceRole;
|
||||||
workspace_dir: string;
|
workspace_dir: string;
|
||||||
snapshot_source_dir: string | null;
|
snapshot_source_dir: string | null;
|
||||||
snapshot_source_fingerprint: string | null;
|
snapshot_ref: string | null;
|
||||||
status: PairedWorkspaceStatus;
|
status: PairedWorkspaceStatus;
|
||||||
snapshot_refreshed_at: string | null;
|
snapshot_refreshed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairedApproval {
|
export type AgentResponsePolicy = 'normal' | 'silent-unless-addressed';
|
||||||
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 StructuredAgentOutput =
|
export type StructuredAgentOutput =
|
||||||
| {
|
| {
|
||||||
|
|||||||
Reference in New Issue
Block a user