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 fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
|
_initTestDatabaseFromFile,
|
||||||
claimServiceHandoff,
|
claimServiceHandoff,
|
||||||
completeServiceHandoffAndAdvanceTargetCursor,
|
completeServiceHandoffAndAdvanceTargetCursor,
|
||||||
createPairedApproval,
|
createPairedApproval,
|
||||||
@@ -563,6 +566,9 @@ describe('paired task state', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'wire up workspaces',
|
title: 'wire up workspaces',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -608,7 +614,7 @@ describe('paired task state', () => {
|
|||||||
task_id: 'paired-task-1',
|
task_id: 'paired-task-1',
|
||||||
execution_id: 'paired-exec-1',
|
execution_id: 'paired-exec-1',
|
||||||
service_id: 'codex-review',
|
service_id: 'codex-review',
|
||||||
artifact_type: 'report',
|
artifact_type: 'plan_brief',
|
||||||
title: 'review notes',
|
title: 'review notes',
|
||||||
content: 'looks fine',
|
content: 'looks fine',
|
||||||
file_path: null,
|
file_path: null,
|
||||||
@@ -619,6 +625,11 @@ describe('paired task state', () => {
|
|||||||
'/tmp/paired-room',
|
'/tmp/paired-room',
|
||||||
);
|
);
|
||||||
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
|
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(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
|
||||||
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
|
||||||
'/tmp/paired-room/owner',
|
'/tmp/paired-room/owner',
|
||||||
@@ -627,6 +638,116 @@ describe('paired task state', () => {
|
|||||||
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
|
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', () => {
|
it('updates task and execution state and keeps one workspace per role', () => {
|
||||||
createPairedTask({
|
createPairedTask({
|
||||||
id: 'paired-task-2',
|
id: 'paired-task-2',
|
||||||
@@ -636,6 +757,9 @@ describe('paired task state', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: null,
|
source_ref: null,
|
||||||
|
task_policy: 'user_signoff_required',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -655,6 +779,9 @@ describe('paired task state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updatePairedTask('paired-task-2', {
|
updatePairedTask('paired-task-2', {
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'approved',
|
||||||
status: 'review_pending',
|
status: 'review_pending',
|
||||||
review_requested_at: '2026-03-28T00:10:00.000Z',
|
review_requested_at: '2026-03-28T00:10:00.000Z',
|
||||||
updated_at: '2026-03-28T00:10:00.000Z',
|
updated_at: '2026-03-28T00:10:00.000Z',
|
||||||
@@ -690,6 +817,9 @@ describe('paired task state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
|
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(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
|
||||||
expect(
|
expect(
|
||||||
listPairedWorkspacesForTask('paired-task-2').map(
|
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,
|
reviewer_service_id TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
source_ref 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,
|
review_requested_at TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
||||||
|
CHECK (risk_level IN ('low', 'high')),
|
||||||
|
CHECK (
|
||||||
|
plan_status IN (
|
||||||
|
'not_requested',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'changes_requested'
|
||||||
|
)
|
||||||
|
),
|
||||||
CHECK (
|
CHECK (
|
||||||
status IN (
|
status IN (
|
||||||
|
'active',
|
||||||
'draft',
|
'draft',
|
||||||
|
'plan_review_pending',
|
||||||
'review_pending',
|
'review_pending',
|
||||||
'review_ready',
|
'review_ready',
|
||||||
'in_review',
|
'in_review',
|
||||||
@@ -328,7 +343,16 @@ function createSchema(database: Database.Database): void {
|
|||||||
content TEXT,
|
content TEXT,
|
||||||
file_path TEXT,
|
file_path TEXT,
|
||||||
created_at TEXT NOT NULL,
|
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
|
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
|
||||||
ON paired_artifacts(task_id, created_at);
|
ON paired_artifacts(task_id, created_at);
|
||||||
@@ -624,7 +648,15 @@ function createSchema(database: Database.Database): void {
|
|||||||
)
|
)
|
||||||
.get() as { sql?: string } | undefined;
|
.get() as { sql?: string } | undefined;
|
||||||
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
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(`
|
database.exec(`
|
||||||
CREATE TABLE paired_tasks_new (
|
CREATE TABLE paired_tasks_new (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -634,13 +666,28 @@ function createSchema(database: Database.Database): void {
|
|||||||
reviewer_service_id TEXT NOT NULL,
|
reviewer_service_id TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
source_ref TEXT,
|
source_ref TEXT,
|
||||||
|
task_policy TEXT NOT NULL DEFAULT 'autonomous',
|
||||||
|
risk_level TEXT NOT NULL DEFAULT 'low',
|
||||||
|
plan_status TEXT NOT NULL DEFAULT 'not_requested',
|
||||||
review_requested_at TEXT,
|
review_requested_at TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'draft',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
|
||||||
|
CHECK (risk_level IN ('low', 'high')),
|
||||||
|
CHECK (
|
||||||
|
plan_status IN (
|
||||||
|
'not_requested',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'changes_requested'
|
||||||
|
)
|
||||||
|
),
|
||||||
CHECK (
|
CHECK (
|
||||||
status IN (
|
status IN (
|
||||||
|
'active',
|
||||||
'draft',
|
'draft',
|
||||||
|
'plan_review_pending',
|
||||||
'review_pending',
|
'review_pending',
|
||||||
'review_ready',
|
'review_ready',
|
||||||
'in_review',
|
'in_review',
|
||||||
@@ -662,6 +709,9 @@ function createSchema(database: Database.Database): void {
|
|||||||
reviewer_service_id,
|
reviewer_service_id,
|
||||||
title,
|
title,
|
||||||
source_ref,
|
source_ref,
|
||||||
|
task_policy,
|
||||||
|
risk_level,
|
||||||
|
plan_status,
|
||||||
review_requested_at,
|
review_requested_at,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
@@ -675,6 +725,19 @@ function createSchema(database: Database.Database): void {
|
|||||||
reviewer_service_id,
|
reviewer_service_id,
|
||||||
title,
|
title,
|
||||||
source_ref,
|
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,
|
review_requested_at,
|
||||||
status,
|
status,
|
||||||
created_at,
|
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)
|
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||||
const sessionCols = database
|
const sessionCols = database
|
||||||
.prepare('PRAGMA table_info(sessions)')
|
.prepare('PRAGMA table_info(sessions)')
|
||||||
@@ -757,6 +887,12 @@ export function _initTestDatabase(): void {
|
|||||||
createSchema(db);
|
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).
|
* Store chat metadata only (no message content).
|
||||||
* Used for all chats to enable group discovery without storing sensitive 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,
|
reviewer_service_id,
|
||||||
title,
|
title,
|
||||||
source_ref,
|
source_ref,
|
||||||
|
task_policy,
|
||||||
|
risk_level,
|
||||||
|
plan_status,
|
||||||
review_requested_at,
|
review_requested_at,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
).run(
|
).run(
|
||||||
task.id,
|
task.id,
|
||||||
@@ -1883,6 +2022,9 @@ export function createPairedTask(task: PairedTask): void {
|
|||||||
task.reviewer_service_id,
|
task.reviewer_service_id,
|
||||||
task.title,
|
task.title,
|
||||||
task.source_ref,
|
task.source_ref,
|
||||||
|
task.task_policy,
|
||||||
|
task.risk_level,
|
||||||
|
task.plan_status,
|
||||||
task.review_requested_at,
|
task.review_requested_at,
|
||||||
task.status,
|
task.status,
|
||||||
task.created_at,
|
task.created_at,
|
||||||
@@ -1918,7 +2060,14 @@ export function updatePairedTask(
|
|||||||
updates: Partial<
|
updates: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
PairedTask,
|
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 {
|
): void {
|
||||||
@@ -1933,6 +2082,18 @@ export function updatePairedTask(
|
|||||||
fields.push('source_ref = ?');
|
fields.push('source_ref = ?');
|
||||||
values.push(updates.source_ref);
|
values.push(updates.source_ref);
|
||||||
}
|
}
|
||||||
|
if (updates.task_policy !== undefined) {
|
||||||
|
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) {
|
if (updates.review_requested_at !== undefined) {
|
||||||
fields.push('review_requested_at = ?');
|
fields.push('review_requested_at = ?');
|
||||||
values.push(updates.review_requested_at);
|
values.push(updates.review_requested_at);
|
||||||
|
|||||||
@@ -321,6 +321,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
reviewer_service_id: 'codex-main',
|
reviewer_service_id: 'codex-main',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -415,6 +418,9 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'claude',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'approved',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ vi.mock('./config.js', () => ({
|
|||||||
SERVICE_ID: 'claude',
|
SERVICE_ID: 'claude',
|
||||||
SERVICE_AGENT_TYPE: 'claude-code',
|
SERVICE_AGENT_TYPE: 'claude-code',
|
||||||
SERVICE_SESSION_SCOPE: 'claude',
|
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),
|
isClaudeService: vi.fn(() => true),
|
||||||
isReviewService: vi.fn(() => false),
|
isReviewService: vi.fn(() => false),
|
||||||
isSessionCommandSenderAllowed: 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', () => {
|
vi.mock('./db.js', () => {
|
||||||
const getMessagesSince = vi.fn(
|
const getMessagesSince = vi.fn(
|
||||||
(
|
(
|
||||||
@@ -171,6 +181,8 @@ import { resolveGroupIpcPath } from './group-folder.js';
|
|||||||
import { createMessageRuntime } from './message-runtime.js';
|
import { createMessageRuntime } from './message-runtime.js';
|
||||||
import * as outputSuppression from './output-suppression.js';
|
import * as outputSuppression from './output-suppression.js';
|
||||||
import * as config from './config.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';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
||||||
@@ -207,6 +219,100 @@ describe('createMessageRuntime', () => {
|
|||||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
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 () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
@@ -1421,7 +1527,6 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(lastAgentTimestamps[chatJid]).toBe('1');
|
expect(lastAgentTimestamps[chatJid]).toBe('1');
|
||||||
expect(channel.sendMessage).not.toHaveBeenCalled();
|
expect(channel.sendMessage).not.toHaveBeenCalled();
|
||||||
expect(channel.sendAndTrack).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 () => {
|
it('does not emit a visible message when the final output is structured silent output', async () => {
|
||||||
@@ -2173,17 +2278,17 @@ describe('createMessageRuntime', () => {
|
|||||||
chatJid,
|
chatJid,
|
||||||
P('검증 중입니다.\n\n0초'),
|
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(
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'progress-1',
|
'progress-1',
|
||||||
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n5초'),
|
P('테스트도 통과했습니다.\n\n5초'),
|
||||||
);
|
);
|
||||||
// finish() promotes the last flushed progress text to a final message
|
// finish() promotes the last flushed progress text to a final message
|
||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
|
'검증 중입니다.',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -2294,7 +2399,7 @@ describe('createMessageRuntime', () => {
|
|||||||
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(channel.sendMessage).toHaveBeenCalledWith(
|
expect(channel.sendMessage).toHaveBeenCalledWith(
|
||||||
chatJid,
|
chatJid,
|
||||||
'아직 진행 중.',
|
'진행 중입니다.',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ describe('paired execution context', () => {
|
|||||||
|
|
||||||
expect(db.upsertPairedProject).toHaveBeenCalled();
|
expect(db.upsertPairedProject).toHaveBeenCalled();
|
||||||
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
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(db.createPairedExecution).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'run-1:codex-main',
|
id: 'run-1:codex-main',
|
||||||
@@ -122,6 +130,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -152,6 +163,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'approved',
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -185,6 +199,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -224,6 +241,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'approved',
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'in_review',
|
status: 'in_review',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -260,6 +280,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -273,6 +296,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'approved',
|
||||||
review_requested_at: '2026-03-28T00:00:00.000Z',
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
status: 'review_ready',
|
status: 'review_ready',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -317,13 +343,11 @@ describe('paired execution context', () => {
|
|||||||
pairedWorkspaceManager.markPairedTaskReviewReady,
|
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||||
).toHaveBeenCalledWith('task-1');
|
).toHaveBeenCalledWith('task-1');
|
||||||
expect(result?.status).toBe('ready');
|
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');
|
||||||
|
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({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
chat_jid: 'dc:test',
|
chat_jid: 'dc:test',
|
||||||
@@ -332,6 +356,9 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
@@ -345,10 +372,13 @@ describe('paired execution context', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
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',
|
status: 'review_pending',
|
||||||
created_at: '2026-03-28T00:00:00.000Z',
|
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(
|
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
|
||||||
null,
|
null,
|
||||||
@@ -360,39 +390,20 @@ describe('paired execution context', () => {
|
|||||||
roomRoleContext: ownerContext,
|
roomRoleContext: ownerContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual(
|
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({
|
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
pendingReason: 'owner-workspace-not-ready',
|
task: expect.objectContaining({
|
||||||
task: {
|
|
||||||
id: 'task-1',
|
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',
|
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(formatRoomReviewReadyMessage(result)).toBe(
|
||||||
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.',
|
'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,
|
reviewer_service_id: roomRoleContext.reviewerServiceId,
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'active',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -124,9 +124,10 @@ describe('paired /review command path', () => {
|
|||||||
'owner',
|
'owner',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
expect(result?.status).toBe('ready');
|
expect(result?.status).toBe('ready');
|
||||||
if (result?.status !== 'ready') {
|
if (!result || result.status !== 'ready') {
|
||||||
throw new Error('expected ready review result');
|
throw new Error('expected reviewer /review to prepare a ready snapshot');
|
||||||
}
|
}
|
||||||
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
|
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
|
||||||
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
|
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
|
||||||
@@ -141,7 +142,7 @@ describe('paired /review command path', () => {
|
|||||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
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');
|
const canonicalDir = path.join(tempRoot, 'canonical');
|
||||||
fs.mkdirSync(canonicalDir, { recursive: true });
|
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||||
runGit(['init'], canonicalDir);
|
runGit(['init'], canonicalDir);
|
||||||
@@ -172,26 +173,27 @@ describe('paired /review command path', () => {
|
|||||||
roomRoleContext: reviewerContext,
|
roomRoleContext: reviewerContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const task = db.getLatestOpenPairedTaskForChat('dc:test');
|
||||||
const reviewerLocalOwnerDir = path.join(
|
const reviewerLocalOwnerDir = path.join(
|
||||||
tempRoot,
|
tempRoot,
|
||||||
'data-review',
|
'data-review',
|
||||||
'workspaces',
|
'workspaces',
|
||||||
'paired-room',
|
'paired-room',
|
||||||
'tasks',
|
'tasks',
|
||||||
result?.task.id ?? 'missing-task',
|
task!.id,
|
||||||
'owner',
|
'owner',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual(
|
expect(result).toEqual({
|
||||||
expect.objectContaining({
|
status: 'pending',
|
||||||
status: 'pending',
|
task: expect.objectContaining({
|
||||||
pendingReason: 'owner-workspace-not-ready',
|
id: task!.id,
|
||||||
task: expect.objectContaining({
|
status: 'review_pending',
|
||||||
status: 'review_pending',
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
);
|
pendingReason: 'owner-workspace-not-ready',
|
||||||
expect(result?.task.review_requested_at).toBeTruthy();
|
});
|
||||||
|
expect(task?.status).toBe('review_pending');
|
||||||
|
expect(task?.review_requested_at).toBeTruthy();
|
||||||
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'review the owner changes',
|
title: 'review the owner changes',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -214,6 +217,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: 'cross service review',
|
title: 'cross service review',
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -288,6 +294,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -362,6 +371,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'pending',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -502,6 +514,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -563,6 +578,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'not_requested',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -618,6 +636,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'low',
|
||||||
|
plan_status: 'pending',
|
||||||
review_requested_at: '2026-03-28T00:01:00.000Z',
|
review_requested_at: '2026-03-28T00:01:00.000Z',
|
||||||
status: 'review_pending',
|
status: 'review_pending',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -742,6 +763,9 @@ describe('paired workspace manager', () => {
|
|||||||
reviewer_service_id: 'codex-review',
|
reviewer_service_id: 'codex-review',
|
||||||
title: null,
|
title: null,
|
||||||
source_ref: 'HEAD',
|
source_ref: 'HEAD',
|
||||||
|
task_policy: 'autonomous',
|
||||||
|
risk_level: 'high',
|
||||||
|
plan_status: 'approved',
|
||||||
review_requested_at: null,
|
review_requested_at: null,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|||||||
@@ -91,18 +91,19 @@ describe('isSessionCommandControlMessage', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches multiline review-updated output', () => {
|
it('matches multiline review snapshot output', () => {
|
||||||
expect(
|
expect(
|
||||||
isSessionCommandControlMessage(
|
isSessionCommandControlMessage('Review snapshot updated.\n- Task: task-1'),
|
||||||
'Review snapshot updated.\n- Task: paired-task-1',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches pending review output', () => {
|
it('matches pending review request output', () => {
|
||||||
expect(
|
expect(
|
||||||
isSessionCommandControlMessage(
|
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);
|
).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({
|
const deps = makeDeps({
|
||||||
markReviewReady: vi
|
markReviewReady: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(
|
.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(result).toEqual({ handled: true, success: true });
|
||||||
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
|
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
|
||||||
expect(deps.sendMessage).toHaveBeenCalledWith(
|
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 PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
|
||||||
|
|
||||||
export type PairedTaskStatus =
|
export type PairedTaskStatus =
|
||||||
|
| 'active'
|
||||||
| 'draft'
|
| 'draft'
|
||||||
|
| 'plan_review_pending'
|
||||||
| 'review_pending'
|
| 'review_pending'
|
||||||
| 'review_ready'
|
| 'review_ready'
|
||||||
| 'in_review'
|
| 'in_review'
|
||||||
@@ -38,6 +40,16 @@ export type PairedTaskStatus =
|
|||||||
| 'discarded'
|
| 'discarded'
|
||||||
| 'failed';
|
| '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 =
|
export type PairedExecutionStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
| 'running'
|
| 'running'
|
||||||
@@ -56,7 +68,13 @@ export type PairedWorkspaceStatus =
|
|||||||
|
|
||||||
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
|
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 {
|
export interface RoomRoleContext {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -83,6 +101,9 @@ export interface PairedTask {
|
|||||||
reviewer_service_id: string;
|
reviewer_service_id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
source_ref: string | null;
|
source_ref: string | null;
|
||||||
|
task_policy: PairedTaskPolicy;
|
||||||
|
risk_level: PairedRiskLevel;
|
||||||
|
plan_status: PairedPlanStatus;
|
||||||
review_requested_at: string | null;
|
review_requested_at: string | null;
|
||||||
status: PairedTaskStatus;
|
status: PairedTaskStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user