feat: add paired review workspace flow
This commit is contained in:
@@ -25,6 +25,11 @@ import {
|
|||||||
prependRoomRoleHeader,
|
prependRoomRoleHeader,
|
||||||
type RoomRoleContext,
|
type RoomRoleContext,
|
||||||
} from './room-role-context.js';
|
} from './room-role-context.js';
|
||||||
|
import {
|
||||||
|
buildReviewerGitGuardEnv,
|
||||||
|
isReviewerMutatingShellCommand,
|
||||||
|
isReviewerRuntime,
|
||||||
|
} from './reviewer-runtime.js';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -466,6 +471,25 @@ function createSanitizeBashHook(): HookCallback {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createReviewerBashGuardHook(): HookCallback {
|
||||||
|
return async (input, _toolUseId, _context) => {
|
||||||
|
const preInput = input as PreToolUseHookInput;
|
||||||
|
const command = (preInput.tool_input as { command?: string })?.command;
|
||||||
|
if (!command || !isReviewerMutatingShellCommand(command)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason:
|
||||||
|
'EJClaw reviewer runtime blocks mutating shell commands in paired review mode.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFilename(summary: string): string {
|
function sanitizeFilename(summary: string): string {
|
||||||
return summary
|
return summary
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -595,6 +619,7 @@ async function runQuery(
|
|||||||
mcpServerPath: string,
|
mcpServerPath: string,
|
||||||
containerInput: ContainerInput,
|
containerInput: ContainerInput,
|
||||||
sdkEnv: Record<string, string | undefined>,
|
sdkEnv: Record<string, string | undefined>,
|
||||||
|
reviewerRuntime: boolean,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
closedDuringQuery: boolean;
|
closedDuringQuery: boolean;
|
||||||
@@ -658,6 +683,28 @@ async function runQuery(
|
|||||||
if (model) log(`Using model: ${model}`);
|
if (model) log(`Using model: ${model}`);
|
||||||
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
|
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
|
||||||
if (effort) log(`Effort: ${effort}`);
|
if (effort) log(`Effort: ${effort}`);
|
||||||
|
if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
|
||||||
|
|
||||||
|
const allowedTools = reviewerRuntime
|
||||||
|
? [
|
||||||
|
'Bash',
|
||||||
|
'Read', 'Glob', 'Grep',
|
||||||
|
'WebSearch', 'WebFetch',
|
||||||
|
'Task', 'TaskOutput', 'TaskStop',
|
||||||
|
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||||
|
'TodoWrite', 'ToolSearch', 'Skill',
|
||||||
|
'mcp__ejclaw__*',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'Bash',
|
||||||
|
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||||
|
'WebSearch', 'WebFetch',
|
||||||
|
'Task', 'TaskOutput', 'TaskStop',
|
||||||
|
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||||
|
'TodoWrite', 'ToolSearch', 'Skill',
|
||||||
|
'NotebookEdit',
|
||||||
|
'mcp__ejclaw__*'
|
||||||
|
];
|
||||||
|
|
||||||
for await (const message of query({
|
for await (const message of query({
|
||||||
prompt: stream,
|
prompt: stream,
|
||||||
@@ -668,16 +715,7 @@ async function runQuery(
|
|||||||
effort,
|
effort,
|
||||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||||
resume: sessionId,
|
resume: sessionId,
|
||||||
allowedTools: [
|
allowedTools,
|
||||||
'Bash',
|
|
||||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
|
||||||
'WebSearch', 'WebFetch',
|
|
||||||
'Task', 'TaskOutput', 'TaskStop',
|
|
||||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
|
||||||
'TodoWrite', 'ToolSearch', 'Skill',
|
|
||||||
'NotebookEdit',
|
|
||||||
'mcp__ejclaw__*'
|
|
||||||
],
|
|
||||||
env: sdkEnv,
|
env: sdkEnv,
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
@@ -715,7 +753,14 @@ async function runQuery(
|
|||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
PreToolUse: [
|
||||||
|
{
|
||||||
|
matcher: 'Bash',
|
||||||
|
hooks: reviewerRuntime
|
||||||
|
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
|
||||||
|
: [createSanitizeBashHook()],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
agentProgressSummaries: true,
|
agentProgressSummaries: true,
|
||||||
}
|
}
|
||||||
@@ -954,6 +999,10 @@ async function main(): Promise<void> {
|
|||||||
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
|
||||||
sdkEnv[key] = value;
|
sdkEnv[key] = value;
|
||||||
}
|
}
|
||||||
|
const reviewerRuntime =
|
||||||
|
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
||||||
|
isReviewerRuntime(containerInput.roomRoleContext);
|
||||||
|
const guardedSdkEnv = buildReviewerGitGuardEnv(sdkEnv, reviewerRuntime);
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||||
@@ -1001,7 +1050,7 @@ async function main(): Promise<void> {
|
|||||||
resume: sessionId,
|
resume: sessionId,
|
||||||
systemPrompt: undefined,
|
systemPrompt: undefined,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
env: sdkEnv,
|
env: guardedSdkEnv,
|
||||||
permissionMode: 'bypassPermissions' as const,
|
permissionMode: 'bypassPermissions' as const,
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
settingSources: ['project', 'user'] as const,
|
settingSources: ['project', 'user'] as const,
|
||||||
@@ -1088,7 +1137,8 @@ async function main(): Promise<void> {
|
|||||||
sessionId,
|
sessionId,
|
||||||
mcpServerPath,
|
mcpServerPath,
|
||||||
containerInput,
|
containerInput,
|
||||||
sdkEnv,
|
guardedSdkEnv,
|
||||||
|
reviewerRuntime,
|
||||||
);
|
);
|
||||||
if (queryResult.newSessionId) {
|
if (queryResult.newSessionId) {
|
||||||
sessionId = queryResult.newSessionId;
|
sessionId = queryResult.newSessionId;
|
||||||
|
|||||||
220
runners/agent-runner/src/reviewer-runtime.ts
Normal file
220
runners/agent-runner/src/reviewer-runtime.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { RoomRoleContext } from './room-role-context.js';
|
||||||
|
|
||||||
|
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||||
|
'add',
|
||||||
|
'am',
|
||||||
|
'apply',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'cherry-pick',
|
||||||
|
'clean',
|
||||||
|
'commit',
|
||||||
|
'merge',
|
||||||
|
'push',
|
||||||
|
'rebase',
|
||||||
|
'reset',
|
||||||
|
'restore',
|
||||||
|
'stash',
|
||||||
|
'switch',
|
||||||
|
'tag',
|
||||||
|
'worktree',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MUTATING_SHELL_PATTERNS = [
|
||||||
|
/\bsed\s+-i\b/i,
|
||||||
|
/\bperl\s+-i\b/i,
|
||||||
|
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isReviewerRuntime(
|
||||||
|
roomRoleContext?: RoomRoleContext,
|
||||||
|
): boolean {
|
||||||
|
return roomRoleContext?.role === 'reviewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGitSubcommand(args: string[]): string {
|
||||||
|
let skipNext = false;
|
||||||
|
for (const arg of args) {
|
||||||
|
if (skipNext) {
|
||||||
|
skipNext = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (arg) {
|
||||||
|
case '-c':
|
||||||
|
case '-C':
|
||||||
|
case '--git-dir':
|
||||||
|
case '--work-tree':
|
||||||
|
case '--namespace':
|
||||||
|
case '--exec-path':
|
||||||
|
case '--config-env':
|
||||||
|
skipNext = true;
|
||||||
|
continue;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
arg.startsWith('-c') ||
|
||||||
|
arg.startsWith('-C') ||
|
||||||
|
arg.startsWith('--git-dir=') ||
|
||||||
|
arg.startsWith('--work-tree=') ||
|
||||||
|
arg.startsWith('--namespace=') ||
|
||||||
|
arg.startsWith('--exec-path=') ||
|
||||||
|
arg.startsWith('--config-env=')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg.startsWith('-')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeShellWords(segment: string): string[] {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (const char of segment) {
|
||||||
|
if (escaped) {
|
||||||
|
current += char;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '\\' && quote !== "'") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/\s/.test(char)) {
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedGitCommand(command: string): boolean {
|
||||||
|
const segments = command
|
||||||
|
.split(/&&|\|\||[;\n]/)
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const segment of segments) {
|
||||||
|
const tokens = tokenizeShellWords(segment);
|
||||||
|
if (tokens[0] !== 'git') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const subcommand = findGitSubcommand(tokens.slice(1));
|
||||||
|
if (BLOCKED_GIT_SUBCOMMANDS.has(subcommand)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
|
||||||
|
return execFileSync('bash', ['-lc', 'command -v git'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
env: baseEnv,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReviewerGitGuardEnv(
|
||||||
|
baseEnv: NodeJS.ProcessEnv,
|
||||||
|
reviewerRuntime: boolean,
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
if (!reviewerRuntime) {
|
||||||
|
return baseEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGitPath = resolveGitBinary(baseEnv);
|
||||||
|
const wrapperDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
|
||||||
|
);
|
||||||
|
const wrapperPath = path.join(wrapperDir, 'git');
|
||||||
|
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
||||||
|
.map((value) => `'${value}'`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const script = `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
real_git=${JSON.stringify(realGitPath)}
|
||||||
|
blocked_subcommands=(${blocked})
|
||||||
|
subcmd=""
|
||||||
|
skip_next=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "$skip_next" == "1" ]]; then
|
||||||
|
skip_next=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
|
||||||
|
skip_next=1
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
subcmd="$arg"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
for blocked in "\${blocked_subcommands[@]}"; do
|
||||||
|
if [[ "$subcmd" == "$blocked" ]]; then
|
||||||
|
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec "$real_git" "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
|
||||||
|
return {
|
||||||
|
...baseEnv,
|
||||||
|
EJCLAW_REAL_GIT: realGitPath,
|
||||||
|
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReviewerMutatingShellCommand(command: string): boolean {
|
||||||
|
const normalized = command.trim();
|
||||||
|
return (
|
||||||
|
isBlockedGitCommand(normalized) ||
|
||||||
|
MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized))
|
||||||
|
);
|
||||||
|
}
|
||||||
67
runners/agent-runner/test/reviewer-runtime.test.ts
Normal file
67
runners/agent-runner/test/reviewer-runtime.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildReviewerGitGuardEnv,
|
||||||
|
isReviewerMutatingShellCommand,
|
||||||
|
isReviewerRuntime,
|
||||||
|
} from '../src/reviewer-runtime.js';
|
||||||
|
|
||||||
|
describe('claude reviewer runtime guard', () => {
|
||||||
|
it('detects reviewer room metadata', () => {
|
||||||
|
expect(
|
||||||
|
isReviewerRuntime({
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'codex-review',
|
||||||
|
failoverOwner: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags mutating shell commands', () => {
|
||||||
|
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(true);
|
||||||
|
expect(
|
||||||
|
isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isReviewerMutatingShellCommand('git status')).toBe(false);
|
||||||
|
expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
|
||||||
|
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
|
||||||
|
expect(env.PATH).toContain('ejclaw-reviewer-git-');
|
||||||
|
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks mutating git subcommands even when git options come first', () => {
|
||||||
|
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
|
||||||
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
throw new Error('expected git wrapper to block commit');
|
||||||
|
} catch (error) {
|
||||||
|
const stderr =
|
||||||
|
error instanceof Error && 'stderr' in error
|
||||||
|
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
|
||||||
|
: '';
|
||||||
|
expect(stderr).toContain(
|
||||||
|
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
prependRoomRoleHeader,
|
prependRoomRoleHeader,
|
||||||
type RoomRoleContext,
|
type RoomRoleContext,
|
||||||
} from './room-role-context.js';
|
} from './room-role-context.js';
|
||||||
|
import {
|
||||||
|
buildReviewerGitGuardEnv,
|
||||||
|
isReviewerRuntime,
|
||||||
|
} from './reviewer-runtime.js';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -358,9 +362,13 @@ async function runAppServerSession(
|
|||||||
containerInput: ContainerInput,
|
containerInput: ContainerInput,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const reviewerRuntime =
|
||||||
|
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
|
||||||
|
isReviewerRuntime(containerInput.roomRoleContext);
|
||||||
|
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
|
||||||
const client = new CodexAppServerClient({
|
const client = new CodexAppServerClient({
|
||||||
cwd: EFFECTIVE_CWD,
|
cwd: EFFECTIVE_CWD,
|
||||||
env: process.env,
|
env: clientEnv,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
105
runners/codex-runner/src/reviewer-runtime.ts
Normal file
105
runners/codex-runner/src/reviewer-runtime.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { RoomRoleContext } from './room-role-context.js';
|
||||||
|
|
||||||
|
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||||
|
'add',
|
||||||
|
'am',
|
||||||
|
'apply',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'cherry-pick',
|
||||||
|
'clean',
|
||||||
|
'commit',
|
||||||
|
'merge',
|
||||||
|
'push',
|
||||||
|
'rebase',
|
||||||
|
'reset',
|
||||||
|
'restore',
|
||||||
|
'stash',
|
||||||
|
'switch',
|
||||||
|
'tag',
|
||||||
|
'worktree',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isReviewerRuntime(
|
||||||
|
roomRoleContext?: RoomRoleContext,
|
||||||
|
): boolean {
|
||||||
|
return roomRoleContext?.role === 'reviewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
|
||||||
|
return execFileSync('bash', ['-lc', 'command -v git'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
env: baseEnv,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReviewerGitGuardEnv(
|
||||||
|
baseEnv: NodeJS.ProcessEnv,
|
||||||
|
reviewerRuntime: boolean,
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
if (!reviewerRuntime) {
|
||||||
|
return baseEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGitPath = resolveGitBinary(baseEnv);
|
||||||
|
const wrapperDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
|
||||||
|
);
|
||||||
|
const wrapperPath = path.join(wrapperDir, 'git');
|
||||||
|
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
|
||||||
|
.map((value) => `'${value}'`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const script = `#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
real_git=${JSON.stringify(realGitPath)}
|
||||||
|
blocked_subcommands=(${blocked})
|
||||||
|
subcmd=""
|
||||||
|
skip_next=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [[ "$skip_next" == "1" ]]; then
|
||||||
|
skip_next=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
|
||||||
|
skip_next=1
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
subcmd="$arg"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
for blocked in "\${blocked_subcommands[@]}"; do
|
||||||
|
if [[ "$subcmd" == "$blocked" ]]; then
|
||||||
|
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exec "$real_git" "$@"
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
|
||||||
|
return {
|
||||||
|
...baseEnv,
|
||||||
|
EJCLAW_REAL_GIT: realGitPath,
|
||||||
|
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
runners/codex-runner/test/reviewer-runtime.test.ts
Normal file
54
runners/codex-runner/test/reviewer-runtime.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildReviewerGitGuardEnv,
|
||||||
|
isReviewerRuntime,
|
||||||
|
} from '../src/reviewer-runtime.js';
|
||||||
|
|
||||||
|
describe('codex reviewer runtime guard', () => {
|
||||||
|
it('detects reviewer room metadata', () => {
|
||||||
|
expect(
|
||||||
|
isReviewerRuntime({
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'codex-review',
|
||||||
|
failoverOwner: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
|
||||||
|
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
|
||||||
|
expect(env.PATH).toContain('ejclaw-reviewer-git-');
|
||||||
|
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks mutating git subcommands even when git options come first', () => {
|
||||||
|
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
|
||||||
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
throw new Error('expected git wrapper to block commit');
|
||||||
|
} catch (error) {
|
||||||
|
const stderr =
|
||||||
|
error instanceof Error && 'stderr' in error
|
||||||
|
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
|
||||||
|
: '';
|
||||||
|
expect(stderr).toContain(
|
||||||
|
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
165
src/db.test.ts
165
src/db.test.ts
@@ -6,6 +6,10 @@ import {
|
|||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
claimServiceHandoff,
|
claimServiceHandoff,
|
||||||
completeServiceHandoffAndAdvanceTargetCursor,
|
completeServiceHandoffAndAdvanceTargetCursor,
|
||||||
|
createPairedApproval,
|
||||||
|
createPairedArtifact,
|
||||||
|
createPairedExecution,
|
||||||
|
createPairedTask,
|
||||||
createTask,
|
createTask,
|
||||||
createServiceHandoff,
|
createServiceHandoff,
|
||||||
createProducedWorkItem,
|
createProducedWorkItem,
|
||||||
@@ -22,10 +26,17 @@ import {
|
|||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
|
getPairedExecutionById,
|
||||||
|
getPairedProject,
|
||||||
|
getPairedTaskById,
|
||||||
|
getPairedWorkspace,
|
||||||
getRouterStateForService,
|
getRouterStateForService,
|
||||||
isPairedRoomJid,
|
isPairedRoomJid,
|
||||||
getSession,
|
getSession,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
listPairedApprovalsForTask,
|
||||||
|
listPairedArtifactsForTask,
|
||||||
|
listPairedWorkspacesForTask,
|
||||||
markWorkItemDelivered,
|
markWorkItemDelivered,
|
||||||
markWorkItemDeliveryRetry,
|
markWorkItemDeliveryRetry,
|
||||||
setSession,
|
setSession,
|
||||||
@@ -33,6 +44,10 @@ import {
|
|||||||
setRouterStateForService,
|
setRouterStateForService,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
|
updatePairedExecution,
|
||||||
|
updatePairedTask,
|
||||||
|
upsertPairedProject,
|
||||||
|
upsertPairedWorkspace,
|
||||||
updateTask,
|
updateTask,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
@@ -529,6 +544,156 @@ Check the run.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('paired task state', () => {
|
||||||
|
it('stores project, task, execution, workspace, approval, and artifact state', () => {
|
||||||
|
upsertPairedProject({
|
||||||
|
chat_jid: 'dc:paired',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: '/tmp/paired-room',
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
createPairedTask({
|
||||||
|
id: 'paired-task-1',
|
||||||
|
chat_jid: 'dc:paired',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: 'wire up workspaces',
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
createPairedExecution({
|
||||||
|
id: 'paired-exec-1',
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
service_id: 'codex-main',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_id: null,
|
||||||
|
status: 'pending',
|
||||||
|
summary: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
upsertPairedWorkspace({
|
||||||
|
id: 'paired-task-1:owner',
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_dir: '/tmp/paired-room/owner',
|
||||||
|
snapshot_source_dir: null,
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const approvalId = createPairedApproval({
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
service_id: 'codex-review',
|
||||||
|
role: 'reviewer',
|
||||||
|
status: 'pending',
|
||||||
|
note: 'needs snapshot',
|
||||||
|
created_at: '2026-03-28T00:00:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifactId = createPairedArtifact({
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
execution_id: 'paired-exec-1',
|
||||||
|
service_id: 'codex-review',
|
||||||
|
artifact_type: 'report',
|
||||||
|
title: 'review notes',
|
||||||
|
content: 'looks fine',
|
||||||
|
file_path: null,
|
||||||
|
created_at: '2026-03-28T00:00:02.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPairedProject('dc:paired')?.canonical_work_dir).toBe(
|
||||||
|
'/tmp/paired-room',
|
||||||
|
);
|
||||||
|
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
|
||||||
|
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
|
||||||
|
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
||||||
|
'/tmp/paired-room/owner',
|
||||||
|
);
|
||||||
|
expect(listPairedApprovalsForTask('paired-task-1')[0]?.id).toBe(approvalId);
|
||||||
|
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates task and execution state and keeps one workspace per role', () => {
|
||||||
|
createPairedTask({
|
||||||
|
id: 'paired-task-2',
|
||||||
|
chat_jid: 'dc:paired',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
createPairedExecution({
|
||||||
|
id: 'paired-exec-2',
|
||||||
|
task_id: 'paired-task-2',
|
||||||
|
service_id: 'codex-review',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_id: null,
|
||||||
|
status: 'pending',
|
||||||
|
summary: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePairedTask('paired-task-2', {
|
||||||
|
status: 'review_ready',
|
||||||
|
review_requested_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
});
|
||||||
|
updatePairedExecution('paired-exec-2', {
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-03-28T00:11:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
upsertPairedWorkspace({
|
||||||
|
id: 'paired-task-2:reviewer',
|
||||||
|
task_id: 'paired-task-2',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_dir: '/tmp/reviewer-v1',
|
||||||
|
snapshot_source_dir: '/tmp/owner',
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
created_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
});
|
||||||
|
upsertPairedWorkspace({
|
||||||
|
id: 'paired-task-2:reviewer',
|
||||||
|
task_id: 'paired-task-2',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_dir: '/tmp/reviewer-v2',
|
||||||
|
snapshot_source_dir: '/tmp/owner',
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
|
||||||
|
created_at: '2026-03-28T00:10:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:12:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
|
||||||
|
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
|
||||||
|
expect(
|
||||||
|
listPairedWorkspacesForTask('paired-task-2').map((workspace) => workspace.workspace_dir),
|
||||||
|
).toEqual(['/tmp/reviewer-v2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- LIMIT behavior ---
|
// --- LIMIT behavior ---
|
||||||
|
|
||||||
describe('message query LIMIT', () => {
|
describe('message query LIMIT', () => {
|
||||||
|
|||||||
441
src/db.ts
441
src/db.ts
@@ -25,6 +25,12 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js';
|
|||||||
import {
|
import {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
AgentType,
|
AgentType,
|
||||||
|
PairedApproval,
|
||||||
|
PairedArtifact,
|
||||||
|
PairedExecution,
|
||||||
|
PairedProject,
|
||||||
|
PairedTask,
|
||||||
|
PairedWorkspace,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
TaskRunLog,
|
TaskRunLog,
|
||||||
@@ -230,6 +236,100 @@ function createSchema(database: Database.Database): void {
|
|||||||
PRIMARY KEY (jid, agent_type),
|
PRIMARY KEY (jid, agent_type),
|
||||||
UNIQUE (folder, agent_type)
|
UNIQUE (folder, agent_type)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS paired_projects (
|
||||||
|
chat_jid TEXT PRIMARY KEY,
|
||||||
|
group_folder TEXT NOT NULL,
|
||||||
|
canonical_work_dir TEXT NOT NULL,
|
||||||
|
workspace_topology TEXT NOT NULL DEFAULT 'shadow-snapshot',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (workspace_topology IN ('shadow-snapshot', 'reviewer-cow'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
||||||
|
CHECK (
|
||||||
|
status IN (
|
||||||
|
'draft',
|
||||||
|
'review_ready',
|
||||||
|
'in_review',
|
||||||
|
'changes_requested',
|
||||||
|
'merge_ready',
|
||||||
|
'merged',
|
||||||
|
'discarded',
|
||||||
|
'failed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
||||||
|
ON paired_tasks(chat_jid, status, updated_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS paired_executions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
workspace_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
summary TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
CHECK (role IN ('owner', 'reviewer')),
|
||||||
|
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'cancelled'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_executions_task
|
||||||
|
ON paired_executions(task_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
workspace_dir TEXT NOT NULL,
|
||||||
|
snapshot_source_dir TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'provisioning',
|
||||||
|
snapshot_refreshed_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (role IN ('owner', 'reviewer')),
|
||||||
|
CHECK (status IN ('provisioning', 'ready', 'stale', 'failed', 'archived'))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
||||||
|
ON paired_workspaces(task_id, role);
|
||||||
|
CREATE TABLE IF NOT EXISTS paired_approvals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
CHECK (role IN ('owner', 'reviewer')),
|
||||||
|
CHECK (status IN ('pending', 'approved', 'rejected'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_approvals_task
|
||||||
|
ON paired_approvals(task_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS paired_artifacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
execution_id TEXT,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
artifact_type TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
CHECK (artifact_type IN ('comment', 'report', 'patch'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
|
||||||
|
ON paired_artifacts(task_id, created_at);
|
||||||
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,
|
||||||
@@ -1638,6 +1738,347 @@ export function isPairedRoomJid(jid: string): boolean {
|
|||||||
return types.includes('claude-code') && types.includes('codex');
|
return types.includes('claude-code') && types.includes('codex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Paired task/project/workspace state ---
|
||||||
|
|
||||||
|
export function upsertPairedProject(project: PairedProject): void {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_projects (
|
||||||
|
chat_jid,
|
||||||
|
group_folder,
|
||||||
|
canonical_work_dir,
|
||||||
|
workspace_topology,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(chat_jid) DO UPDATE SET
|
||||||
|
group_folder = excluded.group_folder,
|
||||||
|
canonical_work_dir = excluded.canonical_work_dir,
|
||||||
|
workspace_topology = excluded.workspace_topology,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
project.chat_jid,
|
||||||
|
project.group_folder,
|
||||||
|
project.canonical_work_dir,
|
||||||
|
project.workspace_topology,
|
||||||
|
project.created_at,
|
||||||
|
project.updated_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPairedProject(chatJid: string): PairedProject | undefined {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM paired_projects WHERE chat_jid = ?')
|
||||||
|
.get(chatJid) as PairedProject | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPairedTask(task: PairedTask): void {
|
||||||
|
db.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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
task.id,
|
||||||
|
task.chat_jid,
|
||||||
|
task.group_folder,
|
||||||
|
task.owner_service_id,
|
||||||
|
task.reviewer_service_id,
|
||||||
|
task.title,
|
||||||
|
task.source_ref,
|
||||||
|
task.review_requested_at,
|
||||||
|
task.status,
|
||||||
|
task.created_at,
|
||||||
|
task.updated_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPairedTaskById(id: string): PairedTask | undefined {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM paired_tasks WHERE id = ?')
|
||||||
|
.get(id) as PairedTask | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestOpenPairedTaskForChat(
|
||||||
|
chatJid: string,
|
||||||
|
): PairedTask | undefined {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM paired_tasks
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND status NOT IN ('merged', 'discarded', 'failed')
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(chatJid) as PairedTask | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePairedTask(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<
|
||||||
|
Pick<
|
||||||
|
PairedTask,
|
||||||
|
'title' | 'source_ref' | 'review_requested_at' | 'status' | 'updated_at'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): void {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (updates.title !== undefined) {
|
||||||
|
fields.push('title = ?');
|
||||||
|
values.push(updates.title);
|
||||||
|
}
|
||||||
|
if (updates.source_ref !== undefined) {
|
||||||
|
fields.push('source_ref = ?');
|
||||||
|
values.push(updates.source_ref);
|
||||||
|
}
|
||||||
|
if (updates.review_requested_at !== undefined) {
|
||||||
|
fields.push('review_requested_at = ?');
|
||||||
|
values.push(updates.review_requested_at);
|
||||||
|
}
|
||||||
|
if (updates.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
values.push(updates.status);
|
||||||
|
}
|
||||||
|
if (updates.updated_at !== undefined) {
|
||||||
|
fields.push('updated_at = ?');
|
||||||
|
values.push(updates.updated_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
db.prepare(`UPDATE paired_tasks SET ${fields.join(', ')} WHERE id = ?`).run(
|
||||||
|
...values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPairedExecution(execution: PairedExecution): void {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_executions (
|
||||||
|
id,
|
||||||
|
task_id,
|
||||||
|
service_id,
|
||||||
|
role,
|
||||||
|
workspace_id,
|
||||||
|
status,
|
||||||
|
summary,
|
||||||
|
created_at,
|
||||||
|
started_at,
|
||||||
|
completed_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
execution.id,
|
||||||
|
execution.task_id,
|
||||||
|
execution.service_id,
|
||||||
|
execution.role,
|
||||||
|
execution.workspace_id,
|
||||||
|
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' | '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.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
values.push(updates.status);
|
||||||
|
}
|
||||||
|
if (updates.summary !== undefined) {
|
||||||
|
fields.push('summary = ?');
|
||||||
|
values.push(updates.summary);
|
||||||
|
}
|
||||||
|
if (updates.started_at !== undefined) {
|
||||||
|
fields.push('started_at = ?');
|
||||||
|
values.push(updates.started_at);
|
||||||
|
}
|
||||||
|
if (updates.completed_at !== undefined) {
|
||||||
|
fields.push('completed_at = ?');
|
||||||
|
values.push(updates.completed_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE paired_executions SET ${fields.join(', ')} WHERE id = ?`,
|
||||||
|
).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO paired_workspaces (
|
||||||
|
id,
|
||||||
|
task_id,
|
||||||
|
role,
|
||||||
|
workspace_dir,
|
||||||
|
snapshot_source_dir,
|
||||||
|
status,
|
||||||
|
snapshot_refreshed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
workspace_dir = excluded.workspace_dir,
|
||||||
|
snapshot_source_dir = excluded.snapshot_source_dir,
|
||||||
|
status = excluded.status,
|
||||||
|
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`,
|
||||||
|
).run(
|
||||||
|
workspace.id,
|
||||||
|
workspace.task_id,
|
||||||
|
workspace.role,
|
||||||
|
workspace.workspace_dir,
|
||||||
|
workspace.snapshot_source_dir,
|
||||||
|
workspace.status,
|
||||||
|
workspace.snapshot_refreshed_at,
|
||||||
|
workspace.created_at,
|
||||||
|
workspace.updated_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPairedWorkspace(
|
||||||
|
taskId: string,
|
||||||
|
role: PairedWorkspace['role'],
|
||||||
|
): PairedWorkspace | undefined {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM paired_workspaces WHERE task_id = ? AND role = ?')
|
||||||
|
.get(taskId, role) as PairedWorkspace | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM paired_workspaces WHERE task_id = ? ORDER BY created_at',
|
||||||
|
)
|
||||||
|
.all(taskId) as PairedWorkspace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|||||||
@@ -129,3 +129,32 @@ export function resolveServiceTaskSessionsPath(
|
|||||||
ensureWithinBase(sessionsBaseDir, sessionsPath);
|
ensureWithinBase(sessionsBaseDir, sessionsPath);
|
||||||
return sessionsPath;
|
return sessionsPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePairedTaskWorkspaceRoot(
|
||||||
|
folder: string,
|
||||||
|
taskId: string,
|
||||||
|
): string {
|
||||||
|
assertValidGroupFolder(folder);
|
||||||
|
assertValidRuntimeSegment(taskId, 'task ID');
|
||||||
|
const workspacesBaseDir = path.resolve(DATA_DIR, 'workspaces');
|
||||||
|
const workspacesPath = path.resolve(
|
||||||
|
workspacesBaseDir,
|
||||||
|
folder,
|
||||||
|
'tasks',
|
||||||
|
taskId,
|
||||||
|
);
|
||||||
|
ensureWithinBase(workspacesBaseDir, workspacesPath);
|
||||||
|
return workspacesPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePairedTaskWorkspacePath(
|
||||||
|
folder: string,
|
||||||
|
taskId: string,
|
||||||
|
role: 'owner' | 'reviewer',
|
||||||
|
): string {
|
||||||
|
assertValidRuntimeSegment(role, 'workspace role');
|
||||||
|
const workspaceRoot = resolvePairedTaskWorkspaceRoot(folder, taskId);
|
||||||
|
const workspacePath = path.resolve(workspaceRoot, role);
|
||||||
|
ensureWithinBase(workspaceRoot, workspacePath);
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,11 +108,18 @@ vi.mock('./memento-client.js', () => ({
|
|||||||
buildRoomMemoryBriefing: vi.fn(),
|
buildRoomMemoryBriefing: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./paired-execution-context.js', () => ({
|
||||||
|
completePairedExecutionContext: vi.fn(),
|
||||||
|
markRoomReviewReady: vi.fn(),
|
||||||
|
preparePairedExecutionContext: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
import * as agentRunner from './agent-runner.js';
|
import * as agentRunner from './agent-runner.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './memento-client.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
import * as sessionRecovery from './session-recovery.js';
|
import * as sessionRecovery from './session-recovery.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
import * as tokenRotation from './token-rotation.js';
|
import * as tokenRotation from './token-rotation.js';
|
||||||
@@ -180,6 +187,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,6 +216,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,6 +240,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +267,7 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,8 +299,179 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
}),
|
}),
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue(
|
||||||
|
{
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-1',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-main',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
id: 'run-room-role:claude',
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
service_id: 'claude',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_id: 'paired-task-1:owner',
|
||||||
|
status: 'running',
|
||||||
|
summary: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
started_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
id: 'paired-task-1:owner',
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_dir: '/tmp/paired/owner',
|
||||||
|
snapshot_source_dir: null,
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
envOverrides: {
|
||||||
|
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
||||||
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
|
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'owner',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'hello',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-room-role',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
group,
|
||||||
|
expect.objectContaining({
|
||||||
|
roomRoleContext: expect.any(Object),
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
EJCLAW_WORK_DIR: '/tmp/paired/owner',
|
||||||
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
|
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'owner',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
executionId: 'run-room-role:claude',
|
||||||
|
status: 'succeeded',
|
||||||
|
summary: 'ok',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks reviewer execution before review-ready and does not spawn the runner', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
const outputs: Array<{ text?: string; result?: string | null }> = [];
|
||||||
|
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue(
|
||||||
|
{
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-1',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
id: 'run-blocked-reviewer:claude',
|
||||||
|
task_id: 'paired-task-1',
|
||||||
|
service_id: 'claude',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_id: null,
|
||||||
|
status: 'running',
|
||||||
|
summary: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
started_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
completed_at: null,
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {
|
||||||
|
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
|
||||||
|
EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'reviewer',
|
||||||
|
EJCLAW_REVIEWER_RUNTIME: '1',
|
||||||
|
},
|
||||||
|
blockMessage:
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'please review',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-blocked-reviewer',
|
||||||
|
onOutput: async (output) => {
|
||||||
|
outputs.push({
|
||||||
|
text: output.output && 'text' in output.output ? output.output.text : undefined,
|
||||||
|
result: output.result,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
|
||||||
|
expect(outputs).toEqual([
|
||||||
|
{
|
||||||
|
text: 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
||||||
|
result: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
executionId: 'run-blocked-reviewer:claude',
|
||||||
|
status: 'failed',
|
||||||
|
summary:
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runAgentForGroup Claude rotation', () => {
|
describe('runAgentForGroup Claude rotation', () => {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { createServiceHandoff, getAllTasks } from './db.js';
|
|||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './memento-client.js';
|
||||||
|
import {
|
||||||
|
completePairedExecutionContext,
|
||||||
|
preparePairedExecutionContext,
|
||||||
|
} from './paired-execution-context.js';
|
||||||
import { buildRoomRoleContext } from './room-role-context.js';
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import {
|
import {
|
||||||
classifyRotationTrigger,
|
classifyRotationTrigger,
|
||||||
@@ -126,9 +130,17 @@ export async function runAgentForGroup(
|
|||||||
currentLease,
|
currentLease,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
);
|
);
|
||||||
|
const pairedExecutionContext = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
runId,
|
||||||
|
roomRoleContext,
|
||||||
|
});
|
||||||
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
|
||||||
reviewerMode,
|
reviewerMode,
|
||||||
});
|
});
|
||||||
|
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
||||||
|
let pairedExecutionSummary: string | null = null;
|
||||||
|
|
||||||
const shouldHandoffToCodex = (
|
const shouldHandoffToCodex = (
|
||||||
reason: AgentTriggerReason,
|
reason: AgentTriggerReason,
|
||||||
@@ -188,6 +200,36 @@ export async function runAgentForGroup(
|
|||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (pairedExecutionContext?.blockMessage) {
|
||||||
|
pairedExecutionSummary = pairedExecutionContext.blockMessage.slice(0, 500);
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
group: group.name,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
runId,
|
||||||
|
serviceId: roomRoleContext?.serviceId,
|
||||||
|
role: roomRoleContext?.role,
|
||||||
|
},
|
||||||
|
'Blocked reviewer execution before review-ready snapshot was available',
|
||||||
|
);
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: pairedExecutionContext.blockMessage,
|
||||||
|
},
|
||||||
|
phase: 'final',
|
||||||
|
});
|
||||||
|
completePairedExecutionContext({
|
||||||
|
executionId: pairedExecutionContext.execution.id,
|
||||||
|
status: pairedExecutionStatus,
|
||||||
|
summary: pairedExecutionSummary,
|
||||||
|
});
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
const runAttempt = async (
|
const runAttempt = async (
|
||||||
provider: string,
|
provider: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -237,6 +279,11 @@ export async function runAgentForGroup(
|
|||||||
streamedState = evaluation.state;
|
streamedState = evaluation.state;
|
||||||
|
|
||||||
const outputText = getAgentOutputText(output);
|
const outputText = getAgentOutputText(output);
|
||||||
|
if (typeof outputText === 'string' && outputText.length > 0) {
|
||||||
|
pairedExecutionSummary = outputText.slice(0, 500);
|
||||||
|
} else if (typeof output.error === 'string' && output.error.length > 0) {
|
||||||
|
pairedExecutionSummary = output.error.slice(0, 500);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
evaluation.newTrigger &&
|
evaluation.newTrigger &&
|
||||||
typeof outputText === 'string' &&
|
typeof outputText === 'string' &&
|
||||||
@@ -345,6 +392,7 @@ export async function runAgentForGroup(
|
|||||||
(proc, processName, ipcDir) =>
|
(proc, processName, ipcDir) =>
|
||||||
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
|
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
|
||||||
wrappedOnOutput,
|
wrappedOnOutput,
|
||||||
|
pairedExecutionContext?.envOverrides,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (provider === 'claude' && output.newSessionId) {
|
if (provider === 'claude' && output.newSessionId) {
|
||||||
@@ -545,7 +593,8 @@ export async function runAgentForGroup(
|
|||||||
|
|
||||||
const provider = 'claude';
|
const provider = 'claude';
|
||||||
|
|
||||||
let primaryAttempt = await runAttempt(provider);
|
try {
|
||||||
|
let primaryAttempt = await runAttempt(provider);
|
||||||
|
|
||||||
const isRetryableClaudeSessionFailure = (
|
const isRetryableClaudeSessionFailure = (
|
||||||
attempt: Awaited<ReturnType<typeof runAttempt>>,
|
attempt: Awaited<ReturnType<typeof runAttempt>>,
|
||||||
@@ -605,6 +654,9 @@ export async function runAgentForGroup(
|
|||||||
if (result === 'error') {
|
if (result === 'error') {
|
||||||
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
|
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
|
||||||
}
|
}
|
||||||
|
if (result === 'success') {
|
||||||
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,7 +665,14 @@ export async function runAgentForGroup(
|
|||||||
const errMsg = getErrorMessage(primaryAttempt.error);
|
const errMsg = getErrorMessage(primaryAttempt.error);
|
||||||
const trigger = detectCodexRotationTrigger(errMsg);
|
const trigger = detectCodexRotationTrigger(errMsg);
|
||||||
if (trigger.shouldRotate && getCodexAccountCount() > 1) {
|
if (trigger.shouldRotate && getCodexAccountCount() > 1) {
|
||||||
return retryCodexWithRotation({ reason: trigger.reason }, errMsg);
|
const result = await retryCodexWithRotation(
|
||||||
|
{ reason: trigger.reason },
|
||||||
|
errMsg,
|
||||||
|
);
|
||||||
|
if (result === 'success') {
|
||||||
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +705,17 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!pairedExecutionSummary) {
|
||||||
|
const finalOutputText = getAgentOutputText(output);
|
||||||
|
pairedExecutionSummary =
|
||||||
|
(typeof finalOutputText === 'string' && finalOutputText.length > 0
|
||||||
|
? finalOutputText.slice(0, 500)
|
||||||
|
: null) ??
|
||||||
|
(typeof output.error === 'string' && output.error.length > 0
|
||||||
|
? output.error.slice(0, 500)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
canRotateToken &&
|
canRotateToken &&
|
||||||
provider === 'claude' &&
|
provider === 'claude' &&
|
||||||
@@ -697,6 +767,9 @@ export async function runAgentForGroup(
|
|||||||
if (result === 'error') {
|
if (result === 'error') {
|
||||||
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
|
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
|
||||||
}
|
}
|
||||||
|
if (result === 'success') {
|
||||||
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -704,10 +777,14 @@ export async function runAgentForGroup(
|
|||||||
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
|
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
|
||||||
const trigger = detectCodexRotationTrigger(output.error);
|
const trigger = detectCodexRotationTrigger(output.error);
|
||||||
if (trigger.shouldRotate) {
|
if (trigger.shouldRotate) {
|
||||||
return retryCodexWithRotation(
|
const result = await retryCodexWithRotation(
|
||||||
{ reason: trigger.reason },
|
{ reason: trigger.reason },
|
||||||
output.error ?? undefined,
|
output.error ?? undefined,
|
||||||
);
|
);
|
||||||
|
if (result === 'success') {
|
||||||
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,13 +806,17 @@ export async function runAgentForGroup(
|
|||||||
primaryAttempt.streamedTriggerReason &&
|
primaryAttempt.streamedTriggerReason &&
|
||||||
getCodexAccountCount() > 1
|
getCodexAccountCount() > 1
|
||||||
) {
|
) {
|
||||||
return retryCodexWithRotation(
|
const result = await retryCodexWithRotation(
|
||||||
{
|
{
|
||||||
reason: primaryAttempt.streamedTriggerReason
|
reason: primaryAttempt.streamedTriggerReason
|
||||||
.reason as CodexRotationReason,
|
.reason as CodexRotationReason,
|
||||||
},
|
},
|
||||||
output.error ?? output.result ?? undefined,
|
output.error ?? output.result ?? undefined,
|
||||||
);
|
);
|
||||||
|
if (result === 'success') {
|
||||||
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unresolved streamed trigger — rotation was unavailable or output was
|
// Unresolved streamed trigger — rotation was unavailable or output was
|
||||||
@@ -771,5 +852,15 @@ export async function runAgentForGroup(
|
|||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'success';
|
pairedExecutionStatus = 'succeeded';
|
||||||
|
return 'success';
|
||||||
|
} finally {
|
||||||
|
if (pairedExecutionContext) {
|
||||||
|
completePairedExecutionContext({
|
||||||
|
executionId: pairedExecutionContext.execution.id,
|
||||||
|
status: pairedExecutionStatus,
|
||||||
|
summary: pairedExecutionSummary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
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 { markRoomReviewReady } from './paired-execution-context.js';
|
||||||
|
import { buildRoomRoleContext } from './room-role-context.js';
|
||||||
import {
|
import {
|
||||||
extractSessionCommand,
|
extractSessionCommand,
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
@@ -50,7 +52,10 @@ import {
|
|||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { shouldServiceProcessChat } from './service-routing.js';
|
import {
|
||||||
|
getEffectiveChannelLease,
|
||||||
|
shouldServiceProcessChat,
|
||||||
|
} from './service-routing.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a message is a duplicate of the last bot final message in a paired room.
|
* Check if a message is a duplicate of the last bot final message in a paired room.
|
||||||
@@ -557,6 +562,27 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
markReviewReady: async () => {
|
||||||
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
|
lease,
|
||||||
|
lease.owner_service_id,
|
||||||
|
);
|
||||||
|
const result = markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
|
});
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'Review snapshot updated.',
|
||||||
|
`- Task: ${result.task.id}`,
|
||||||
|
`- Owner workspace: ${result.ownerWorkspace.workspace_dir}`,
|
||||||
|
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (cmdResult.handled) return cmdResult.success;
|
if (cmdResult.handled) return cmdResult.success;
|
||||||
|
|||||||
244
src/paired-execution-context.test.ts
Normal file
244
src/paired-execution-context.test.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('./db.js', () => ({
|
||||||
|
createPairedExecution: vi.fn(),
|
||||||
|
createPairedTask: vi.fn(),
|
||||||
|
getLatestOpenPairedTaskForChat: vi.fn(),
|
||||||
|
getPairedExecutionById: vi.fn(),
|
||||||
|
getPairedTaskById: vi.fn(),
|
||||||
|
getPairedWorkspace: vi.fn(),
|
||||||
|
updatePairedExecution: vi.fn(),
|
||||||
|
updatePairedTask: vi.fn(),
|
||||||
|
upsertPairedProject: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./paired-workspace-manager.js', () => ({
|
||||||
|
markPairedTaskReviewReady: vi.fn(),
|
||||||
|
provisionOwnerWorkspaceForPairedTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as db from './db.js';
|
||||||
|
import {
|
||||||
|
markRoomReviewReady,
|
||||||
|
preparePairedExecutionContext,
|
||||||
|
} from './paired-execution-context.js';
|
||||||
|
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
|
||||||
|
import type { RegisteredGroup, RoomRoleContext } from './types.js';
|
||||||
|
|
||||||
|
const group: RegisteredGroup = {
|
||||||
|
name: 'Paired Room',
|
||||||
|
folder: 'paired-room',
|
||||||
|
trigger: '@codex',
|
||||||
|
added_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerContext: RoomRoleContext = {
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
role: 'owner',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'codex-review',
|
||||||
|
failoverOwner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewerContext: RoomRoleContext = {
|
||||||
|
serviceId: 'codex-review',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'codex-review',
|
||||||
|
failoverOwner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('paired execution context', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
|
||||||
|
).mockReturnValue({
|
||||||
|
id: 'task-1:owner',
|
||||||
|
task_id: 'task-1',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_dir: '/tmp/paired/task-1/owner',
|
||||||
|
snapshot_source_dir: null,
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an owner execution with a worktree override', () => {
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-1',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.upsertPairedProject).toHaveBeenCalled();
|
||||||
|
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(db.createPairedExecution).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'run-1:codex-main',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_id: 'task-1:owner',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result?.envOverrides).toMatchObject({
|
||||||
|
EJCLAW_WORK_DIR: '/tmp/paired/task-1/owner',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'owner',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the reviewer snapshot only after review_ready and marks the task in_review', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
status: 'review_ready',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedWorkspace).mockReturnValue({
|
||||||
|
id: 'task-1:reviewer',
|
||||||
|
task_id: 'task-1',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_dir: '/tmp/paired/task-1/reviewer',
|
||||||
|
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-2',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({ status: 'in_review' }),
|
||||||
|
);
|
||||||
|
expect(result?.envOverrides).toMatchObject({
|
||||||
|
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
|
||||||
|
EJCLAW_REVIEWER_RUNTIME: '1',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'reviewer',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks reviewer execution before review_ready instead of falling back to the canonical repo', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-blocked-reviewer',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.blockMessage).toBe(
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
|
||||||
|
);
|
||||||
|
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
|
||||||
|
expect(db.getPairedWorkspace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active room task review-ready through the workspace manager', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
status: 'review_ready',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
|
||||||
|
{
|
||||||
|
ownerWorkspace: {
|
||||||
|
id: 'task-1:owner',
|
||||||
|
task_id: 'task-1',
|
||||||
|
role: 'owner',
|
||||||
|
workspace_dir: '/tmp/paired/task-1/owner',
|
||||||
|
snapshot_source_dir: null,
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
reviewerWorkspace: {
|
||||||
|
id: 'task-1:reviewer',
|
||||||
|
task_id: 'task-1',
|
||||||
|
role: 'reviewer',
|
||||||
|
workspace_dir: '/tmp/paired/task-1/reviewer',
|
||||||
|
snapshot_source_dir: '/tmp/paired/task-1/owner',
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = markRoomReviewReady({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pairedWorkspaceManager.markPairedTaskReviewReady).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
);
|
||||||
|
expect(result?.task.status).toBe('review_ready');
|
||||||
|
});
|
||||||
|
});
|
||||||
258
src/paired-execution-context.ts
Normal file
258
src/paired-execution-context.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPairedExecution,
|
||||||
|
createPairedTask,
|
||||||
|
getLatestOpenPairedTaskForChat,
|
||||||
|
getPairedExecutionById,
|
||||||
|
getPairedTaskById,
|
||||||
|
getPairedWorkspace,
|
||||||
|
updatePairedExecution,
|
||||||
|
updatePairedTask,
|
||||||
|
upsertPairedProject,
|
||||||
|
} from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import {
|
||||||
|
markPairedTaskReviewReady,
|
||||||
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
|
} from './paired-workspace-manager.js';
|
||||||
|
import type {
|
||||||
|
PairedExecution,
|
||||||
|
PairedTask,
|
||||||
|
PairedWorkspace,
|
||||||
|
RegisteredGroup,
|
||||||
|
RoomRoleContext,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function resolveCanonicalSourceRef(workDir: string): string {
|
||||||
|
try {
|
||||||
|
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||||
|
cwd: workDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
return head || 'HEAD';
|
||||||
|
} catch {
|
||||||
|
return 'HEAD';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePairedProject(
|
||||||
|
group: RegisteredGroup,
|
||||||
|
chatJid: string,
|
||||||
|
): string | null {
|
||||||
|
if (!group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
upsertPairedProject({
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
canonical_work_dir: group.workDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
return group.workDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveTask(
|
||||||
|
group: RegisteredGroup,
|
||||||
|
chatJid: string,
|
||||||
|
roomRoleContext: RoomRoleContext,
|
||||||
|
): PairedTask | null {
|
||||||
|
const canonicalWorkDir = ensurePairedProject(group, chatJid);
|
||||||
|
if (!canonicalWorkDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getLatestOpenPairedTaskForChat(chatJid);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const task: PairedTask = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: roomRoleContext.ownerServiceId,
|
||||||
|
reviewer_service_id: roomRoleContext.reviewerServiceId,
|
||||||
|
title: null,
|
||||||
|
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
createPairedTask(task);
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid,
|
||||||
|
groupFolder: group.folder,
|
||||||
|
taskId: task.id,
|
||||||
|
sourceRef: task.source_ref,
|
||||||
|
},
|
||||||
|
'Created active paired task for room',
|
||||||
|
);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExecutionRecord(args: {
|
||||||
|
runId: string;
|
||||||
|
roomRoleContext: RoomRoleContext;
|
||||||
|
task: PairedTask;
|
||||||
|
workspace?: PairedWorkspace;
|
||||||
|
}): PairedExecution {
|
||||||
|
const executionId = `${args.runId}:${args.roomRoleContext.serviceId}`;
|
||||||
|
const existing = getPairedExecutionById(executionId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
updatePairedExecution(existing.id, {
|
||||||
|
workspace_id: args.workspace?.id ?? existing.workspace_id,
|
||||||
|
status: 'running',
|
||||||
|
started_at: existing.started_at ?? now,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
workspace_id: args.workspace?.id ?? existing.workspace_id,
|
||||||
|
status: 'running',
|
||||||
|
started_at: existing.started_at ?? now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution: PairedExecution = {
|
||||||
|
id: executionId,
|
||||||
|
task_id: args.task.id,
|
||||||
|
service_id: args.roomRoleContext.serviceId,
|
||||||
|
role: args.roomRoleContext.role,
|
||||||
|
workspace_id: args.workspace?.id ?? null,
|
||||||
|
status: 'running',
|
||||||
|
summary: null,
|
||||||
|
created_at: now,
|
||||||
|
started_at: now,
|
||||||
|
completed_at: null,
|
||||||
|
};
|
||||||
|
createPairedExecution(execution);
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedPairedExecutionContext {
|
||||||
|
task: PairedTask;
|
||||||
|
execution: PairedExecution;
|
||||||
|
workspace: PairedWorkspace | null;
|
||||||
|
envOverrides: Record<string, string>;
|
||||||
|
blockMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePairedExecutionContext(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
}): PreparedPairedExecutionContext | undefined {
|
||||||
|
const { group, chatJid, runId, roomRoleContext } = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let workspace: PairedWorkspace | null = null;
|
||||||
|
let blockMessage: string | undefined;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (roomRoleContext.role === 'owner') {
|
||||||
|
workspace = provisionOwnerWorkspaceForPairedTask(task.id);
|
||||||
|
} else if (
|
||||||
|
task.status === 'review_ready' ||
|
||||||
|
task.status === 'in_review' ||
|
||||||
|
task.status === 'merge_ready' ||
|
||||||
|
task.status === 'changes_requested'
|
||||||
|
) {
|
||||||
|
workspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
|
||||||
|
if (workspace && task.status === 'review_ready') {
|
||||||
|
updatePairedTask(task.id, {
|
||||||
|
status: 'in_review',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blockMessage =
|
||||||
|
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const execution = ensureExecutionRecord({
|
||||||
|
runId,
|
||||||
|
roomRoleContext,
|
||||||
|
task,
|
||||||
|
workspace: workspace ?? undefined,
|
||||||
|
});
|
||||||
|
const envOverrides: Record<string, string> = {
|
||||||
|
EJCLAW_PAIRED_TASK_ID: task.id,
|
||||||
|
EJCLAW_PAIRED_EXECUTION_ID: execution.id,
|
||||||
|
EJCLAW_PAIRED_ROLE: roomRoleContext.role,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workspace?.workspace_dir) {
|
||||||
|
envOverrides.EJCLAW_WORK_DIR = workspace.workspace_dir;
|
||||||
|
}
|
||||||
|
if (roomRoleContext.role === 'reviewer') {
|
||||||
|
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
task: getPairedTaskById(task.id) ?? task,
|
||||||
|
execution,
|
||||||
|
workspace,
|
||||||
|
envOverrides,
|
||||||
|
blockMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completePairedExecutionContext(args: {
|
||||||
|
executionId: string;
|
||||||
|
status: 'succeeded' | 'failed';
|
||||||
|
summary?: string | null;
|
||||||
|
}): void {
|
||||||
|
updatePairedExecution(args.executionId, {
|
||||||
|
status: args.status,
|
||||||
|
summary: args.summary ?? null,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markRoomReviewReady(args: {
|
||||||
|
group: RegisteredGroup;
|
||||||
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
|
}): {
|
||||||
|
task: PairedTask;
|
||||||
|
ownerWorkspace: PairedWorkspace;
|
||||||
|
reviewerWorkspace: PairedWorkspace;
|
||||||
|
} | null {
|
||||||
|
const { group, chatJid, roomRoleContext } = args;
|
||||||
|
if (!roomRoleContext || !group.workDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = ensureActiveTask(group, chatJid, roomRoleContext);
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ownerWorkspace, reviewerWorkspace } = markPairedTaskReviewReady(
|
||||||
|
task.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
task: getPairedTaskById(task.id) ?? task,
|
||||||
|
ownerWorkspace,
|
||||||
|
reviewerWorkspace,
|
||||||
|
};
|
||||||
|
}
|
||||||
274
src/paired-workspace-manager.test.ts
Normal file
274
src/paired-workspace-manager.test.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd: string): string {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModules() {
|
||||||
|
const db = await import('./db.js');
|
||||||
|
const manager = await import('./paired-workspace-manager.js');
|
||||||
|
return { db, manager };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('paired workspace manager', () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
let previousDataDir: string | undefined;
|
||||||
|
let previousGroupsDir: string | undefined;
|
||||||
|
let previousStoreDir: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-paired-workspace-'));
|
||||||
|
previousDataDir = process.env.EJCLAW_DATA_DIR;
|
||||||
|
previousGroupsDir = process.env.EJCLAW_GROUPS_DIR;
|
||||||
|
previousStoreDir = process.env.EJCLAW_STORE_DIR;
|
||||||
|
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data');
|
||||||
|
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'groups');
|
||||||
|
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'store');
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousDataDir === undefined) delete process.env.EJCLAW_DATA_DIR;
|
||||||
|
else process.env.EJCLAW_DATA_DIR = previousDataDir;
|
||||||
|
if (previousGroupsDir === undefined) delete process.env.EJCLAW_GROUPS_DIR;
|
||||||
|
else process.env.EJCLAW_GROUPS_DIR = previousGroupsDir;
|
||||||
|
if (previousStoreDir === undefined) delete process.env.EJCLAW_STORE_DIR;
|
||||||
|
else process.env.EJCLAW_STORE_DIR = previousStoreDir;
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provisions an owner worktree and refreshes a reviewer shadow snapshot', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
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 now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: 'review the owner changes',
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-1');
|
||||||
|
expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'README.md'),
|
||||||
|
'owner modified\n',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ownerWorkspace.workspace_dir, 'NEW_FILE.txt'),
|
||||||
|
'review me\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { reviewerWorkspace } =
|
||||||
|
manager.markPairedTaskReviewReady('paired-task-1');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'README.md'), 'utf-8'),
|
||||||
|
).toBe('owner modified\n');
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(reviewerWorkspace.workspace_dir, 'NEW_FILE.txt'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('review me\n');
|
||||||
|
expect(
|
||||||
|
runGit(['config', '--local', '--get', 'remote.origin.pushurl'], reviewerWorkspace.workspace_dir),
|
||||||
|
).toBe('DISABLED_BY_EJCLAW');
|
||||||
|
expect(
|
||||||
|
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
|
||||||
|
).toContain('M README.md');
|
||||||
|
expect(
|
||||||
|
db.getPairedTaskById('paired-task-1')?.status,
|
||||||
|
).toBe('review_ready');
|
||||||
|
expect(
|
||||||
|
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces stale reviewer files on snapshot refresh', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
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, 'keep.txt'), 'keep\n');
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, 'remove.txt'), 'remove\n');
|
||||||
|
runGit(['add', 'keep.txt', 'remove.txt'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-2',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-2');
|
||||||
|
manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
|
||||||
|
|
||||||
|
fs.rmSync(path.join(ownerWorkspace.workspace_dir, 'remove.txt'));
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'keep.txt'), 'updated\n');
|
||||||
|
|
||||||
|
const reviewerWorkspace =
|
||||||
|
manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'remove.txt')),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'keep.txt'), 'utf-8'),
|
||||||
|
).toBe('updated\n');
|
||||||
|
expect(
|
||||||
|
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
|
||||||
|
).toContain('D remove.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters secrets, caches, and build outputs out of reviewer snapshots', async () => {
|
||||||
|
const { db, manager } = await loadModules();
|
||||||
|
db._initTestDatabase();
|
||||||
|
|
||||||
|
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, 'tracked.ts'), 'export const ok = 1;\n');
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, '.env.production'), 'TRACKED_SECRET=1\n');
|
||||||
|
fs.writeFileSync(path.join(canonicalDir, '.env.example'), 'EXAMPLE=1\n');
|
||||||
|
runGit(['add', 'tracked.ts', '.env.production', '.env.example'], canonicalDir);
|
||||||
|
runGit(['commit', '-m', 'initial'], canonicalDir);
|
||||||
|
|
||||||
|
const now = '2026-03-28T00:00:00.000Z';
|
||||||
|
db.upsertPairedProject({
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
canonical_work_dir: canonicalDir,
|
||||||
|
workspace_topology: 'shadow-snapshot',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
db.createPairedTask({
|
||||||
|
id: 'paired-task-3',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: 'paired-room',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: null,
|
||||||
|
status: 'draft',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerWorkspace =
|
||||||
|
manager.provisionOwnerWorkspaceForPairedTask('paired-task-3');
|
||||||
|
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'src'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'dist'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'logs'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'), 'export const draft = true;\n');
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, '.env.local'), 'SECRET=1\n');
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), 'cache\n');
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'dist', 'bundle.js'), 'dist\n');
|
||||||
|
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'logs', 'debug.log'), 'log\n');
|
||||||
|
|
||||||
|
const reviewerWorkspace =
|
||||||
|
manager.refreshReviewerSnapshotForPairedTask('paired-task-3');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(reviewerWorkspace.workspace_dir, 'src', 'draft.ts'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('export const draft = true;\n');
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.production')),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(reviewerWorkspace.workspace_dir, '.env.example'),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
).toBe('EXAMPLE=1\n');
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.local')),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(reviewerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js')),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log')),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
337
src/paired-workspace-manager.ts
Normal file
337
src/paired-workspace-manager.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPairedProject,
|
||||||
|
getPairedTaskById,
|
||||||
|
getPairedWorkspace,
|
||||||
|
updatePairedTask,
|
||||||
|
upsertPairedWorkspace,
|
||||||
|
} from './db.js';
|
||||||
|
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import type { PairedTask, PairedWorkspace } from './types.js';
|
||||||
|
|
||||||
|
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
|
||||||
|
'.git',
|
||||||
|
'.claude',
|
||||||
|
'.codex',
|
||||||
|
'.next',
|
||||||
|
'.turbo',
|
||||||
|
'.cache',
|
||||||
|
'node_modules',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'coverage',
|
||||||
|
'logs',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_EXTENSIONS = new Set([
|
||||||
|
'.c',
|
||||||
|
'.cc',
|
||||||
|
'.cpp',
|
||||||
|
'.css',
|
||||||
|
'.csv',
|
||||||
|
'.go',
|
||||||
|
'.graphql',
|
||||||
|
'.h',
|
||||||
|
'.hpp',
|
||||||
|
'.html',
|
||||||
|
'.java',
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.jsx',
|
||||||
|
'.kt',
|
||||||
|
'.md',
|
||||||
|
'.mjs',
|
||||||
|
'.prisma',
|
||||||
|
'.proto',
|
||||||
|
'.py',
|
||||||
|
'.rb',
|
||||||
|
'.rs',
|
||||||
|
'.scss',
|
||||||
|
'.sh',
|
||||||
|
'.sql',
|
||||||
|
'.svg',
|
||||||
|
'.swift',
|
||||||
|
'.toml',
|
||||||
|
'.ts',
|
||||||
|
'.tsx',
|
||||||
|
'.txt',
|
||||||
|
'.xml',
|
||||||
|
'.yaml',
|
||||||
|
'.yml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_BASENAMES = new Set([
|
||||||
|
'.editorconfig',
|
||||||
|
'.eslintignore',
|
||||||
|
'.eslintrc',
|
||||||
|
'.gitattributes',
|
||||||
|
'.gitignore',
|
||||||
|
'.npmrc',
|
||||||
|
'.nvmrc',
|
||||||
|
'.prettierignore',
|
||||||
|
'.prettierrc',
|
||||||
|
'Dockerfile',
|
||||||
|
'Makefile',
|
||||||
|
'README',
|
||||||
|
'README.md',
|
||||||
|
'package-lock.json',
|
||||||
|
'pnpm-lock.yaml',
|
||||||
|
'tsconfig.json',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function runGit(args: string[], cwd?: string): string {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGitRepository(repoDir: string): void {
|
||||||
|
const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree'], repoDir);
|
||||||
|
if (insideWorkTree !== 'true') {
|
||||||
|
throw new Error(`Not a git repository: ${repoDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCleanDirectory(targetDir: string): void {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDirectoryExceptGit(targetDir: string): void {
|
||||||
|
if (!fs.existsSync(targetDir)) return;
|
||||||
|
for (const entry of fs.readdirSync(targetDir)) {
|
||||||
|
if (entry === '.git') continue;
|
||||||
|
fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listGitPaths(repoDir: string, args: string[]): string[] {
|
||||||
|
const output = execFileSync('git', args, {
|
||||||
|
cwd: repoDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
return output
|
||||||
|
.split('\0')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReviewerSnapshotDeniedPath(relativePath: string): boolean {
|
||||||
|
const segments = relativePath.split(/[\\/]+/).filter(Boolean);
|
||||||
|
if (segments.some((segment) => REVIEWER_SNAPSHOT_DENY_SEGMENTS.has(segment))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = path.basename(relativePath);
|
||||||
|
if (basename === '.env') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
basename.startsWith('.env.') &&
|
||||||
|
basename !== '.env.example' &&
|
||||||
|
basename !== '.env.sample'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (basename.endsWith('.log')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludeUntrackedReviewerPath(relativePath: string): boolean {
|
||||||
|
if (isReviewerSnapshotDeniedPath(relativePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = path.basename(relativePath);
|
||||||
|
if (REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_BASENAMES.has(basename)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_EXTENSIONS.has(
|
||||||
|
path.extname(basename).toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySelectedSnapshotTree(sourceDir: string, targetDir: string): void {
|
||||||
|
resetDirectoryExceptGit(targetDir);
|
||||||
|
|
||||||
|
const trackedFiles = listGitPaths(sourceDir, [
|
||||||
|
'ls-files',
|
||||||
|
'--cached',
|
||||||
|
'-z',
|
||||||
|
]).filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath));
|
||||||
|
const untrackedFiles = listGitPaths(sourceDir, [
|
||||||
|
'ls-files',
|
||||||
|
'--others',
|
||||||
|
'--exclude-standard',
|
||||||
|
'-z',
|
||||||
|
]).filter(shouldIncludeUntrackedReviewerPath);
|
||||||
|
|
||||||
|
const filesToCopy = [...new Set([...trackedFiles, ...untrackedFiles])].sort();
|
||||||
|
|
||||||
|
for (const relativePath of filesToCopy) {
|
||||||
|
const sourcePath = path.join(sourceDir, relativePath);
|
||||||
|
if (!fs.existsSync(sourcePath)) continue;
|
||||||
|
|
||||||
|
const targetPath = path.join(targetDir, relativePath);
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.cpSync(sourcePath, targetPath, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureReviewerGitIsolation(workspaceDir: string): void {
|
||||||
|
try {
|
||||||
|
runGit(['config', '--local', 'push.default', 'nothing'], workspaceDir);
|
||||||
|
runGit(['config', '--local', 'credential.helper', ''], workspaceDir);
|
||||||
|
runGit(
|
||||||
|
['config', '--local', 'remote.origin.pushurl', 'DISABLED_BY_EJCLAW'],
|
||||||
|
workspaceDir,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ workspaceDir, error },
|
||||||
|
'Failed to apply reviewer git isolation settings',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskAndProject(taskId: string): {
|
||||||
|
task: PairedTask;
|
||||||
|
canonicalWorkDir: string;
|
||||||
|
} {
|
||||||
|
const task = getPairedTaskById(taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Paired task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = getPairedProject(task.chat_jid);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(`Paired project not found for chat: ${task.chat_jid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { task, canonicalWorkDir: project.canonical_work_dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWorkspaceRecord(args: {
|
||||||
|
taskId: string;
|
||||||
|
role: PairedWorkspace['role'];
|
||||||
|
workspaceDir: string;
|
||||||
|
snapshotSourceDir?: string | null;
|
||||||
|
snapshotRefreshedAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}): PairedWorkspace {
|
||||||
|
const existing = getPairedWorkspace(args.taskId, args.role);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: existing?.id || `${args.taskId}:${args.role}`,
|
||||||
|
task_id: args.taskId,
|
||||||
|
role: args.role,
|
||||||
|
workspace_dir: args.workspaceDir,
|
||||||
|
snapshot_source_dir: args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
|
||||||
|
status: 'ready',
|
||||||
|
snapshot_refreshed_at:
|
||||||
|
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
|
||||||
|
created_at: existing?.created_at || args.createdAt || now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provisionOwnerWorkspaceForPairedTask(
|
||||||
|
taskId: string,
|
||||||
|
): PairedWorkspace {
|
||||||
|
const { task, canonicalWorkDir } = getTaskAndProject(taskId);
|
||||||
|
ensureGitRepository(canonicalWorkDir);
|
||||||
|
|
||||||
|
const sourceRef = task.source_ref || 'HEAD';
|
||||||
|
const workspaceDir = resolvePairedTaskWorkspacePath(
|
||||||
|
task.group_folder,
|
||||||
|
task.id,
|
||||||
|
'owner',
|
||||||
|
);
|
||||||
|
const parentDir = path.dirname(workspaceDir);
|
||||||
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(workspaceDir, '.git'))) {
|
||||||
|
ensureCleanDirectory(parentDir);
|
||||||
|
runGit(['worktree', 'add', '--detach', workspaceDir, sourceRef], canonicalWorkDir);
|
||||||
|
logger.info(
|
||||||
|
{ taskId, workspaceDir, sourceRef },
|
||||||
|
'Provisioned owner workspace for paired task',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = makeWorkspaceRecord({
|
||||||
|
taskId,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
upsertPairedWorkspace(workspace);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshReviewerSnapshotForPairedTask(
|
||||||
|
taskId: string,
|
||||||
|
): PairedWorkspace {
|
||||||
|
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
|
||||||
|
ensureGitRepository(ownerWorkspace.workspace_dir);
|
||||||
|
|
||||||
|
const { task } = getTaskAndProject(taskId);
|
||||||
|
const reviewerDir = resolvePairedTaskWorkspacePath(
|
||||||
|
task.group_folder,
|
||||||
|
task.id,
|
||||||
|
'reviewer',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(reviewerDir), { recursive: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(reviewerDir, '.git'))) {
|
||||||
|
fs.rmSync(reviewerDir, { recursive: true, force: true });
|
||||||
|
runGit(['clone', '--shared', ownerWorkspace.workspace_dir, reviewerDir]);
|
||||||
|
}
|
||||||
|
|
||||||
|
runGit(['reset', '--hard', 'HEAD'], reviewerDir);
|
||||||
|
runGit(['clean', '-fdx'], reviewerDir);
|
||||||
|
copySelectedSnapshotTree(ownerWorkspace.workspace_dir, reviewerDir);
|
||||||
|
configureReviewerGitIsolation(reviewerDir);
|
||||||
|
|
||||||
|
const refreshedAt = new Date().toISOString();
|
||||||
|
const workspace = makeWorkspaceRecord({
|
||||||
|
taskId,
|
||||||
|
role: 'reviewer',
|
||||||
|
workspaceDir: reviewerDir,
|
||||||
|
snapshotSourceDir: ownerWorkspace.workspace_dir,
|
||||||
|
snapshotRefreshedAt: refreshedAt,
|
||||||
|
});
|
||||||
|
upsertPairedWorkspace(workspace);
|
||||||
|
logger.info(
|
||||||
|
{ taskId, reviewerDir, snapshotSourceDir: ownerWorkspace.workspace_dir },
|
||||||
|
'Refreshed reviewer snapshot for paired task',
|
||||||
|
);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPairedTaskReviewReady(taskId: string): {
|
||||||
|
ownerWorkspace: PairedWorkspace;
|
||||||
|
reviewerWorkspace: PairedWorkspace;
|
||||||
|
} {
|
||||||
|
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
|
||||||
|
const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'review_ready',
|
||||||
|
review_requested_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ownerWorkspace, reviewerWorkspace };
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects bare /review-ready', () => {
|
||||||
|
expect(extractSessionCommand('/review-ready', trigger)).toBe(
|
||||||
|
'/review-ready',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
@@ -120,6 +126,7 @@ function makeDeps(
|
|||||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||||
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.'),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,6 +185,31 @@ describe('handleSessionCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles authorized /review-ready without invoking the agent', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
markReviewReady: vi.fn().mockResolvedValue(
|
||||||
|
'Review snapshot updated.\n- Task: paired-task-1',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleSessionCommand({
|
||||||
|
missedMessages: [makeMsg('/review-ready')],
|
||||||
|
isMainGroup: true,
|
||||||
|
groupName: 'test',
|
||||||
|
triggerPattern: trigger,
|
||||||
|
timezone: 'UTC',
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ handled: true, success: true });
|
||||||
|
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||||
|
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||||
|
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||||
|
'Review snapshot updated.\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({
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
|
|||||||
/^Failed to process messages before \/compact\. Try again\.$/,
|
/^Failed to process messages before \/compact\. Try again\.$/,
|
||||||
/^\/compact failed\. The session is unchanged\.$/,
|
/^\/compact failed\. The session is unchanged\.$/,
|
||||||
/^Conversation compacted\.$/,
|
/^Conversation compacted\.$/,
|
||||||
|
/^Review snapshot updated\.$/,
|
||||||
|
/^Review-ready is unavailable for this room\./,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +26,7 @@ export function extractSessionCommand(
|
|||||||
text = text.replace(triggerPattern, '').trim();
|
text = text.replace(triggerPattern, '').trim();
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
if (text === '/clear') return '/clear';
|
if (text === '/clear') return '/clear';
|
||||||
|
if (text === '/review-ready') return '/review-ready';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,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: () => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultToText(result: string | object | null | undefined): string {
|
function resultToText(result: string | object | null | undefined): string {
|
||||||
@@ -149,6 +153,16 @@ export async function handleSessionCommand(opts: {
|
|||||||
return { handled: true, success: true };
|
return { handled: true, success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command === '/review-ready') {
|
||||||
|
const result = await deps.markReviewReady();
|
||||||
|
deps.advanceCursor(cmdMsg.timestamp);
|
||||||
|
await deps.sendMessage(
|
||||||
|
result ??
|
||||||
|
'Review-ready is unavailable for this room. Paired workspaces require a configured project workDir.',
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
102
src/types.ts
102
src/types.ts
@@ -25,6 +25,38 @@ 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 =
|
||||||
|
| 'draft'
|
||||||
|
| 'review_ready'
|
||||||
|
| 'in_review'
|
||||||
|
| 'changes_requested'
|
||||||
|
| 'merge_ready'
|
||||||
|
| 'merged'
|
||||||
|
| 'discarded'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export type PairedExecutionStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export type PairedWorkspaceRole = 'owner' | 'reviewer';
|
||||||
|
|
||||||
|
export type PairedWorkspaceStatus =
|
||||||
|
| 'provisioning'
|
||||||
|
| 'ready'
|
||||||
|
| 'stale'
|
||||||
|
| 'failed'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
|
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
export type PairedArtifactType = 'comment' | 'report' | 'patch';
|
||||||
|
|
||||||
export interface RoomRoleContext {
|
export interface RoomRoleContext {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
role: PairedRoomRole;
|
role: PairedRoomRole;
|
||||||
@@ -33,6 +65,76 @@ export interface RoomRoleContext {
|
|||||||
failoverOwner: boolean;
|
failoverOwner: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PairedProject {
|
||||||
|
chat_jid: string;
|
||||||
|
group_folder: string;
|
||||||
|
canonical_work_dir: string;
|
||||||
|
workspace_topology: PairedWorkspaceTopology;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedTask {
|
||||||
|
id: string;
|
||||||
|
chat_jid: string;
|
||||||
|
group_folder: string;
|
||||||
|
owner_service_id: string;
|
||||||
|
reviewer_service_id: string;
|
||||||
|
title: string | null;
|
||||||
|
source_ref: string | null;
|
||||||
|
review_requested_at: string | null;
|
||||||
|
status: PairedTaskStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedExecution {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
service_id: string;
|
||||||
|
role: PairedWorkspaceRole;
|
||||||
|
workspace_id: string | null;
|
||||||
|
status: PairedExecutionStatus;
|
||||||
|
summary: string | null;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedWorkspace {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
role: PairedWorkspaceRole;
|
||||||
|
workspace_dir: string;
|
||||||
|
snapshot_source_dir: string | null;
|
||||||
|
status: PairedWorkspaceStatus;
|
||||||
|
snapshot_refreshed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedApproval {
|
||||||
|
id: number;
|
||||||
|
task_id: string;
|
||||||
|
service_id: string;
|
||||||
|
role: PairedWorkspaceRole;
|
||||||
|
status: PairedApprovalStatus;
|
||||||
|
note: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairedArtifact {
|
||||||
|
id: number;
|
||||||
|
task_id: string;
|
||||||
|
execution_id: string | null;
|
||||||
|
service_id: string;
|
||||||
|
artifact_type: PairedArtifactType;
|
||||||
|
title: string | null;
|
||||||
|
content: string | null;
|
||||||
|
file_path: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type StructuredAgentOutput =
|
export type StructuredAgentOutput =
|
||||||
| {
|
| {
|
||||||
visibility: 'public';
|
visibility: 'public';
|
||||||
|
|||||||
Reference in New Issue
Block a user