diff --git a/src/db.test.ts b/src/db.test.ts index 6e525dd..5e74ea0 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -24,6 +24,7 @@ import { getAllRegisteredGroups, getDueTasks, getLatestMessageSeqAtOrBefore, + getLatestPairedTaskForChat, getMessagesSinceSeq, getNewMessagesBySeq, getOpenWorkItem, @@ -638,6 +639,9 @@ describe('paired task state', () => { expect(getPairedTaskById('paired-task-1')?.gate_turn_kind ?? null).toBe( null, ); + expect( + getPairedTaskById('paired-task-1')?.last_finalized_checkpoint ?? null, + ).toBe(null); expect(getPairedTaskById('paired-task-1')?.reviewer_verdict ?? null).toBe( null, ); @@ -725,6 +729,85 @@ describe('paired task state', () => { ); }); + it('dedupes deploy_complete and atomically finalizes the checkpoint once', () => { + createPairedTask({ + id: 'paired-task-events-deploy', + chat_jid: 'dc:paired', + group_folder: 'paired-room', + owner_service_id: 'codex-main', + reviewer_service_id: 'codex-review', + title: null, + source_ref: 'HEAD', + task_policy: 'autonomous', + risk_level: 'low', + plan_status: 'approved', + review_requested_at: '2026-03-29T00:00:00.000Z', + last_finalized_checkpoint: null, + gate_turn_kind: null, + reviewer_verdict: 'done', + reviewer_verdict_at: '2026-03-29T00:00:00.000Z', + reviewer_verdict_note: '**DONE** ready', + status: 'merge_ready', + created_at: '2026-03-29T00:00:00.000Z', + updated_at: '2026-03-29T00:00:00.000Z', + }); + + const first = applyPairedEvent({ + event: { + task_id: 'paired-task-events-deploy', + event_type: 'deploy_complete', + actor_role: 'owner', + source_service_id: 'codex-main', + source_fingerprint: 'canonical-v1', + dedupe_key: 'deploy-complete:canonical-v1', + payload_json: '{"checkpoint":"canonical-v1"}', + created_at: '2026-03-29T00:01:00.000Z', + }, + onApply: () => { + updatePairedTask('paired-task-events-deploy', { + status: 'merged', + last_finalized_checkpoint: 'canonical-v1', + updated_at: '2026-03-29T00:01:00.000Z', + }); + }, + }); + + const second = applyPairedEvent({ + event: { + task_id: 'paired-task-events-deploy', + event_type: 'deploy_complete', + actor_role: 'owner', + source_service_id: 'codex-main', + source_fingerprint: 'canonical-v1', + dedupe_key: 'deploy-complete:canonical-v1', + payload_json: '{"checkpoint":"canonical-v1"}', + created_at: '2026-03-29T00:02:00.000Z', + }, + onApply: () => { + updatePairedTask('paired-task-events-deploy', { + status: 'failed', + last_finalized_checkpoint: 'should-not-change', + updated_at: '2026-03-29T00:02:00.000Z', + }); + }, + }); + + expect(first.applied).toBe(true); + expect(second.applied).toBe(false); + expect( + listPairedEventsForTask('paired-task-events-deploy').filter( + (event) => event.event_type === 'deploy_complete', + ), + ).toHaveLength(1); + expect( + getPairedTaskById('paired-task-events-deploy')?.last_finalized_checkpoint, + ).toBe('canonical-v1'); + expect(getLatestPairedTaskForChat('dc:paired')?.status).toBe('merged'); + expect(getPairedTaskById('paired-task-events-deploy')?.status).toBe( + 'merged', + ); + }); + it('rolls back paired event insert when the coupled state update fails', () => { createPairedTask({ id: 'paired-task-events-2', @@ -932,6 +1015,160 @@ describe('paired task state', () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); + it('migrates paired task finalization fields and deploy_complete events', () => { + const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-finalize-')); + const dbPath = path.join(tempRoot, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + title TEXT, + source_ref TEXT, + task_policy TEXT NOT NULL DEFAULT 'autonomous', + risk_level TEXT NOT NULL DEFAULT 'low', + plan_status TEXT NOT NULL DEFAULT 'not_requested', + review_requested_at TEXT, + gate_turn_kind TEXT, + reviewer_verdict TEXT, + reviewer_verdict_at TEXT, + reviewer_verdict_note TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE paired_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + event_type TEXT NOT NULL, + actor_role TEXT NOT NULL, + source_service_id TEXT NOT NULL, + source_fingerprint TEXT, + dedupe_key TEXT NOT NULL, + payload_json TEXT, + created_at TEXT NOT NULL + ); + `); + + legacyDb + .prepare( + ` + INSERT INTO paired_tasks ( + id, + chat_jid, + group_folder, + owner_service_id, + reviewer_service_id, + title, + source_ref, + task_policy, + risk_level, + plan_status, + review_requested_at, + gate_turn_kind, + reviewer_verdict, + reviewer_verdict_at, + reviewer_verdict_note, + status, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'paired-finalized', + 'dc:paired', + 'paired-room', + 'codex-main', + 'codex-review', + null, + 'HEAD', + 'autonomous', + 'low', + 'approved', + '2026-03-29T00:00:00.000Z', + null, + 'done', + '2026-03-29T00:00:00.000Z', + '**DONE**', + 'merge_ready', + '2026-03-29T00:00:00.000Z', + '2026-03-29T00:00:00.000Z', + ); + legacyDb + .prepare( + ` + INSERT INTO paired_events ( + task_id, + event_type, + actor_role, + source_service_id, + source_fingerprint, + dedupe_key, + payload_json, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + 'paired-finalized', + 'request_review', + 'owner', + 'codex-main', + 'fingerprint-v1', + 'msg-review-legacy', + null, + '2026-03-29T00:00:00.000Z', + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect( + getPairedTaskById('paired-finalized')?.last_finalized_checkpoint ?? null, + ).toBe(null); + + applyPairedEvent({ + event: { + task_id: 'paired-finalized', + event_type: 'deploy_complete', + actor_role: 'owner', + source_service_id: 'codex-main', + source_fingerprint: 'canonical-v1', + dedupe_key: 'deploy-complete:canonical-v1', + payload_json: '{"checkpoint":"canonical-v1"}', + created_at: '2026-03-29T00:01:00.000Z', + }, + onApply: () => { + updatePairedTask('paired-finalized', { + status: 'merged', + last_finalized_checkpoint: 'canonical-v1', + updated_at: '2026-03-29T00:01:00.000Z', + }); + }, + }); + + expect(getPairedTaskById('paired-finalized')?.status).toBe('merged'); + expect( + getPairedTaskById('paired-finalized')?.last_finalized_checkpoint, + ).toBe('canonical-v1'); + expect( + getPairedEventByDedupeKey({ + taskId: 'paired-finalized', + eventType: 'deploy_complete', + dedupeKey: 'deploy-complete:canonical-v1', + })?.source_fingerprint, + ).toBe('canonical-v1'); + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + it('updates task and execution state and keeps one workspace per role', () => { createPairedTask({ id: 'paired-task-2', diff --git a/src/db.ts b/src/db.ts index 5d3d4d6..35de23e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -258,6 +258,7 @@ function createSchema(database: Database.Database): void { risk_level TEXT NOT NULL DEFAULT 'low', plan_status TEXT NOT NULL DEFAULT 'not_requested', review_requested_at TEXT, + last_finalized_checkpoint TEXT, gate_turn_kind TEXT, reviewer_verdict TEXT, reviewer_verdict_at TEXT, @@ -386,7 +387,8 @@ function createSchema(database: Database.Database): void { 'submit_plan', 'approve_plan', 'request_plan_changes', - 'request_review' + 'request_review', + 'deploy_complete' ) ), CHECK (actor_role IN ('owner', 'reviewer', 'system')) @@ -703,6 +705,7 @@ function createSchema(database: Database.Database): void { !pairedTasksSql.includes('task_policy TEXT') || !pairedTasksSql.includes('risk_level TEXT') || !pairedTasksSql.includes('plan_status TEXT') || + !pairedTasksSql.includes('last_finalized_checkpoint TEXT') || !pairedTasksSql.includes('gate_turn_kind TEXT') || !pairedTasksSql.includes('reviewer_verdict TEXT')); if (pairedTasksNeedsMigration) { @@ -719,6 +722,7 @@ function createSchema(database: Database.Database): void { risk_level TEXT NOT NULL DEFAULT 'low', plan_status TEXT NOT NULL DEFAULT 'not_requested', review_requested_at TEXT, + last_finalized_checkpoint TEXT, gate_turn_kind TEXT, reviewer_verdict TEXT, reviewer_verdict_at TEXT, @@ -774,6 +778,7 @@ function createSchema(database: Database.Database): void { risk_level, plan_status, review_requested_at, + last_finalized_checkpoint, gate_turn_kind, reviewer_verdict, reviewer_verdict_at, @@ -808,6 +813,7 @@ function createSchema(database: Database.Database): void { NULL, NULL, NULL, + NULL, status, created_at, updated_at @@ -823,6 +829,77 @@ function createSchema(database: Database.Database): void { `); } + const pairedEventsSqlRow = database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_events'`, + ) + .get() as { sql?: string } | undefined; + const pairedEventsSql = pairedEventsSqlRow?.sql || ''; + const pairedEventsNeedsMigration = + pairedEventsSql && !pairedEventsSql.includes("'deploy_complete'"); + if (pairedEventsNeedsMigration) { + database.exec(` + CREATE TABLE paired_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + event_type TEXT NOT NULL, + actor_role TEXT NOT NULL, + source_service_id TEXT NOT NULL, + source_fingerprint TEXT, + dedupe_key TEXT NOT NULL, + payload_json TEXT, + created_at TEXT NOT NULL, + CHECK ( + event_type IN ( + 'set_risk', + 'submit_plan', + 'approve_plan', + 'request_plan_changes', + 'request_review', + 'deploy_complete' + ) + ), + CHECK (actor_role IN ('owner', 'reviewer', 'system')) + ); + `); + database.exec(` + INSERT INTO paired_events_new ( + id, + task_id, + event_type, + actor_role, + source_service_id, + source_fingerprint, + dedupe_key, + payload_json, + created_at + ) + SELECT + id, + task_id, + event_type, + actor_role, + source_service_id, + source_fingerprint, + dedupe_key, + payload_json, + created_at + FROM paired_events; + `); + database.exec(` + DROP TABLE paired_events; + ALTER TABLE paired_events_new RENAME TO paired_events; + `); + database.exec(` + CREATE INDEX IF NOT EXISTS idx_paired_events_task + ON paired_events(task_id, created_at, id); + `); + database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe + ON paired_events(task_id, event_type, dedupe_key); + `); + } + const pairedArtifactsSqlRow = database .prepare( `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`, @@ -2076,6 +2153,7 @@ export function createPairedTask(task: PairedTask): void { risk_level, plan_status, review_requested_at, + last_finalized_checkpoint, gate_turn_kind, reviewer_verdict, reviewer_verdict_at, @@ -2084,7 +2162,7 @@ export function createPairedTask(task: PairedTask): void { created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, @@ -2098,6 +2176,7 @@ export function createPairedTask(task: PairedTask): void { task.risk_level, task.plan_status, task.review_requested_at, + task.last_finalized_checkpoint ?? null, task.gate_turn_kind ?? null, task.reviewer_verdict ?? null, task.reviewer_verdict_at ?? null, @@ -2114,6 +2193,22 @@ export function getPairedTaskById(id: string): PairedTask | undefined { | undefined; } +export function getLatestPairedTaskForChat( + chatJid: string, +): PairedTask | undefined { + return db + .prepare( + ` + SELECT * + FROM paired_tasks + WHERE chat_jid = ? + ORDER BY updated_at DESC + LIMIT 1 + `, + ) + .get(chatJid) as PairedTask | undefined; +} + export function getLatestOpenPairedTaskForChat( chatJid: string, ): PairedTask | undefined { @@ -2142,6 +2237,7 @@ export function updatePairedTask( | 'risk_level' | 'plan_status' | 'review_requested_at' + | 'last_finalized_checkpoint' | 'gate_turn_kind' | 'reviewer_verdict' | 'reviewer_verdict_at' @@ -2178,6 +2274,10 @@ export function updatePairedTask( fields.push('review_requested_at = ?'); values.push(updates.review_requested_at); } + if (updates.last_finalized_checkpoint !== undefined) { + fields.push('last_finalized_checkpoint = ?'); + values.push(updates.last_finalized_checkpoint); + } if (updates.gate_turn_kind !== undefined) { fields.push('gate_turn_kind = ?'); values.push(updates.gate_turn_kind); diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 50d75ed..c799c65 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -39,6 +39,7 @@ vi.mock('./paired-execution-context.js', () => ({ markRoomReviewReady: vi.fn(() => null), formatRoomReviewReadyMessage: vi.fn(() => null), planPairedExecutionRecovery: vi.fn(() => null), + finalizeRoomDeployment: vi.fn(() => null), setRoomTaskRiskLevel: vi.fn(() => null), recordRoomPlan: vi.fn(() => null), approveRoomPlan: vi.fn(() => null), @@ -409,6 +410,77 @@ describe('createMessageRuntime', () => { expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage); }); + it('surfaces the deploy-complete message through the message-runtime path', async () => { + const chatJid = 'group@test'; + const group = { + ...makeGroup('codex'), + workDir: '/repo/canonical', + }; + const channel = makeChannel(chatJid); + const saveState = vi.fn(); + const lastAgentTimestamps: Record = {}; + const deployMessage = [ + 'Deployment finalized.', + '- Task: paired-task-1', + '- Status: merged', + '- Checkpoint: abc123', + ].join('\n'); + + vi.mocked(db.isPairedRoomJid).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-deploy', + chat_jid: chatJid, + sender: 'me@test', + sender_name: 'Me', + content: '/deploy-complete', + timestamp: '2026-03-29T00:00:00.000Z', + is_from_me: true, + }, + ]); + const actualSessionCommands = await vi.importActual< + typeof import('./session-commands.js') + >('./session-commands.js'); + vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => + actualSessionCommands.handleSessionCommand(opts), + ); + vi.mocked(pairedExecutionContext.finalizeRoomDeployment).mockReturnValue( + deployMessage, + ); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-deploy-complete', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(sessionCommands.handleSessionCommand).toHaveBeenCalled(); + expect(pairedExecutionContext.finalizeRoomDeployment).toHaveBeenCalled(); + expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, deployMessage); + }); + it('routes /approve-plan through reviewer context in the message-runtime path', async () => { const chatJid = 'group@test'; const group = { diff --git a/src/message-runtime.ts b/src/message-runtime.ts index d617975..685eb05 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -43,6 +43,7 @@ import { MessageTurnController } from './message-turn-controller.js'; import { createSuppressToken } from './output-suppression.js'; import { approveRoomPlan, + finalizeRoomDeployment, formatRoomReviewReadyMessage, markRoomReviewReady, planPairedExecutionRecovery, @@ -587,6 +588,18 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); return formatRoomReviewReadyMessage(result); }, + finalizeDeployment: async () => { + const lease = getEffectiveChannelLease(chatJid); + const roomRoleContext = buildRoomRoleContext( + lease, + lease.owner_service_id, + ); + return finalizeRoomDeployment({ + group, + chatJid, + roomRoleContext, + }); + }, setTaskRiskLevel: async (riskLevel, dedupeKey) => { const lease = getEffectiveChannelLease(chatJid); const roomRoleContext = buildRoomRoleContext( diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index e275d8b..15a0e73 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -1,3 +1,8 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./db.js', () => ({ @@ -6,6 +11,7 @@ vi.mock('./db.js', () => ({ createPairedArtifact: vi.fn(), createPairedExecution: vi.fn(), createPairedTask: vi.fn(), + getLatestPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), getPairedExecutionById: vi.fn(), getPairedTaskById: vi.fn(), @@ -41,6 +47,7 @@ import * as db from './db.js'; import { approveRoomPlan, completePairedExecutionContext, + finalizeRoomDeployment, formatRoomReviewReadyMessage, markRoomReviewReady, planPairedExecutionRecovery, @@ -77,6 +84,26 @@ const reviewerContext: RoomRoleContext = { failoverOwner: false, }; +function createCanonicalRepoWithCommit(commitMessage: string): string { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-')); + execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.name', 'Test User'], { + cwd: repoDir, + stdio: 'ignore', + }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { + cwd: repoDir, + stdio: 'ignore', + }); + fs.writeFileSync(path.join(repoDir, 'README.md'), `${commitMessage}\n`); + execFileSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'ignore' }); + execFileSync('git', ['commit', '-m', commitMessage], { + cwd: repoDir, + stdio: 'ignore', + }); + return repoDir; +} + async function importExecutionContextForService(serviceId: string) { vi.resetModules(); @@ -86,6 +113,7 @@ async function importExecutionContextForService(serviceId: string) { createPairedArtifact: vi.fn(), createPairedExecution: vi.fn(), createPairedTask: vi.fn(), + getLatestPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), getPairedExecutionById: vi.fn(), getPairedTaskById: vi.fn(), @@ -102,6 +130,7 @@ async function importExecutionContextForService(serviceId: string) { event: { id: 1, ...event }, result: onApply ? onApply() : null, })); + dbModule.getLatestPairedTaskForChat.mockReturnValue(undefined); dbModule.getLatestOpenPairedTaskForChat.mockReturnValue(undefined); dbModule.getPairedExecutionById.mockReturnValue(undefined); dbModule.getPairedTaskById.mockReturnValue(undefined); @@ -182,6 +211,7 @@ describe('paired execution context', () => { result: onApply ? onApply() : null, })); vi.mocked(db.cancelSupersededPairedExecutions).mockReturnValue(0); + vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(undefined); vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined); vi.mocked(db.getPairedExecutionById).mockReturnValue(undefined); vi.mocked(db.getPairedTaskById).mockReturnValue(undefined); @@ -418,6 +448,256 @@ describe('paired execution context', () => { ); }); + it('promotes an approved push gate verdict to merge_ready', () => { + vi.mocked(db.getPairedExecutionById).mockReturnValue({ + id: 'run-review:codex-review', + task_id: 'task-1', + service_id: 'codex-review', + role: 'reviewer', + workspace_id: 'task-1:reviewer', + checkpoint_fingerprint: null, + status: 'running', + summary: null, + created_at: '2026-03-28T00:00:00.000Z', + started_at: '2026-03-28T00:00:00.000Z', + completed_at: null, + }); + 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', + task_policy: 'autonomous', + risk_level: 'low', + plan_status: 'approved', + review_requested_at: '2026-03-28T00:01:00.000Z', + gate_turn_kind: 'push', + reviewer_verdict: null, + reviewer_verdict_at: null, + reviewer_verdict_note: null, + status: 'in_review', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:01:00.000Z', + }); + + completePairedExecutionContext({ + executionId: 'run-review:codex-review', + status: 'succeeded', + reviewerVerdict: 'done', + reviewerVerdictNote: '**DONE** okay to deploy', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + gate_turn_kind: null, + reviewer_verdict: 'done', + reviewer_verdict_note: '**DONE** okay to deploy', + status: 'merge_ready', + }), + ); + }); + + it('finalizes a merge-ready task with a durable deploy_complete checkpoint', () => { + const canonicalDir = createCanonicalRepoWithCommit('deploy-ready'); + const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: canonicalDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const groupWithRepo: RegisteredGroup = { + ...group, + workDir: canonicalDir, + }; + const task = { + 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', + task_policy: 'autonomous' as const, + risk_level: 'low' as const, + plan_status: 'approved' as const, + review_requested_at: '2026-03-28T00:01:00.000Z', + last_finalized_checkpoint: null, + gate_turn_kind: null, + reviewer_verdict: 'done' as const, + reviewer_verdict_at: '2026-03-28T00:01:00.000Z', + reviewer_verdict_note: '**DONE** okay to deploy', + status: 'merge_ready' as const, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:01:00.000Z', + }; + vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(task); + vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any); + vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => { + Object.assign(task, updates); + }); + + const message = finalizeRoomDeployment({ + group: groupWithRepo, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(db.applyPairedEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + task_id: 'task-1', + event_type: 'deploy_complete', + source_fingerprint: deployedCheckpoint, + dedupe_key: `deploy-complete:${deployedCheckpoint}`, + }), + }), + ); + expect(task.status).toBe('merged'); + expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint); + expect(message).toContain('Deployment finalized.'); + expect(message).toContain(deployedCheckpoint); + + fs.rmSync(canonicalDir, { recursive: true, force: true }); + }); + + it('keeps deploy completion idempotent for the same checkpoint', () => { + const canonicalDir = createCanonicalRepoWithCommit('deploy-once'); + const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: canonicalDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + const groupWithRepo: RegisteredGroup = { + ...group, + workDir: canonicalDir, + }; + const task = { + 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', + task_policy: 'autonomous' as const, + risk_level: 'low' as const, + plan_status: 'approved' as const, + review_requested_at: '2026-03-28T00:01:00.000Z', + last_finalized_checkpoint: null, + gate_turn_kind: null, + reviewer_verdict: 'done' as const, + reviewer_verdict_at: '2026-03-28T00:01:00.000Z', + reviewer_verdict_note: '**DONE** okay to deploy', + status: 'merge_ready' as const, + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:01:00.000Z', + }; + let applied = false; + vi.mocked(db.getLatestPairedTaskForChat).mockImplementation(() => task as any); + vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any); + vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => { + Object.assign(task, updates); + }); + vi.mocked(db.applyPairedEvent).mockImplementation(({ event, onApply }) => { + if (applied) { + return { + applied: false, + event: { id: 1, ...event }, + result: null, + }; + } + applied = true; + const result = onApply ? onApply() : null; + return { + applied: true, + event: { id: 1, ...event }, + result, + }; + }); + + finalizeRoomDeployment({ + group: groupWithRepo, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + const second = finalizeRoomDeployment({ + group: groupWithRepo, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(db.applyPairedEvent).toHaveBeenCalledTimes(2); + expect(task.status).toBe('merged'); + expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint); + expect(second).toContain(deployedCheckpoint); + + fs.rmSync(canonicalDir, { recursive: true, force: true }); + }); + + it('rejects deploy completion for a non-merge-ready task', () => { + const canonicalDir = createCanonicalRepoWithCommit('not-ready'); + const groupWithRepo: RegisteredGroup = { + ...group, + workDir: canonicalDir, + }; + vi.mocked(db.getLatestPairedTaskForChat).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', + task_policy: 'autonomous', + risk_level: 'low', + plan_status: 'approved', + review_requested_at: '2026-03-28T00:01:00.000Z', + last_finalized_checkpoint: null, + gate_turn_kind: null, + reviewer_verdict: 'done', + reviewer_verdict_at: '2026-03-28T00:01:00.000Z', + reviewer_verdict_note: '**DONE** okay to deploy', + status: 'active', + created_at: '2026-03-28T00:00:00.000Z', + updated_at: '2026-03-28T00:01:00.000Z', + }); + + const message = finalizeRoomDeployment({ + group: groupWithRepo, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(message).toContain( + 'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.', + ); + expect(db.applyPairedEvent).not.toHaveBeenCalled(); + + fs.rmSync(canonicalDir, { recursive: true, force: true }); + }); + + it('rejects deploy completion when the canonical HEAD is unavailable', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-no-git-')); + const message = finalizeRoomDeployment({ + group: { + ...group, + workDir: tempDir, + }, + chatJid: 'dc:test', + roomRoleContext: ownerContext, + }); + + expect(message).toBe( + 'Deploy completion requires a canonical workDir with a readable HEAD.', + ); + expect(db.applyPairedEvent).not.toHaveBeenCalled(); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + it('plans reviewer recovery from the latest review checkpoint', () => { vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ id: 'task-1', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index b41a23c..b7a5386 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -8,6 +8,7 @@ import { createPairedArtifact, createPairedExecution, createPairedTask, + getLatestPairedTaskForChat, getLatestOpenPairedTaskForChat, getPairedExecutionById, getPairedTaskById, @@ -51,6 +52,12 @@ const VISIBLE_REVIEWER_GATE_VERDICTS = new Set([ ]); const REVIEWER_GATE_REQUIRED_MESSAGE = 'A visible reviewer verdict is required before the owner can proceed with this gate.'; +const DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE = + 'Deployment finalization must be handled by the owner service.'; +const DEPLOY_COMPLETE_PRECONDITION_MESSAGE = + 'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.'; +const DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE = + 'Deploy completion requires a canonical workDir with a readable HEAD.'; function getGateTurnKind(task: PairedTask): PairedGateTurnKind | null { return task.gate_turn_kind ?? null; @@ -90,6 +97,19 @@ function resolveCanonicalSourceRef(workDir: string): string { } } +function resolveCanonicalHead(workDir: string): string | null { + try { + const head = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: workDir, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + return head || null; + } catch { + return null; + } +} + function ensurePairedProject( group: RegisteredGroup, chatJid: string, @@ -138,6 +158,7 @@ function ensureActiveTask( risk_level: 'low', plan_status: 'not_requested', review_requested_at: null, + last_finalized_checkpoint: null, gate_turn_kind: null, reviewer_verdict: null, reviewer_verdict_at: null, @@ -835,10 +856,15 @@ export function completePairedExecutionContext(args: { task.gate_turn_kind && args.reviewerVerdict ) { + const shouldPromoteToMergeReady = + task.gate_turn_kind === 'push' && + APPROVED_REVIEWER_GATE_VERDICTS.has(args.reviewerVerdict); updatePairedTask(task.id, { + gate_turn_kind: shouldPromoteToMergeReady ? null : task.gate_turn_kind, reviewer_verdict: args.reviewerVerdict, reviewer_verdict_at: completedAt, reviewer_verdict_note: args.reviewerVerdictNote ?? null, + status: shouldPromoteToMergeReady ? 'merge_ready' : task.status, updated_at: completedAt, }); } @@ -1201,3 +1227,72 @@ export function requestRoomPlanChanges(args: { `- Status: ${latestTask.status}`, ].join('\n'); } + +export function finalizeRoomDeployment(args: { + group: RegisteredGroup; + chatJid: string; + roomRoleContext?: RoomRoleContext; +}): string | null { + const { group, chatJid, roomRoleContext } = args; + if (!roomRoleContext) { + return null; + } + if (roomRoleContext.role !== 'owner') { + return DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE; + } + if (!group.workDir) { + return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE; + } + + const deployedCheckpoint = resolveCanonicalHead(group.workDir); + if (!deployedCheckpoint) { + return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE; + } + + const task = getLatestPairedTaskForChat(chatJid); + if (!task) { + return null; + } + + const alreadyFinalized = + task.status === 'merged' && + task.last_finalized_checkpoint === deployedCheckpoint; + if (task.status !== 'merge_ready' && !alreadyFinalized) { + return [ + DEPLOY_COMPLETE_PRECONDITION_MESSAGE, + `- Task: ${task.id}`, + `- Status: ${task.status}`, + `- Checkpoint: ${deployedCheckpoint}`, + ].join('\n'); + } + + const finalizedAt = new Date().toISOString(); + applyPairedEvent({ + event: { + task_id: task.id, + event_type: 'deploy_complete', + actor_role: roomRoleContext.role, + source_service_id: roomRoleContext.serviceId, + source_fingerprint: deployedCheckpoint, + dedupe_key: `deploy-complete:${deployedCheckpoint}`, + payload_json: serializeIntentPayload({ checkpoint: deployedCheckpoint }), + created_at: finalizedAt, + }, + onApply: () => { + updatePairedTask(task.id, { + status: 'merged', + last_finalized_checkpoint: deployedCheckpoint, + gate_turn_kind: null, + updated_at: finalizedAt, + }); + }, + }); + + const latestTask = getPairedTaskById(task.id) ?? task; + return [ + 'Deployment finalized.', + `- Task: ${latestTask.id}`, + `- Status: ${latestTask.status}`, + `- Checkpoint: ${deployedCheckpoint}`, + ].join('\n'); +} diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index cf52a61..cbeea65 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -27,6 +27,12 @@ describe('extractSessionCommand', () => { expect(extractSessionCommand('/review', trigger)).toBe('/review'); }); + it('detects bare /deploy-complete', () => { + expect(extractSessionCommand('/deploy-complete', trigger)).toBe( + '/deploy-complete', + ); + }); + it('normalizes /review-ready to /review', () => { expect(extractSessionCommand('/review-ready', trigger)).toBe('/review'); }); @@ -146,6 +152,14 @@ describe('isSessionCommandControlMessage', () => { ).toBe(true); }); + it('matches deployment finalized output', () => { + expect( + isSessionCommandControlMessage( + 'Deployment finalized.\n- Task: task-1\n- Checkpoint: abc123', + ), + ).toBe(true); + }); + it('does not match regular bot conversation', () => { expect( isSessionCommandControlMessage( @@ -184,6 +198,7 @@ function makeDeps( isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), + finalizeDeployment: vi.fn().mockResolvedValue('Deployment finalized.'), setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'), recordPlan: vi.fn().mockResolvedValue('Plan recorded.'), approvePlan: vi.fn().mockResolvedValue('Plan approved.'), @@ -246,6 +261,23 @@ describe('handleSessionCommand', () => { ); }); + it('handles authorized /deploy-complete without invoking the agent', async () => { + const deps = makeDeps(); + const result = await handleSessionCommand({ + missedMessages: [makeMsg('/deploy-complete')], + isMainGroup: true, + groupName: 'test', + triggerPattern: trigger, + timezone: 'UTC', + deps, + }); + expect(result).toEqual({ handled: true, success: true }); + expect(deps.finalizeDeployment).toHaveBeenCalledTimes(1); + expect(deps.runAgent).not.toHaveBeenCalled(); + expect(deps.advanceCursor).toHaveBeenCalledWith('100'); + expect(deps.sendMessage).toHaveBeenCalledWith('Deployment finalized.'); + }); + it('handles authorized /review without invoking the agent', async () => { const deps = makeDeps({ markReviewReady: vi diff --git a/src/session-commands.ts b/src/session-commands.ts index 9ed501a..ca368fd 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -22,6 +22,10 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [ /^Plan approval must be handled by the reviewer service\.$/, /^Plan review commands are only required for high-risk tasks\.$/, /^Plan artifacts are incomplete\.(?:\n|$)/, + /^Deployment finalized\.(?:\n|$)/, + /^Deployment finalization must be handled by the owner service\.$/, + /^Deploy completion requires a merge-ready task or the same already-finalized checkpoint\.(?:\n|$)/, + /^Deploy completion requires a canonical workDir with a readable HEAD\.$/, /^Review is unavailable for this room\./, ]; @@ -44,6 +48,7 @@ export function extractSessionCommand( if (text === '/compact') return '/compact'; if (text === '/clear') return '/clear'; if (text === '/review' || text === '/review-ready') return '/review'; + if (text === '/deploy-complete') return '/deploy-complete'; if (/^\/risk(?:\s|$)/.test(text)) return '/risk'; if (/^\/plan(?:\s|$)/.test(text)) return '/plan'; if (text === '/approve-plan') return '/approve-plan'; @@ -109,6 +114,7 @@ export interface SessionCommandDeps { note: string | undefined, dedupeKey: string, ) => Promise; + finalizeDeployment: () => Promise; } function resultToText(result: string | object | null | undefined): string { @@ -203,6 +209,15 @@ export async function handleSessionCommand(opts: { return { handled: true, success: true }; } + if (command === '/deploy-complete') { + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + (await deps.finalizeDeployment()) ?? + 'Deploy finalization is unavailable for this room.', + ); + return { handled: true, success: true }; + } + if (command === '/risk') { const riskArg = normalizedCommandText .slice('/risk'.length) diff --git a/src/types.ts b/src/types.ts index 653fcfc..e628faa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,7 +91,8 @@ export type PairedEventType = | 'submit_plan' | 'approve_plan' | 'request_plan_changes' - | 'request_review'; + | 'request_review' + | 'deploy_complete'; export interface RoomRoleContext { serviceId: string; @@ -122,6 +123,7 @@ export interface PairedTask { risk_level: PairedRiskLevel; plan_status: PairedPlanStatus; review_requested_at: string | null; + last_finalized_checkpoint?: string | null; gate_turn_kind?: PairedGateTurnKind | null; reviewer_verdict?: PairedReviewerVerdict | null; reviewer_verdict_at?: string | null;