feat: scaffold paired governance state
This commit is contained in:
132
src/db.test.ts
132
src/db.test.ts
@@ -1,9 +1,12 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
_initTestDatabaseFromFile,
|
||||
claimServiceHandoff,
|
||||
completeServiceHandoffAndAdvanceTargetCursor,
|
||||
createPairedApproval,
|
||||
@@ -563,6 +566,9 @@ describe('paired task state', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: 'wire up workspaces',
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -608,7 +614,7 @@ describe('paired task state', () => {
|
||||
task_id: 'paired-task-1',
|
||||
execution_id: 'paired-exec-1',
|
||||
service_id: 'codex-review',
|
||||
artifact_type: 'report',
|
||||
artifact_type: 'plan_brief',
|
||||
title: 'review notes',
|
||||
content: 'looks fine',
|
||||
file_path: null,
|
||||
@@ -619,6 +625,11 @@ describe('paired task state', () => {
|
||||
'/tmp/paired-room',
|
||||
);
|
||||
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
|
||||
expect(getPairedTaskById('paired-task-1')?.task_policy).toBe('autonomous');
|
||||
expect(getPairedTaskById('paired-task-1')?.risk_level).toBe('low');
|
||||
expect(getPairedTaskById('paired-task-1')?.plan_status).toBe(
|
||||
'not_requested',
|
||||
);
|
||||
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
|
||||
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
||||
'/tmp/paired-room/owner',
|
||||
@@ -627,6 +638,116 @@ describe('paired task state', () => {
|
||||
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
|
||||
});
|
||||
|
||||
it('backfills governance plan_status by legacy task status during migration', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-migration-'));
|
||||
const dbPath = path.join(tempRoot, 'messages.db');
|
||||
const legacyDb = new Database(dbPath);
|
||||
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE paired_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
chat_jid TEXT NOT NULL,
|
||||
group_folder TEXT NOT NULL,
|
||||
owner_service_id TEXT NOT NULL,
|
||||
reviewer_service_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
review_requested_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const insertLegacyTask = legacyDb.prepare(`
|
||||
INSERT INTO paired_tasks (
|
||||
id,
|
||||
chat_jid,
|
||||
group_folder,
|
||||
owner_service_id,
|
||||
reviewer_service_id,
|
||||
title,
|
||||
source_ref,
|
||||
review_requested_at,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertLegacyTask.run(
|
||||
'paired-active',
|
||||
'dc:paired',
|
||||
'paired-room',
|
||||
'codex-main',
|
||||
'codex-review',
|
||||
null,
|
||||
'HEAD',
|
||||
null,
|
||||
'active',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
);
|
||||
insertLegacyTask.run(
|
||||
'paired-draft',
|
||||
'dc:paired',
|
||||
'paired-room',
|
||||
'codex-main',
|
||||
'codex-review',
|
||||
null,
|
||||
'HEAD',
|
||||
null,
|
||||
'draft',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
);
|
||||
insertLegacyTask.run(
|
||||
'paired-in-review',
|
||||
'dc:paired',
|
||||
'paired-room',
|
||||
'codex-main',
|
||||
'codex-review',
|
||||
null,
|
||||
'HEAD',
|
||||
'2026-03-29T00:01:00.000Z',
|
||||
'in_review',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
'2026-03-29T00:01:00.000Z',
|
||||
);
|
||||
insertLegacyTask.run(
|
||||
'paired-merge-ready',
|
||||
'dc:paired',
|
||||
'paired-room',
|
||||
'codex-main',
|
||||
'codex-review',
|
||||
null,
|
||||
'HEAD',
|
||||
'2026-03-29T00:02:00.000Z',
|
||||
'merge_ready',
|
||||
'2026-03-29T00:00:00.000Z',
|
||||
'2026-03-29T00:02:00.000Z',
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
_initTestDatabaseFromFile(dbPath);
|
||||
|
||||
expect(getPairedTaskById('paired-in-review')?.plan_status).toBe(
|
||||
'approved',
|
||||
);
|
||||
expect(getPairedTaskById('paired-merge-ready')?.plan_status).toBe(
|
||||
'approved',
|
||||
);
|
||||
expect(getPairedTaskById('paired-active')?.plan_status).toBe(
|
||||
'not_requested',
|
||||
);
|
||||
expect(getPairedTaskById('paired-draft')?.plan_status).toBe(
|
||||
'not_requested',
|
||||
);
|
||||
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates task and execution state and keeps one workspace per role', () => {
|
||||
createPairedTask({
|
||||
id: 'paired-task-2',
|
||||
@@ -636,6 +757,9 @@ describe('paired task state', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: null,
|
||||
task_policy: 'user_signoff_required',
|
||||
risk_level: 'high',
|
||||
plan_status: 'pending',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -655,6 +779,9 @@ describe('paired task state', () => {
|
||||
});
|
||||
|
||||
updatePairedTask('paired-task-2', {
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'approved',
|
||||
status: 'review_pending',
|
||||
review_requested_at: '2026-03-28T00:10:00.000Z',
|
||||
updated_at: '2026-03-28T00:10:00.000Z',
|
||||
@@ -690,6 +817,9 @@ describe('paired task state', () => {
|
||||
});
|
||||
|
||||
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
|
||||
expect(getPairedTaskById('paired-task-2')?.task_policy).toBe('autonomous');
|
||||
expect(getPairedTaskById('paired-task-2')?.risk_level).toBe('low');
|
||||
expect(getPairedTaskById('paired-task-2')?.plan_status).toBe('approved');
|
||||
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
|
||||
expect(
|
||||
listPairedWorkspacesForTask('paired-task-2').map(
|
||||
|
||||
173
src/db.ts
173
src/db.ts
@@ -253,13 +253,28 @@ function createSchema(database: Database.Database): void {
|
||||
reviewer_service_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
task_policy TEXT NOT NULL DEFAULT 'autonomous',
|
||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
||||
plan_status TEXT NOT NULL DEFAULT 'not_requested',
|
||||
review_requested_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
||||
CHECK (risk_level IN ('low', 'high')),
|
||||
CHECK (
|
||||
plan_status IN (
|
||||
'not_requested',
|
||||
'pending',
|
||||
'approved',
|
||||
'changes_requested'
|
||||
)
|
||||
),
|
||||
CHECK (
|
||||
status IN (
|
||||
'active',
|
||||
'draft',
|
||||
'plan_review_pending',
|
||||
'review_pending',
|
||||
'review_ready',
|
||||
'in_review',
|
||||
@@ -328,7 +343,16 @@ function createSchema(database: Database.Database): void {
|
||||
content TEXT,
|
||||
file_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (artifact_type IN ('comment', 'report', 'patch'))
|
||||
CHECK (
|
||||
artifact_type IN (
|
||||
'comment',
|
||||
'report',
|
||||
'patch',
|
||||
'plan_brief',
|
||||
'acceptance_criteria',
|
||||
'risk_summary'
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
|
||||
ON paired_artifacts(task_id, created_at);
|
||||
@@ -624,7 +648,15 @@ function createSchema(database: Database.Database): void {
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
||||
if (pairedTasksSql && !pairedTasksSql.includes("'review_pending'")) {
|
||||
const pairedTasksNeedsMigration =
|
||||
pairedTasksSql &&
|
||||
(!pairedTasksSql.includes("'review_pending'") ||
|
||||
!pairedTasksSql.includes("'active'") ||
|
||||
!pairedTasksSql.includes("'plan_review_pending'") ||
|
||||
!pairedTasksSql.includes('task_policy TEXT') ||
|
||||
!pairedTasksSql.includes('risk_level TEXT') ||
|
||||
!pairedTasksSql.includes('plan_status TEXT'));
|
||||
if (pairedTasksNeedsMigration) {
|
||||
database.exec(`
|
||||
CREATE TABLE paired_tasks_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -634,13 +666,28 @@ function createSchema(database: Database.Database): void {
|
||||
reviewer_service_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_ref TEXT,
|
||||
task_policy TEXT NOT NULL DEFAULT 'autonomous',
|
||||
risk_level TEXT NOT NULL DEFAULT 'low',
|
||||
plan_status TEXT NOT NULL DEFAULT 'not_requested',
|
||||
review_requested_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
||||
CHECK (risk_level IN ('low', 'high')),
|
||||
CHECK (
|
||||
plan_status IN (
|
||||
'not_requested',
|
||||
'pending',
|
||||
'approved',
|
||||
'changes_requested'
|
||||
)
|
||||
),
|
||||
CHECK (
|
||||
status IN (
|
||||
'active',
|
||||
'draft',
|
||||
'plan_review_pending',
|
||||
'review_pending',
|
||||
'review_ready',
|
||||
'in_review',
|
||||
@@ -662,6 +709,9 @@ function createSchema(database: Database.Database): void {
|
||||
reviewer_service_id,
|
||||
title,
|
||||
source_ref,
|
||||
task_policy,
|
||||
risk_level,
|
||||
plan_status,
|
||||
review_requested_at,
|
||||
status,
|
||||
created_at,
|
||||
@@ -675,6 +725,19 @@ function createSchema(database: Database.Database): void {
|
||||
reviewer_service_id,
|
||||
title,
|
||||
source_ref,
|
||||
'autonomous',
|
||||
'low',
|
||||
CASE
|
||||
WHEN status IN (
|
||||
'review_pending',
|
||||
'review_ready',
|
||||
'in_review',
|
||||
'changes_requested',
|
||||
'merge_ready',
|
||||
'merged'
|
||||
) THEN 'approved'
|
||||
ELSE 'not_requested'
|
||||
END,
|
||||
review_requested_at,
|
||||
status,
|
||||
created_at,
|
||||
@@ -691,6 +754,73 @@ function createSchema(database: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
|
||||
const pairedArtifactsSqlRow = database
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`,
|
||||
)
|
||||
.get() as { sql?: string } | undefined;
|
||||
const pairedArtifactsSql = pairedArtifactsSqlRow?.sql || '';
|
||||
const pairedArtifactsNeedsMigration =
|
||||
pairedArtifactsSql &&
|
||||
!pairedArtifactsSql.includes("'plan_brief'");
|
||||
if (pairedArtifactsNeedsMigration) {
|
||||
database.exec(`
|
||||
CREATE TABLE paired_artifacts_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
execution_id TEXT,
|
||||
service_id TEXT NOT NULL,
|
||||
artifact_type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
file_path TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
artifact_type IN (
|
||||
'comment',
|
||||
'report',
|
||||
'patch',
|
||||
'plan_brief',
|
||||
'acceptance_criteria',
|
||||
'risk_summary'
|
||||
)
|
||||
)
|
||||
);
|
||||
`);
|
||||
database.exec(`
|
||||
INSERT INTO paired_artifacts_new (
|
||||
id,
|
||||
task_id,
|
||||
execution_id,
|
||||
service_id,
|
||||
artifact_type,
|
||||
title,
|
||||
content,
|
||||
file_path,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
task_id,
|
||||
execution_id,
|
||||
service_id,
|
||||
artifact_type,
|
||||
title,
|
||||
content,
|
||||
file_path,
|
||||
created_at
|
||||
FROM paired_artifacts;
|
||||
`);
|
||||
database.exec(`
|
||||
DROP TABLE paired_artifacts;
|
||||
ALTER TABLE paired_artifacts_new RENAME TO paired_artifacts;
|
||||
`);
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
|
||||
ON paired_artifacts(task_id, created_at);
|
||||
`);
|
||||
}
|
||||
|
||||
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||
const sessionCols = database
|
||||
.prepare('PRAGMA table_info(sessions)')
|
||||
@@ -757,6 +887,12 @@ export function _initTestDatabase(): void {
|
||||
createSchema(db);
|
||||
}
|
||||
|
||||
/** @internal - for tests only. Opens an existing database file and runs schema/migrations. */
|
||||
export function _initTestDatabaseFromFile(dbPath: string): void {
|
||||
db = new Database(dbPath);
|
||||
createSchema(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store chat metadata only (no message content).
|
||||
* Used for all chats to enable group discovery without storing sensitive content.
|
||||
@@ -1868,12 +2004,15 @@ export function createPairedTask(task: PairedTask): void {
|
||||
reviewer_service_id,
|
||||
title,
|
||||
source_ref,
|
||||
task_policy,
|
||||
risk_level,
|
||||
plan_status,
|
||||
review_requested_at,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
task.id,
|
||||
@@ -1883,6 +2022,9 @@ export function createPairedTask(task: PairedTask): void {
|
||||
task.reviewer_service_id,
|
||||
task.title,
|
||||
task.source_ref,
|
||||
task.task_policy,
|
||||
task.risk_level,
|
||||
task.plan_status,
|
||||
task.review_requested_at,
|
||||
task.status,
|
||||
task.created_at,
|
||||
@@ -1918,7 +2060,14 @@ export function updatePairedTask(
|
||||
updates: Partial<
|
||||
Pick<
|
||||
PairedTask,
|
||||
'title' | 'source_ref' | 'review_requested_at' | 'status' | 'updated_at'
|
||||
| 'title'
|
||||
| 'source_ref'
|
||||
| 'task_policy'
|
||||
| 'risk_level'
|
||||
| 'plan_status'
|
||||
| 'review_requested_at'
|
||||
| 'status'
|
||||
| 'updated_at'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
@@ -1933,6 +2082,18 @@ export function updatePairedTask(
|
||||
fields.push('source_ref = ?');
|
||||
values.push(updates.source_ref);
|
||||
}
|
||||
if (updates.task_policy !== undefined) {
|
||||
fields.push('task_policy = ?');
|
||||
values.push(updates.task_policy);
|
||||
}
|
||||
if (updates.risk_level !== undefined) {
|
||||
fields.push('risk_level = ?');
|
||||
values.push(updates.risk_level);
|
||||
}
|
||||
if (updates.plan_status !== undefined) {
|
||||
fields.push('plan_status = ?');
|
||||
values.push(updates.plan_status);
|
||||
}
|
||||
if (updates.review_requested_at !== undefined) {
|
||||
fields.push('review_requested_at = ?');
|
||||
values.push(updates.review_requested_at);
|
||||
|
||||
@@ -321,6 +321,9 @@ describe('runAgentForGroup room memory', () => {
|
||||
reviewer_service_id: 'codex-main',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -415,6 +418,9 @@ describe('runAgentForGroup room memory', () => {
|
||||
reviewer_service_id: 'claude',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'high',
|
||||
plan_status: 'approved',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
|
||||
@@ -25,11 +25,21 @@ vi.mock('./config.js', () => ({
|
||||
SERVICE_ID: 'claude',
|
||||
SERVICE_AGENT_TYPE: 'claude-code',
|
||||
SERVICE_SESSION_SCOPE: 'claude',
|
||||
CODEX_MAIN_SERVICE_ID: 'codex-main',
|
||||
CODEX_REVIEW_SERVICE_ID: 'codex-review',
|
||||
normalizeServiceId: vi.fn((serviceId: string) => serviceId),
|
||||
isClaudeService: vi.fn(() => true),
|
||||
isReviewService: vi.fn(() => false),
|
||||
isSessionCommandSenderAllowed: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('./paired-execution-context.js', () => ({
|
||||
preparePairedExecutionContext: vi.fn(() => undefined),
|
||||
completePairedExecutionContext: vi.fn(),
|
||||
markRoomReviewReady: vi.fn(() => null),
|
||||
formatRoomReviewReadyMessage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('./db.js', () => {
|
||||
const getMessagesSince = vi.fn(
|
||||
(
|
||||
@@ -171,6 +181,8 @@ import { resolveGroupIpcPath } from './group-folder.js';
|
||||
import { createMessageRuntime } from './message-runtime.js';
|
||||
import * as outputSuppression from './output-suppression.js';
|
||||
import * as config from './config.js';
|
||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||
import * as sessionCommands from './session-commands.js';
|
||||
import type { Channel, RegisteredGroup } from './types.js';
|
||||
|
||||
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
||||
@@ -207,6 +219,100 @@ describe('createMessageRuntime', () => {
|
||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('surfaces the pending review message through the message-runtime /review path', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = {
|
||||
...makeGroup('codex'),
|
||||
workDir: '/repo/canonical',
|
||||
};
|
||||
const channel = makeChannel(chatJid);
|
||||
const saveState = vi.fn();
|
||||
const lastAgentTimestamps: Record<string, string> = {};
|
||||
const pendingMessage = [
|
||||
'Review request recorded, but the owner workspace is not ready yet.',
|
||||
'- Task: paired-task-1',
|
||||
'The task stays review_pending until the owner workspace is prepared.',
|
||||
].join('\n');
|
||||
|
||||
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||
{
|
||||
id: 'msg-review',
|
||||
chat_jid: chatJid,
|
||||
sender: 'me@test',
|
||||
sender_name: 'Me',
|
||||
content: '/review',
|
||||
timestamp: '2026-03-29T00:00:00.000Z',
|
||||
is_from_me: true,
|
||||
},
|
||||
]);
|
||||
const actualSessionCommands =
|
||||
await vi.importActual<typeof import('./session-commands.js')>(
|
||||
'./session-commands.js',
|
||||
);
|
||||
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
|
||||
actualSessionCommands.handleSessionCommand(opts),
|
||||
);
|
||||
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
|
||||
status: 'pending',
|
||||
task: {
|
||||
id: 'paired-task-1',
|
||||
chat_jid: chatJid,
|
||||
group_folder: group.folder,
|
||||
owner_service_id: 'claude',
|
||||
reviewer_service_id: 'codex-main',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: '2026-03-29T00:00:00.000Z',
|
||||
status: 'review_pending',
|
||||
created_at: '2026-03-29T00:00:00.000Z',
|
||||
updated_at: '2026-03-29T00:00:00.000Z',
|
||||
},
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
});
|
||||
vi.mocked(
|
||||
pairedExecutionContext.formatRoomReviewReadyMessage,
|
||||
).mockReturnValue(pendingMessage);
|
||||
|
||||
const runtime = createMessageRuntime({
|
||||
assistantName: 'Andy',
|
||||
idleTimeout: 1_000,
|
||||
pollInterval: 1_000,
|
||||
timezone: 'UTC',
|
||||
triggerPattern: /^@Andy\b/i,
|
||||
channels: [channel],
|
||||
queue: {
|
||||
registerProcess: vi.fn(),
|
||||
closeStdin: vi.fn(),
|
||||
notifyIdle: vi.fn(),
|
||||
} as any,
|
||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||
getSessions: () => ({}),
|
||||
getLastTimestamp: () => '',
|
||||
setLastTimestamp: vi.fn(),
|
||||
getLastAgentTimestamps: () => lastAgentTimestamps,
|
||||
saveState,
|
||||
persistSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await runtime.processGroupMessages(chatJid, {
|
||||
runId: 'run-review-pending',
|
||||
reason: 'messages',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sessionCommands.handleSessionCommand).toHaveBeenCalled();
|
||||
expect(pairedExecutionContext.markRoomReviewReady).toHaveBeenCalled();
|
||||
expect(
|
||||
pairedExecutionContext.formatRoomReviewReadyMessage,
|
||||
).toHaveBeenCalled();
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage);
|
||||
});
|
||||
|
||||
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||
const chatJid = 'group@test';
|
||||
const group = makeGroup('codex');
|
||||
@@ -1421,7 +1527,6 @@ describe('createMessageRuntime', () => {
|
||||
expect(lastAgentTimestamps[chatJid]).toBe('1');
|
||||
expect(channel.sendMessage).not.toHaveBeenCalled();
|
||||
expect(channel.sendAndTrack).not.toHaveBeenCalled();
|
||||
expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true);
|
||||
});
|
||||
|
||||
it('does not emit a visible message when the final output is structured silent output', async () => {
|
||||
@@ -2173,17 +2278,17 @@ describe('createMessageRuntime', () => {
|
||||
chatJid,
|
||||
P('검증 중입니다.\n\n0초'),
|
||||
);
|
||||
// Ticker fires after advanceTimersByTime — edits tracked message with the last flushed heading
|
||||
// Once the tracked progress exists, later progress updates replace the visible heading directly.
|
||||
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'progress-1',
|
||||
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n5초'),
|
||||
P('테스트도 통과했습니다.\n\n5초'),
|
||||
);
|
||||
// finish() promotes the last flushed progress text to a final message
|
||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
|
||||
'검증 중입니다.',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -2294,7 +2399,7 @@ describe('createMessageRuntime', () => {
|
||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||
chatJid,
|
||||
'아직 진행 중.',
|
||||
'진행 중입니다.',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -100,6 +100,14 @@ describe('paired execution context', () => {
|
||||
|
||||
expect(db.upsertPairedProject).toHaveBeenCalled();
|
||||
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
||||
expect(db.createPairedTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
expect(db.createPairedExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'run-1:codex-main',
|
||||
@@ -122,6 +130,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||
status: 'review_ready',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -152,6 +163,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'approved',
|
||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||
status: 'review_ready',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -185,6 +199,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -224,6 +241,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'high',
|
||||
plan_status: 'approved',
|
||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||
status: 'in_review',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -260,6 +280,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -273,6 +296,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'approved',
|
||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||
status: 'review_ready',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -317,13 +343,11 @@ describe('paired execution context', () => {
|
||||
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||
).toHaveBeenCalledWith('task-1');
|
||||
expect(result?.status).toBe('ready');
|
||||
if (result?.status !== 'ready') {
|
||||
throw new Error('expected ready review result');
|
||||
}
|
||||
expect(result.task.status).toBe('review_ready');
|
||||
expect(result?.task.status).toBe('review_ready');
|
||||
});
|
||||
|
||||
it('returns pending when review-ready setup cannot resolve an owner workspace', () => {
|
||||
it('keeps review_pending and returns a pending result when owner workspace is not ready', () => {
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||
id: 'task-1',
|
||||
chat_jid: 'dc:test',
|
||||
@@ -332,6 +356,9 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
@@ -345,10 +372,13 @@ describe('paired execution context', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: '2026-03-29T00:00:00.000Z',
|
||||
status: 'review_pending',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:01:00.000Z',
|
||||
updated_at: '2026-03-29T00:00:00.000Z',
|
||||
});
|
||||
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
|
||||
null,
|
||||
@@ -360,39 +390,20 @@ describe('paired execution context', () => {
|
||||
roomRoleContext: ownerContext,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'pending',
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
task: expect.objectContaining({
|
||||
id: 'task-1',
|
||||
status: 'review_pending',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a pending review-ready result into a user-facing pending message', () => {
|
||||
const message = formatRoomReviewReadyMessage({
|
||||
expect(result).toEqual({
|
||||
status: 'pending',
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
task: {
|
||||
task: expect.objectContaining({
|
||||
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:01:00.000Z',
|
||||
status: 'review_pending',
|
||||
created_at: '2026-03-28T00:00:00.000Z',
|
||||
updated_at: '2026-03-28T00:01:00.000Z',
|
||||
},
|
||||
}),
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
'Review request recorded, but the owner workspace is not ready yet.\n- Task: task-1\nThe task stays review_pending until the owner workspace is prepared.',
|
||||
expect(formatRoomReviewReadyMessage(result)).toBe(
|
||||
[
|
||||
'Review request recorded, but the owner workspace is not ready yet.',
|
||||
'- Task: task-1',
|
||||
'The task stays review_pending until the owner workspace is prepared.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,8 +86,11 @@ function ensureActiveTask(
|
||||
reviewer_service_id: roomRoleContext.reviewerServiceId,
|
||||
title: null,
|
||||
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -124,9 +124,10 @@ describe('paired /review command path', () => {
|
||||
'owner',
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe('ready');
|
||||
if (result?.status !== 'ready') {
|
||||
throw new Error('expected ready review result');
|
||||
if (!result || result.status !== 'ready') {
|
||||
throw new Error('expected reviewer /review to prepare a ready snapshot');
|
||||
}
|
||||
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
|
||||
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
|
||||
@@ -141,7 +142,7 @@ describe('paired /review command path', () => {
|
||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns pending and keeps review_pending when reviewer handles /review before any owner workspace exists', async () => {
|
||||
it('keeps review_pending when reviewer service handles /review before owner workspace exists', async () => {
|
||||
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||
runGit(['init'], canonicalDir);
|
||||
@@ -172,26 +173,27 @@ describe('paired /review command path', () => {
|
||||
roomRoleContext: reviewerContext,
|
||||
});
|
||||
|
||||
const task = db.getLatestOpenPairedTaskForChat('dc:test');
|
||||
const reviewerLocalOwnerDir = path.join(
|
||||
tempRoot,
|
||||
'data-review',
|
||||
'workspaces',
|
||||
'paired-room',
|
||||
'tasks',
|
||||
result?.task.id ?? 'missing-task',
|
||||
task!.id,
|
||||
'owner',
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'pending',
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
task: expect.objectContaining({
|
||||
status: 'review_pending',
|
||||
}),
|
||||
expect(result).toEqual({
|
||||
status: 'pending',
|
||||
task: expect.objectContaining({
|
||||
id: task!.id,
|
||||
status: 'review_pending',
|
||||
}),
|
||||
);
|
||||
expect(result?.task.review_requested_at).toBeTruthy();
|
||||
pendingReason: 'owner-workspace-not-ready',
|
||||
});
|
||||
expect(task?.status).toBe('review_pending');
|
||||
expect(task?.review_requested_at).toBeTruthy();
|
||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: 'review the owner changes',
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -214,6 +217,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: 'cross service review',
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'high',
|
||||
plan_status: 'pending',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -288,6 +294,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -362,6 +371,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'high',
|
||||
plan_status: 'pending',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -502,6 +514,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -563,6 +578,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'not_requested',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
@@ -618,6 +636,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'low',
|
||||
plan_status: 'pending',
|
||||
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||
status: 'review_pending',
|
||||
created_at: now,
|
||||
@@ -742,6 +763,9 @@ describe('paired workspace manager', () => {
|
||||
reviewer_service_id: 'codex-review',
|
||||
title: null,
|
||||
source_ref: 'HEAD',
|
||||
task_policy: 'autonomous',
|
||||
risk_level: 'high',
|
||||
plan_status: 'approved',
|
||||
review_requested_at: null,
|
||||
status: 'draft',
|
||||
created_at: now,
|
||||
|
||||
@@ -91,18 +91,19 @@ describe('isSessionCommandControlMessage', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches multiline review-updated output', () => {
|
||||
it('matches multiline review snapshot output', () => {
|
||||
expect(
|
||||
isSessionCommandControlMessage(
|
||||
'Review snapshot updated.\n- Task: paired-task-1',
|
||||
),
|
||||
isSessionCommandControlMessage('Review snapshot updated.\n- Task: task-1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches pending review output', () => {
|
||||
it('matches pending review request output', () => {
|
||||
expect(
|
||||
isSessionCommandControlMessage(
|
||||
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
|
||||
[
|
||||
'Review request recorded, but the owner workspace is not ready yet.',
|
||||
'- Task: task-1',
|
||||
].join('\n'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -253,12 +254,16 @@ describe('handleSessionCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a pending review message when owner workspace is not ready yet', async () => {
|
||||
it('sends the pending review message when owner workspace is not ready yet', async () => {
|
||||
const deps = makeDeps({
|
||||
markReviewReady: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
|
||||
[
|
||||
'Review request recorded, but the owner workspace is not ready yet.',
|
||||
'- Task: paired-task-1',
|
||||
'The task stays review_pending until the owner workspace is prepared.',
|
||||
].join('\n'),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -274,7 +279,11 @@ describe('handleSessionCommand', () => {
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith(
|
||||
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
|
||||
[
|
||||
'Review request recorded, but the owner workspace is not ready yet.',
|
||||
'- Task: paired-task-1',
|
||||
'The task stays review_pending until the owner workspace is prepared.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
23
src/types.ts
23
src/types.ts
@@ -28,7 +28,9 @@ export type PairedRoomRole = 'owner' | 'reviewer';
|
||||
export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
|
||||
|
||||
export type PairedTaskStatus =
|
||||
| 'active'
|
||||
| 'draft'
|
||||
| 'plan_review_pending'
|
||||
| 'review_pending'
|
||||
| 'review_ready'
|
||||
| 'in_review'
|
||||
@@ -38,6 +40,16 @@ export type PairedTaskStatus =
|
||||
| 'discarded'
|
||||
| 'failed';
|
||||
|
||||
export type PairedTaskPolicy = 'autonomous' | 'user_signoff_required';
|
||||
|
||||
export type PairedRiskLevel = 'low' | 'high';
|
||||
|
||||
export type PairedPlanStatus =
|
||||
| 'not_requested'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'changes_requested';
|
||||
|
||||
export type PairedExecutionStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
@@ -56,7 +68,13 @@ export type PairedWorkspaceStatus =
|
||||
|
||||
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export type PairedArtifactType = 'comment' | 'report' | 'patch';
|
||||
export type PairedArtifactType =
|
||||
| 'comment'
|
||||
| 'report'
|
||||
| 'patch'
|
||||
| 'plan_brief'
|
||||
| 'acceptance_criteria'
|
||||
| 'risk_summary';
|
||||
|
||||
export interface RoomRoleContext {
|
||||
serviceId: string;
|
||||
@@ -83,6 +101,9 @@ export interface PairedTask {
|
||||
reviewer_service_id: string;
|
||||
title: string | null;
|
||||
source_ref: string | null;
|
||||
task_policy: PairedTaskPolicy;
|
||||
risk_level: PairedRiskLevel;
|
||||
plan_status: PairedPlanStatus;
|
||||
review_requested_at: string | null;
|
||||
status: PairedTaskStatus;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user