feat: add paired review workspace flow

This commit is contained in:
Eyejoker
2026-03-28 21:14:25 +09:00
parent 79a8a2639e
commit e7f49d77da
19 changed files with 2719 additions and 20 deletions

View File

@@ -6,6 +6,10 @@ import {
_initTestDatabase,
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
createPairedApproval,
createPairedArtifact,
createPairedExecution,
createPairedTask,
createTask,
createServiceHandoff,
createProducedWorkItem,
@@ -22,10 +26,17 @@ import {
getRegisteredAgentTypesForJid,
getMessagesSince,
getNewMessages,
getPairedExecutionById,
getPairedProject,
getPairedTaskById,
getPairedWorkspace,
getRouterStateForService,
isPairedRoomJid,
getSession,
getTaskById,
listPairedApprovalsForTask,
listPairedArtifactsForTask,
listPairedWorkspacesForTask,
markWorkItemDelivered,
markWorkItemDeliveryRetry,
setSession,
@@ -33,6 +44,10 @@ import {
setRouterStateForService,
storeChatMetadata,
storeMessage,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
upsertPairedWorkspace,
updateTask,
} from './db.js';
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 ---
describe('message query LIMIT', () => {

441
src/db.ts
View File

@@ -25,6 +25,12 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js';
import {
NewMessage,
AgentType,
PairedApproval,
PairedArtifact,
PairedExecution,
PairedProject,
PairedTask,
PairedWorkspace,
RegisteredGroup,
ScheduledTask,
TaskRunLog,
@@ -230,6 +236,100 @@ function createSchema(database: Database.Database): void {
PRIMARY KEY (jid, 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 (
chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL,
@@ -1638,6 +1738,347 @@ export function isPairedRoomJid(jid: string): boolean {
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.
* Used for duplicate detection in pair rooms.

View File

@@ -129,3 +129,32 @@ export function resolveServiceTaskSessionsPath(
ensureWithinBase(sessionsBaseDir, 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;
}

View File

@@ -108,11 +108,18 @@ vi.mock('./memento-client.js', () => ({
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 codexTokenRotation from './codex-token-rotation.js';
import * as db from './db.js';
import { buildRoomMemoryBriefing } from './memento-client.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 serviceRouting from './service-routing.js';
import * as tokenRotation from './token-rotation.js';
@@ -180,6 +187,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -208,6 +216,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -231,6 +240,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -257,6 +267,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -288,8 +299,179 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
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', () => {

View File

@@ -12,6 +12,10 @@ import { createServiceHandoff, getAllTasks } from './db.js';
import { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,
} from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js';
import {
classifyRotationTrigger,
@@ -126,9 +130,17 @@ export async function runAgentForGroup(
currentLease,
SERVICE_SESSION_SCOPE,
);
const pairedExecutionContext = preparePairedExecutionContext({
group,
chatJid,
runId,
roomRoleContext,
});
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
reviewerMode,
});
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null;
const shouldHandoffToCodex = (
reason: AgentTriggerReason,
@@ -188,6 +200,36 @@ export async function runAgentForGroup(
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 (
provider: string,
): Promise<{
@@ -237,6 +279,11 @@ export async function runAgentForGroup(
streamedState = evaluation.state;
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 (
evaluation.newTrigger &&
typeof outputText === 'string' &&
@@ -345,6 +392,7 @@ export async function runAgentForGroup(
(proc, processName, ipcDir) =>
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
wrappedOnOutput,
pairedExecutionContext?.envOverrides,
);
if (provider === 'claude' && output.newSessionId) {
@@ -545,7 +593,8 @@ export async function runAgentForGroup(
const provider = 'claude';
let primaryAttempt = await runAttempt(provider);
try {
let primaryAttempt = await runAttempt(provider);
const isRetryableClaudeSessionFailure = (
attempt: Awaited<ReturnType<typeof runAttempt>>,
@@ -605,6 +654,9 @@ export async function runAgentForGroup(
if (result === 'error') {
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
}
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -613,7 +665,14 @@ export async function runAgentForGroup(
const errMsg = getErrorMessage(primaryAttempt.error);
const trigger = detectCodexRotationTrigger(errMsg);
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';
}
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 (
canRotateToken &&
provider === 'claude' &&
@@ -697,6 +767,9 @@ export async function runAgentForGroup(
if (result === 'error') {
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
}
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -704,10 +777,14 @@ export async function runAgentForGroup(
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
const trigger = detectCodexRotationTrigger(output.error);
if (trigger.shouldRotate) {
return retryCodexWithRotation(
const result = await retryCodexWithRotation(
{ reason: trigger.reason },
output.error ?? undefined,
);
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -729,13 +806,17 @@ export async function runAgentForGroup(
primaryAttempt.streamedTriggerReason &&
getCodexAccountCount() > 1
) {
return retryCodexWithRotation(
const result = await retryCodexWithRotation(
{
reason: primaryAttempt.streamedTriggerReason
.reason as CodexRotationReason,
},
output.error ?? output.result ?? undefined,
);
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
// Unresolved streamed trigger — rotation was unavailable or output was
@@ -771,5 +852,15 @@ export async function runAgentForGroup(
return 'error';
}
return 'success';
pairedExecutionStatus = 'succeeded';
return 'success';
} finally {
if (pairedExecutionContext) {
completePairedExecutionContext({
executionId: pairedExecutionContext.execution.id,
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});
}
}
}

View File

@@ -41,6 +41,8 @@ import {
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js';
import { markRoomReviewReady } from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js';
import {
extractSessionCommand,
handleSessionCommand,
@@ -50,7 +52,10 @@ import {
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.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.
@@ -557,6 +562,27 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
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;

View 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');
});
});

View 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,
};
}

View 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);
});
});

View 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 };
}

View File

@@ -23,6 +23,12 @@ describe('extractSessionCommand', () => {
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', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
@@ -120,6 +126,7 @@ function makeDeps(
formatMessages: vi.fn().mockReturnValue('<formatted>'),
isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
...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 () => {
const deps = makeDeps();
const result = await handleSessionCommand({

View File

@@ -10,6 +10,8 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Failed to process messages before \/compact\. Try again\.$/,
/^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/,
/^Review snapshot updated\.$/,
/^Review-ready is unavailable for this room\./,
];
/**
@@ -24,6 +26,7 @@ export function extractSessionCommand(
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear';
if (text === '/review-ready') return '/review-ready';
return null;
}
@@ -68,6 +71,7 @@ export interface SessionCommandDeps {
isAdminSender: (msg: NewMessage) => boolean;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
markReviewReady: () => Promise<string | null>;
}
function resultToText(result: string | object | null | undefined): string {
@@ -149,6 +153,16 @@ export async function handleSessionCommand(opts: {
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 preCompactMsgs = missedMessages.slice(0, cmdIndex);

View File

@@ -25,6 +25,38 @@ export type AgentVisibility = 'public' | 'silent';
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 {
serviceId: string;
role: PairedRoomRole;
@@ -33,6 +65,76 @@ export interface RoomRoleContext {
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 =
| {
visibility: 'public';