mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 21:47:53 +00:00
Fixes three tightly-coupled bugs that made web approval gates unusable:
1. orchestrator-agent did not pass parentConversationId to executeWorkflow
for any web-dispatched foreground / interactive / resumable run. Without
that field, findResumableRunByParentConversation (the machinery the CLI
relies on for resume) couldn't find the paused run from the same
conversation on a follow-up message, and the approve/reject API handlers
had no conversation to dispatch back to.
2. POST /api/workflows/runs/:runId/{approve,reject} recorded the decision
and returned "Send a message to continue the workflow." — the workflow
never actually resumed. Added tryAutoResumeAfterGate() that mirrors what
workflowApproveCommand / workflowRejectCommand already do on the CLI:
look up the parent conversation, dispatch `/workflow run <name>
<userMessage>` back through dispatchToOrchestrator. Failures are
non-fatal — the user can still send a manual message as a fallback.
3. The during-streaming cancel-check in dag-executor aborted any streaming
node whenever the run status left 'running', including the legitimate
transition to 'paused' that an approval node performs. A concurrent AI
node in the same DAG layer now tolerates 'paused' and finishes its own
stream; only truly terminal / unknown states (null, cancelled, failed,
completed) abort the in-flight stream.
Web UI: ConfirmRunActionDialog gains an optional reasonInput prop (label +
placeholder) that renders a textarea and passes the trimmed value to
onConfirm. WorkflowRunCard (dashboard) and WorkflowProgressCard (chat)
both use it for Reject now — the chat card was still on window.confirm,
which was both inconsistent with the dashboard and couldn't collect a
reason. The trimmed reason threads through to $REJECTION_REASON in the
workflow's on_reject prompt.
Supersedes #1147. @jonasvanderhaegen surfaced the root cause and shape of
the fix; that PR was 87 commits stale and pre-dated the reject-UX upgrade
(#1261 area), so this is a fresh re-do on current dev.
Tests:
- packages/server/src/routes/api.workflow-runs.test.ts — 5 new cases:
approve with parent dispatches; approve without parent returns "Send a
message"; approve with deleted parent conversation skips safely; reject
dispatches on-reject flows; reject that cancels (no on_reject) does NOT
dispatch.
- packages/core/src/orchestrator/orchestrator.test.ts — updated the two
synthesizedPrompt-dispatch tests for the new executeWorkflow arity.
Closes #1131.
Co-authored-by: Jonas Vanderhaegen <7755555+jonasvanderhaegen@users.noreply.github.com>
1516 lines
52 KiB
TypeScript
1516 lines
52 KiB
TypeScript
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
import { OpenAPIHono } from '@hono/zod-openapi';
|
|
import type { ConversationLockManager } from '@archon/core';
|
|
import type { WebAdapter } from '../adapters/web';
|
|
import { validationErrorHook } from './openapi-defaults';
|
|
import { mockAllWorkflowModules } from '../test/workflow-mock-factories';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock setup — must be before dynamic imports of mocked modules
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockGetWorkflowRun = mock(async (_id: string) => null as null | MockWorkflowRun);
|
|
const mockCancelWorkflowRun = mock(async (_id: string) => {});
|
|
const mockListWorkflowRuns = mock(async () => [] as MockWorkflowRun[]);
|
|
const mockListDashboardRuns = mock(async () => ({
|
|
runs: [] as MockWorkflowRun[],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
const mockGetWorkflowRunByWorkerPlatformId = mock(
|
|
async (_id: string) => null as null | MockWorkflowRun
|
|
);
|
|
const mockListWorkflowEvents = mock(async (_runId: string) => [] as MockWorkflowEvent[]);
|
|
const mockGetConversationById = mock(
|
|
async (_id: string) => null as null | { id: string; platform_conversation_id: string }
|
|
);
|
|
const mockFindConversationByPlatformId = mock(
|
|
async (_id: string) =>
|
|
null as null | {
|
|
id: string;
|
|
platform_conversation_id: string;
|
|
title: string | null;
|
|
ai_assistant_type: string;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
platform_type: string;
|
|
deleted_at: Date | null;
|
|
codebase_id: string | null;
|
|
}
|
|
);
|
|
const mockHandleMessage = mock(async () => {});
|
|
const mockAddMessage = mock(async () => ({
|
|
id: 'msg-1',
|
|
conversation_id: 'conv-1',
|
|
role: 'user' as const,
|
|
content: 'hi',
|
|
metadata: '{}',
|
|
created_at: new Date().toISOString(),
|
|
}));
|
|
const mockGenerateAndSetTitle = mock(async () => {});
|
|
|
|
// Type aliases for clarity in tests
|
|
type MockWorkflowRun = {
|
|
id: string;
|
|
workflow_name: string;
|
|
conversation_id: string | null;
|
|
parent_conversation_id: string | null;
|
|
codebase_id: string | null;
|
|
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused';
|
|
user_message: string;
|
|
started_at: string;
|
|
completed_at: string | null;
|
|
metadata: Record<string, unknown>;
|
|
working_path: string | null;
|
|
last_activity_at: string | null;
|
|
};
|
|
|
|
type MockWorkflowEvent = {
|
|
id: string;
|
|
workflow_run_id: string;
|
|
event_type: string;
|
|
step_index: number | null;
|
|
step_name: string | null;
|
|
data: Record<string, unknown>;
|
|
created_at: string;
|
|
};
|
|
|
|
mock.module('@archon/core', () => ({
|
|
handleMessage: mockHandleMessage,
|
|
getDatabaseType: () => 'sqlite',
|
|
loadConfig: mock(async () => ({})),
|
|
cloneRepository: mock(async () => ({ codebaseId: 'x', alreadyExisted: false })),
|
|
registerRepository: mock(async () => ({ codebaseId: 'x', alreadyExisted: false })),
|
|
ConversationNotFoundError: class ConversationNotFoundError extends Error {
|
|
constructor(id: string) {
|
|
super(`Conversation not found: ${id}`);
|
|
this.name = 'ConversationNotFoundError';
|
|
}
|
|
},
|
|
getArchonWorkspacesPath: () => '/tmp/.archon/workspaces',
|
|
generateAndSetTitle: mockGenerateAndSetTitle,
|
|
createLogger: () => ({
|
|
fatal: mock(() => undefined),
|
|
error: mock(() => undefined),
|
|
warn: mock(() => undefined),
|
|
info: mock(() => undefined),
|
|
debug: mock(() => undefined),
|
|
trace: mock(() => undefined),
|
|
child: mock(function (this: unknown) {
|
|
return this;
|
|
}),
|
|
bindings: mock(() => ({ module: 'test' })),
|
|
isLevelEnabled: mock(() => true),
|
|
level: 'info',
|
|
}),
|
|
}));
|
|
|
|
mock.module('@archon/paths', () => ({
|
|
createLogger: () => ({
|
|
fatal: mock(() => undefined),
|
|
error: mock(() => undefined),
|
|
warn: mock(() => undefined),
|
|
info: mock(() => undefined),
|
|
debug: mock(() => undefined),
|
|
trace: mock(() => undefined),
|
|
child: mock(function (this: unknown) {
|
|
return this;
|
|
}),
|
|
bindings: mock(() => ({ module: 'test' })),
|
|
isLevelEnabled: mock(() => true),
|
|
level: 'info',
|
|
}),
|
|
getWorkflowFolderSearchPaths: mock(() => ['.archon/workflows']),
|
|
getCommandFolderSearchPaths: mock(() => ['.archon/commands']),
|
|
getDefaultCommandsPath: mock(() => '/tmp/.archon-test-nonexistent/commands/defaults'),
|
|
getDefaultWorkflowsPath: mock(() => '/tmp/.archon-test-nonexistent/workflows/defaults'),
|
|
getArchonWorkspacesPath: () => '/tmp/.archon/workspaces',
|
|
}));
|
|
|
|
mockAllWorkflowModules();
|
|
|
|
mock.module('@archon/git', () => ({
|
|
removeWorktree: mock(async () => {}),
|
|
toRepoPath: (p: string) => p,
|
|
toWorktreePath: (p: string) => p,
|
|
}));
|
|
|
|
mock.module('@archon/core/db/conversations', () => ({
|
|
findConversationByPlatformId: mockFindConversationByPlatformId,
|
|
listConversations: mock(async () => []),
|
|
getOrCreateConversation: mock(async () => ({
|
|
id: 'internal-uuid-123',
|
|
platform_conversation_id: 'web-test-abc',
|
|
title: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
platform_type: 'web',
|
|
deleted_at: null,
|
|
codebase_id: null,
|
|
ai_assistant_type: 'claude',
|
|
})),
|
|
softDeleteConversation: mock(async () => {}),
|
|
updateConversationTitle: mock(async () => {}),
|
|
getConversationById: mockGetConversationById,
|
|
}));
|
|
|
|
mock.module('@archon/core/db/codebases', () => ({
|
|
listCodebases: mock(async () => [{ default_cwd: '/tmp/project' }]),
|
|
getCodebase: mock(async () => null),
|
|
deleteCodebase: mock(async () => {}),
|
|
}));
|
|
|
|
mock.module('@archon/core/db/isolation-environments', () => ({
|
|
listByCodebase: mock(async () => []),
|
|
updateStatus: mock(async () => {}),
|
|
}));
|
|
|
|
const mockDeleteWorkflowRun = mock(async (_id: string) => {});
|
|
const mockUpdateWorkflowRun = mock(async (_id: string, _update: unknown) => {});
|
|
|
|
mock.module('@archon/core/db/workflows', () => ({
|
|
listWorkflowRuns: mockListWorkflowRuns,
|
|
listDashboardRuns: mockListDashboardRuns,
|
|
getWorkflowRun: mockGetWorkflowRun,
|
|
cancelWorkflowRun: mockCancelWorkflowRun,
|
|
deleteWorkflowRun: mockDeleteWorkflowRun,
|
|
updateWorkflowRun: mockUpdateWorkflowRun,
|
|
getWorkflowRunByWorkerPlatformId: mockGetWorkflowRunByWorkerPlatformId,
|
|
}));
|
|
|
|
const mockCreateWorkflowEvent = mock(async (_event: unknown) => {});
|
|
|
|
mock.module('@archon/core/db/workflow-events', () => ({
|
|
listWorkflowEvents: mockListWorkflowEvents,
|
|
createWorkflowEvent: mockCreateWorkflowEvent,
|
|
}));
|
|
|
|
mock.module('@archon/core/db/messages', () => ({
|
|
addMessage: mockAddMessage,
|
|
listMessages: mock(async () => []),
|
|
}));
|
|
|
|
mock.module('@archon/core/utils/commands', () => ({
|
|
findMarkdownFilesRecursive: mock(async () => []),
|
|
}));
|
|
|
|
import { registerApiRoutes } from './api';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NOW = new Date().toISOString();
|
|
|
|
const MOCK_RUNNING_RUN: MockWorkflowRun = {
|
|
id: 'run-uuid-1',
|
|
workflow_name: 'deploy',
|
|
conversation_id: 'conv-uuid-1',
|
|
parent_conversation_id: null,
|
|
codebase_id: 'cb-uuid-1',
|
|
status: 'running',
|
|
user_message: 'Deploy to staging',
|
|
started_at: NOW,
|
|
completed_at: null,
|
|
metadata: {},
|
|
working_path: '/tmp/worktrees/feature',
|
|
last_activity_at: NOW,
|
|
};
|
|
|
|
const MOCK_COMPLETED_RUN: MockWorkflowRun = {
|
|
...MOCK_RUNNING_RUN,
|
|
id: 'run-uuid-2',
|
|
status: 'completed',
|
|
completed_at: NOW,
|
|
};
|
|
|
|
const MOCK_FAILED_RUN: MockWorkflowRun = {
|
|
...MOCK_RUNNING_RUN,
|
|
id: 'run-uuid-4',
|
|
status: 'failed',
|
|
completed_at: NOW,
|
|
};
|
|
|
|
const MOCK_PENDING_RUN: MockWorkflowRun = {
|
|
...MOCK_RUNNING_RUN,
|
|
id: 'run-uuid-3',
|
|
status: 'pending',
|
|
};
|
|
|
|
const MOCK_EVENTS: MockWorkflowEvent[] = [
|
|
{
|
|
id: 'evt-1',
|
|
workflow_run_id: 'run-uuid-1',
|
|
event_type: 'step_started',
|
|
step_index: 0,
|
|
step_name: 'plan',
|
|
data: {},
|
|
created_at: NOW,
|
|
},
|
|
{
|
|
id: 'evt-2',
|
|
workflow_run_id: 'run-uuid-1',
|
|
event_type: 'step_completed',
|
|
step_index: 0,
|
|
step_name: 'plan',
|
|
data: { duration_ms: 1234 },
|
|
created_at: NOW,
|
|
},
|
|
{
|
|
id: 'evt-3',
|
|
workflow_run_id: 'run-uuid-1',
|
|
event_type: 'tool_called',
|
|
step_index: 0,
|
|
step_name: 'plan',
|
|
data: { tool_name: 'Read', tool_input: { file_path: '/tmp/test.ts' } },
|
|
created_at: NOW,
|
|
},
|
|
];
|
|
|
|
const MOCK_CONV = {
|
|
id: 'internal-uuid-123',
|
|
platform_conversation_id: 'web-test-abc',
|
|
title: null,
|
|
ai_assistant_type: 'claude',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
platform_type: 'web',
|
|
deleted_at: null,
|
|
codebase_id: null,
|
|
};
|
|
|
|
function makeApp(): { app: OpenAPIHono; mockWebAdapter: WebAdapter } {
|
|
const app = new OpenAPIHono({ defaultHook: validationErrorHook });
|
|
const mockWebAdapter = {
|
|
setConversationDbId: mock((_platformId: string, _dbId: string) => {}),
|
|
emitSSE: mock(async () => {}),
|
|
emitLockEvent: mock(async () => {}),
|
|
} as unknown as WebAdapter;
|
|
const mockLockManager = {
|
|
acquireLock: mock(async (_id: string, fn: () => Promise<void>) => {
|
|
await fn();
|
|
return { status: 'started' };
|
|
}),
|
|
getStats: mock(() => ({ active: 0, queued: 0 })),
|
|
} as unknown as ConversationLockManager;
|
|
registerApiRoutes(app, mockWebAdapter, mockLockManager);
|
|
return { app, mockWebAdapter };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/:name/run
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/workflows/:name/run', () => {
|
|
beforeEach(() => {
|
|
mockFindConversationByPlatformId.mockReset();
|
|
mockHandleMessage.mockReset();
|
|
mockAddMessage.mockReset();
|
|
mockGenerateAndSetTitle.mockReset();
|
|
});
|
|
|
|
test('dispatches workflow run to orchestrator and returns accepted', async () => {
|
|
mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV);
|
|
mockAddMessage.mockImplementationOnce(async () => ({
|
|
id: 'msg-1',
|
|
conversation_id: MOCK_CONV.id,
|
|
role: 'user' as const,
|
|
content: 'Deploy to staging',
|
|
metadata: '{}',
|
|
created_at: NOW,
|
|
}));
|
|
mockHandleMessage.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Deploy to staging' }),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as { accepted: boolean; status: string };
|
|
expect(body.accepted).toBe(true);
|
|
expect(body.status).toBe('started');
|
|
});
|
|
|
|
test('sends /workflow run <name> <message> to orchestrator', async () => {
|
|
mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV);
|
|
mockAddMessage.mockImplementationOnce(async () => ({
|
|
id: 'msg-1',
|
|
conversation_id: MOCK_CONV.id,
|
|
role: 'user' as const,
|
|
content: 'Run tests',
|
|
metadata: '{}',
|
|
created_at: NOW,
|
|
}));
|
|
mockHandleMessage.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/test-suite/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Run tests' }),
|
|
});
|
|
|
|
expect(mockHandleMessage).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'web-test-abc',
|
|
'/workflow run test-suite Run tests',
|
|
expect.objectContaining({
|
|
isolationHints: { workflowType: 'thread', workflowId: 'web-test-abc' },
|
|
})
|
|
);
|
|
});
|
|
|
|
test('persists user message to DB when conversation found', async () => {
|
|
mockFindConversationByPlatformId.mockImplementationOnce(async () => MOCK_CONV);
|
|
mockAddMessage.mockImplementationOnce(async () => ({
|
|
id: 'msg-1',
|
|
conversation_id: MOCK_CONV.id,
|
|
role: 'user' as const,
|
|
content: 'Deploy',
|
|
metadata: '{}',
|
|
created_at: NOW,
|
|
}));
|
|
mockHandleMessage.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Deploy' }),
|
|
});
|
|
|
|
expect(mockAddMessage).toHaveBeenCalledWith(MOCK_CONV.id, 'user', 'Deploy');
|
|
});
|
|
|
|
test('fires title generation for conversations without title', async () => {
|
|
mockFindConversationByPlatformId.mockImplementationOnce(async () => ({
|
|
...MOCK_CONV,
|
|
title: null,
|
|
}));
|
|
mockAddMessage.mockImplementationOnce(async () => ({
|
|
id: 'msg-1',
|
|
conversation_id: MOCK_CONV.id,
|
|
role: 'user' as const,
|
|
content: 'Deploy',
|
|
metadata: '{}',
|
|
created_at: NOW,
|
|
}));
|
|
mockHandleMessage.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Deploy' }),
|
|
});
|
|
|
|
// generateAndSetTitle is fire-and-forget; just verify it was called
|
|
// (it runs asynchronously so we check the mock was called, not the result)
|
|
// Allow the microtask queue to flush
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
expect(mockGenerateAndSetTitle).toHaveBeenCalled();
|
|
});
|
|
|
|
test('returns 400 when conversationId is missing', async () => {
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: 'Deploy to staging' }),
|
|
});
|
|
expect(response.status).toBe(400);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('conversationId');
|
|
});
|
|
|
|
test('returns 400 when message is missing', async () => {
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc' }),
|
|
});
|
|
expect(response.status).toBe(400);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('message');
|
|
});
|
|
|
|
test('returns 400 for invalid workflow name (path traversal)', async () => {
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/../secret/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Test' }),
|
|
});
|
|
// Hono routes won't match ../secret as /:name due to path normalization — either 400 or 404
|
|
expect([400, 404]).toContain(response.status);
|
|
});
|
|
|
|
test('returns 400 when isValidCommandName rejects the name', async () => {
|
|
const { isValidCommandName } = await import('@archon/workflows/command-validation');
|
|
(isValidCommandName as ReturnType<typeof mock>).mockReturnValueOnce(false);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/.hidden/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ conversationId: 'web-test-abc', message: 'Test' }),
|
|
});
|
|
expect(response.status).toBe(400);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Invalid workflow name');
|
|
});
|
|
|
|
test('returns 400 for malformed JSON body', async () => {
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/deploy/run', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: 'not valid json {{{',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/runs/:runId/cancel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/workflows/runs/:runId/cancel', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockCancelWorkflowRun.mockReset();
|
|
});
|
|
|
|
test('cancels a running workflow run and returns success', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => MOCK_RUNNING_RUN);
|
|
mockCancelWorkflowRun.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('deploy');
|
|
expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-uuid-1');
|
|
});
|
|
|
|
test('cancels a pending workflow run and returns success', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => MOCK_PENDING_RUN);
|
|
mockCancelWorkflowRun.mockImplementationOnce(async () => {});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-3/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as { success: boolean };
|
|
expect(body.success).toBe(true);
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => null);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/unknown-run/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(404);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
test('returns 400 when trying to cancel a completed run', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => MOCK_COMPLETED_RUN);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-2/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('completed');
|
|
});
|
|
|
|
test('returns 400 when trying to cancel an already-cancelled run', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => ({
|
|
...MOCK_RUNNING_RUN,
|
|
status: 'cancelled' as const,
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('cancelled');
|
|
});
|
|
|
|
test('returns 400 when trying to cancel a failed run', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => ({
|
|
...MOCK_RUNNING_RUN,
|
|
status: 'failed' as const,
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
test('returns 500 when DB throws during cancel', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => MOCK_RUNNING_RUN);
|
|
mockCancelWorkflowRun.mockImplementationOnce(async () => {
|
|
throw new Error('DB locked');
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/cancel', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(500);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Failed to cancel');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: GET /api/workflows/runs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/workflows/runs', () => {
|
|
beforeEach(() => {
|
|
mockListWorkflowRuns.mockReset();
|
|
});
|
|
|
|
test('returns empty runs array when no runs exist', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as { runs: unknown[] };
|
|
expect(Array.isArray(body.runs)).toBe(true);
|
|
expect(body.runs.length).toBe(0);
|
|
});
|
|
|
|
test('returns list of workflow runs', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => [MOCK_RUNNING_RUN, MOCK_COMPLETED_RUN]);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as { runs: Array<{ id: string }> };
|
|
expect(body.runs.length).toBe(2);
|
|
expect(body.runs[0]?.id).toBe('run-uuid-1');
|
|
});
|
|
|
|
test('filters by status query param', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => [MOCK_RUNNING_RUN]);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs?status=running');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [
|
|
[{ status?: string; limit?: number }],
|
|
][];
|
|
expect(callArgs?.status).toBe('running');
|
|
});
|
|
|
|
test('ignores invalid status values', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs?status=invalid_status');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [
|
|
[{ status?: string; limit?: number }],
|
|
][];
|
|
expect(callArgs?.status).toBeUndefined();
|
|
});
|
|
|
|
test('filters by conversationId query param', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs?conversationId=conv-123');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [[{ conversationId?: string }]][];
|
|
expect(callArgs?.conversationId).toBe('conv-123');
|
|
});
|
|
|
|
test('filters by codebaseId query param', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs?codebaseId=cb-uuid-1');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [[{ codebaseId?: string }]][];
|
|
expect(callArgs?.codebaseId).toBe('cb-uuid-1');
|
|
});
|
|
|
|
test('caps limit at 200', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs?limit=9999');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [[{ limit?: number }]][];
|
|
expect(callArgs?.limit).toBeLessThanOrEqual(200);
|
|
});
|
|
|
|
test('uses default limit of 50 when not specified', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/workflows/runs');
|
|
|
|
const [[callArgs]] = mockListWorkflowRuns.mock.calls as [[{ limit?: number }]][];
|
|
expect(callArgs?.limit).toBe(50);
|
|
});
|
|
|
|
test('returns 500 when DB throws', async () => {
|
|
mockListWorkflowRuns.mockImplementationOnce(async () => {
|
|
throw new Error('DB failure');
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs');
|
|
expect(response.status).toBe(500);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Failed to list workflow runs');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: GET /api/workflows/runs/:runId
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/workflows/runs/:runId', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockListWorkflowEvents.mockReset();
|
|
mockGetConversationById.mockReset();
|
|
});
|
|
|
|
test('returns run with events for a known runId', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => MOCK_RUNNING_RUN);
|
|
mockListWorkflowEvents.mockImplementationOnce(async () => MOCK_EVENTS);
|
|
mockGetConversationById.mockImplementationOnce(async () => ({
|
|
id: 'conv-uuid-1',
|
|
platform_conversation_id: 'web-conv-abc',
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as {
|
|
run: { id: string; workflow_name: string };
|
|
events: Array<{ event_type: string }>;
|
|
};
|
|
expect(body.run.id).toBe('run-uuid-1');
|
|
expect(body.run.workflow_name).toBe('deploy');
|
|
expect(Array.isArray(body.events)).toBe(true);
|
|
expect(body.events.length).toBe(3);
|
|
expect(body.events[0]?.event_type).toBe('step_started');
|
|
expect(body.events[2]?.event_type).toBe('tool_called');
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => null);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/unknown-run-id');
|
|
expect(response.status).toBe(404);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
test('includes conversation_platform_id for CLI runs (no parent_conversation_id)', async () => {
|
|
// CLI run: conversation_id set, no parent_conversation_id
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => ({
|
|
...MOCK_RUNNING_RUN,
|
|
parent_conversation_id: null,
|
|
}));
|
|
mockListWorkflowEvents.mockImplementationOnce(async () => []);
|
|
mockGetConversationById.mockImplementationOnce(async () => ({
|
|
id: 'conv-uuid-1',
|
|
platform_conversation_id: 'cli-conv-xyz',
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as {
|
|
run: {
|
|
conversation_platform_id: string | null;
|
|
worker_platform_id: string | undefined;
|
|
};
|
|
};
|
|
// CLI run: conversation_platform_id should be set, worker_platform_id should be undefined
|
|
expect(body.run.conversation_platform_id).toBe('cli-conv-xyz');
|
|
expect(body.run.worker_platform_id).toBeUndefined();
|
|
});
|
|
|
|
test('includes worker_platform_id for web runs (with parent_conversation_id)', async () => {
|
|
// Web run: conversation_id is the worker, parent_conversation_id is the parent
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => ({
|
|
...MOCK_RUNNING_RUN,
|
|
parent_conversation_id: 'parent-conv-uuid',
|
|
}));
|
|
mockListWorkflowEvents.mockImplementationOnce(async () => []);
|
|
// First call: worker conversation
|
|
mockGetConversationById.mockImplementationOnce(async () => ({
|
|
id: 'conv-uuid-1',
|
|
platform_conversation_id: 'worker-platform-id',
|
|
}));
|
|
// Second call: parent conversation
|
|
mockGetConversationById.mockImplementationOnce(async () => ({
|
|
id: 'parent-conv-uuid',
|
|
platform_conversation_id: 'parent-platform-id',
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as {
|
|
run: {
|
|
worker_platform_id: string | undefined;
|
|
parent_platform_id: string | undefined;
|
|
conversation_platform_id: string | null;
|
|
};
|
|
};
|
|
expect(body.run.worker_platform_id).toBe('worker-platform-id');
|
|
expect(body.run.parent_platform_id).toBe('parent-platform-id');
|
|
});
|
|
|
|
test('returns run with null conversation fields when no conversation_id', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => ({
|
|
...MOCK_RUNNING_RUN,
|
|
conversation_id: null,
|
|
parent_conversation_id: null,
|
|
}));
|
|
mockListWorkflowEvents.mockImplementationOnce(async () => []);
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as {
|
|
run: { conversation_platform_id: null };
|
|
};
|
|
expect(body.run.conversation_platform_id).toBeNull();
|
|
});
|
|
|
|
test('returns 500 when DB throws', async () => {
|
|
mockGetWorkflowRun.mockImplementationOnce(async () => {
|
|
throw new Error('DB timeout');
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1');
|
|
expect(response.status).toBe(500);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Failed to get workflow run');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: GET /api/dashboard/runs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/dashboard/runs', () => {
|
|
beforeEach(() => {
|
|
mockListDashboardRuns.mockReset();
|
|
});
|
|
|
|
test('returns paginated runs with total and counts', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [MOCK_RUNNING_RUN, MOCK_COMPLETED_RUN],
|
|
total: 2,
|
|
counts: { all: 5, running: 1, completed: 2, failed: 1, cancelled: 1, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/dashboard/runs');
|
|
expect(response.status).toBe(200);
|
|
|
|
const body = (await response.json()) as {
|
|
runs: unknown[];
|
|
total: number;
|
|
counts: { all: number };
|
|
};
|
|
expect(Array.isArray(body.runs)).toBe(true);
|
|
expect(body.runs.length).toBe(2);
|
|
expect(body.total).toBe(2);
|
|
expect(body.counts.all).toBe(5);
|
|
});
|
|
|
|
test('filters by status query param', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?status=running');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ status?: string }]][];
|
|
expect(callArgs?.status).toBe('running');
|
|
});
|
|
|
|
test('accepts paused as valid status', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?status=paused');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ status?: string }]][];
|
|
expect(callArgs?.status).toBe('paused');
|
|
});
|
|
|
|
test('ignores invalid status values in dashboard runs', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?status=bogus');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ status?: string }]][];
|
|
expect(callArgs?.status).toBeUndefined();
|
|
});
|
|
|
|
test('filters by codebaseId query param', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?codebaseId=cb-1');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ codebaseId?: string }]][];
|
|
expect(callArgs?.codebaseId).toBe('cb-1');
|
|
});
|
|
|
|
test('filters by search query param', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?search=deploy');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ search?: string }]][];
|
|
expect(callArgs?.search).toBe('deploy');
|
|
});
|
|
|
|
test('supports after and before date filters', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?after=2024-01-01T00:00:00Z&before=2024-12-31T23:59:59Z');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [
|
|
[{ after?: string; before?: string }],
|
|
][];
|
|
expect(callArgs?.after).toBe('2024-01-01T00:00:00Z');
|
|
expect(callArgs?.before).toBe('2024-12-31T23:59:59Z');
|
|
});
|
|
|
|
test('caps limit at 200', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?limit=9999');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ limit?: number }]][];
|
|
expect(callArgs?.limit).toBeLessThanOrEqual(200);
|
|
});
|
|
|
|
test('supports offset for pagination', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => ({
|
|
runs: [],
|
|
total: 0,
|
|
counts: { all: 0, running: 0, completed: 0, failed: 0, cancelled: 0, pending: 0 },
|
|
}));
|
|
|
|
const { app } = makeApp();
|
|
await app.request('/api/dashboard/runs?offset=50');
|
|
|
|
const [[callArgs]] = mockListDashboardRuns.mock.calls as [[{ offset?: number }]][];
|
|
expect(callArgs?.offset).toBe(50);
|
|
});
|
|
|
|
test('returns 500 when DB throws', async () => {
|
|
mockListDashboardRuns.mockImplementationOnce(async () => {
|
|
throw new Error('query timeout');
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/dashboard/runs');
|
|
expect(response.status).toBe(500);
|
|
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Failed to list dashboard runs');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/workflows/runs/by-worker/:platformId', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRunByWorkerPlatformId.mockReset();
|
|
});
|
|
|
|
test('returns run when found', async () => {
|
|
mockGetWorkflowRunByWorkerPlatformId.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/by-worker/some-platform-id');
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { run: unknown };
|
|
expect(body.run).toBeDefined();
|
|
});
|
|
|
|
test('returns 404 when not found', async () => {
|
|
mockGetWorkflowRunByWorkerPlatformId.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/by-worker/unknown-id');
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/runs/:runId/resume
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/workflows/runs/:runId/resume', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-missing/resume', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('returns 400 when run is not in failed status', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/resume', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Cannot resume');
|
|
});
|
|
|
|
test('returns 200 with message when run is failed', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_FAILED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-4/resume', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('ready to resume');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/runs/:runId/abandon
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/workflows/runs/:runId/abandon', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockCancelWorkflowRun.mockReset();
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-missing/abandon', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('returns 400 when run is already terminal', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_COMPLETED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-2/abandon', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Cannot abandon');
|
|
});
|
|
|
|
test('returns 200 and calls cancelWorkflowRun for running run', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/abandon', {
|
|
method: 'POST',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('Abandoned');
|
|
expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-uuid-1');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: DELETE /api/workflows/runs/:runId
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('DELETE /api/workflows/runs/:runId', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockDeleteWorkflowRun.mockReset();
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-missing', {
|
|
method: 'DELETE',
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('returns 400 when run is not terminal', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1', {
|
|
method: 'DELETE',
|
|
});
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('Cannot delete');
|
|
});
|
|
|
|
test('returns 200 and deletes a completed run', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_COMPLETED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-2', {
|
|
method: 'DELETE',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('Deleted');
|
|
expect(mockDeleteWorkflowRun).toHaveBeenCalledWith('run-uuid-2');
|
|
});
|
|
|
|
test('returns 200 and deletes a failed run', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_FAILED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-4', {
|
|
method: 'DELETE',
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(mockDeleteWorkflowRun).toHaveBeenCalledWith('run-uuid-4');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/runs/:runId/approve
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOCK_PAUSED_RUN: MockWorkflowRun = {
|
|
...MOCK_RUNNING_RUN,
|
|
id: 'run-paused-1',
|
|
status: 'paused',
|
|
metadata: {
|
|
approval: {
|
|
type: 'approval',
|
|
nodeId: 'review-gate',
|
|
message: 'Review the plan',
|
|
},
|
|
},
|
|
};
|
|
|
|
describe('POST /api/workflows/runs/:runId/approve', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockUpdateWorkflowRun.mockReset();
|
|
mockCreateWorkflowEvent.mockReset();
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/missing/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment: 'LGTM' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('returns 400 when run is not paused', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
test('stores user comment as node_output when captureResponse is true', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
id: 'run-capture',
|
|
metadata: {
|
|
approval: {
|
|
type: 'approval',
|
|
nodeId: 'review-gate',
|
|
message: 'Review the plan',
|
|
captureResponse: true,
|
|
},
|
|
},
|
|
});
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-capture/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment: 'Looks great, proceed' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const nodeCompletedCall = mockCreateWorkflowEvent.mock.calls.find(
|
|
(c: unknown[]) => (c[0] as Record<string, unknown>).event_type === 'node_completed'
|
|
);
|
|
expect(nodeCompletedCall?.[0]).toMatchObject({
|
|
data: { node_output: 'Looks great, proceed', approval_decision: 'approved' },
|
|
});
|
|
});
|
|
|
|
test('stores empty node_output when captureResponse is not set', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_PAUSED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-paused-1/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment: 'a comment' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const nodeCompletedCall = mockCreateWorkflowEvent.mock.calls.find(
|
|
(c: unknown[]) => (c[0] as Record<string, unknown>).event_type === 'node_completed'
|
|
);
|
|
expect(nodeCompletedCall?.[0]).toMatchObject({
|
|
data: { node_output: '', approval_decision: 'approved' },
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: POST /api/workflows/runs/:runId/reject
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/workflows/runs/:runId/reject', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockUpdateWorkflowRun.mockReset();
|
|
mockCancelWorkflowRun.mockReset();
|
|
mockCreateWorkflowEvent.mockReset();
|
|
});
|
|
|
|
test('returns 404 when run not found', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(null);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/missing/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'bad' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test('returns 400 when run is not paused', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_RUNNING_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-uuid-1/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'bad' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
test('cancels immediately when no on_reject configured', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce(MOCK_PAUSED_RUN);
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-paused-1/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'needs work' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-paused-1');
|
|
});
|
|
|
|
test('records rejection and increments count when on_reject configured and under limit', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
id: 'run-on-reject',
|
|
metadata: {
|
|
approval: {
|
|
type: 'approval',
|
|
nodeId: 'review-gate',
|
|
message: 'Approve?',
|
|
onRejectPrompt: 'Fix: $REJECTION_REASON',
|
|
onRejectMaxAttempts: 3,
|
|
},
|
|
rejection_count: 0,
|
|
},
|
|
});
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-on-reject/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'needs more tests' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('On-reject prompt');
|
|
expect(mockUpdateWorkflowRun).toHaveBeenCalledWith('run-on-reject', {
|
|
status: 'failed',
|
|
metadata: { rejection_reason: 'needs more tests', rejection_count: 1 },
|
|
});
|
|
expect(mockCancelWorkflowRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('cancels when max attempts reached', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
id: 'run-max-attempts',
|
|
metadata: {
|
|
approval: {
|
|
type: 'approval',
|
|
nodeId: 'review-gate',
|
|
message: 'Approve?',
|
|
onRejectPrompt: 'Fix: $REJECTION_REASON',
|
|
onRejectMaxAttempts: 3,
|
|
},
|
|
rejection_count: 2,
|
|
},
|
|
});
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-max-attempts/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'still bad' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { success: boolean; message: string };
|
|
expect(body.success).toBe(true);
|
|
expect(body.message).toContain('max attempts reached');
|
|
expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-max-attempts');
|
|
expect(mockUpdateWorkflowRun).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-resume: approve/reject endpoints dispatch to orchestrator when the run
|
|
// has parent_conversation_id set (web-dispatched foreground/interactive
|
|
// workflows). Mirrors what the CLI does in workflowApproveCommand/RejectCommand.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('approve/reject auto-resume', () => {
|
|
beforeEach(() => {
|
|
mockGetWorkflowRun.mockReset();
|
|
mockUpdateWorkflowRun.mockReset();
|
|
mockCreateWorkflowEvent.mockReset();
|
|
mockGetConversationById.mockReset();
|
|
mockHandleMessage.mockReset();
|
|
mockCancelWorkflowRun.mockReset();
|
|
});
|
|
|
|
test('approve: dispatches resume when parent_conversation_id is set', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
id: 'run-auto-resume-approve',
|
|
parent_conversation_id: 'parent-conv-uuid',
|
|
user_message: 'Deploy feature X',
|
|
});
|
|
mockGetConversationById.mockResolvedValueOnce({
|
|
id: 'parent-conv-uuid',
|
|
platform_conversation_id: 'web-plat-abc',
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-auto-resume-approve/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment: 'LGTM' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { message: string };
|
|
expect(body.message).toContain('Resuming workflow');
|
|
|
|
// dispatchToOrchestrator → lockManager → handleMessage
|
|
expect(mockHandleMessage).toHaveBeenCalled();
|
|
const [, platformConvId, dispatchedMessage] = mockHandleMessage.mock.calls[0] as [
|
|
unknown,
|
|
string,
|
|
string,
|
|
];
|
|
expect(platformConvId).toBe('web-plat-abc');
|
|
expect(dispatchedMessage).toBe('/workflow run deploy Deploy feature X');
|
|
});
|
|
|
|
test('approve: skips dispatch when parent_conversation_id is null (CLI-dispatched run)', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
parent_conversation_id: null,
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-paused-1/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ comment: 'LGTM' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { message: string };
|
|
expect(body.message).toContain('Send a message to continue');
|
|
expect(mockHandleMessage).not.toHaveBeenCalled();
|
|
expect(mockGetConversationById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('approve: skips dispatch when parent conversation no longer exists', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
parent_conversation_id: 'deleted-conv-uuid',
|
|
});
|
|
mockGetConversationById.mockResolvedValueOnce(null); // conversation deleted
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-paused-1/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { message: string };
|
|
expect(body.message).toContain('Send a message to continue');
|
|
expect(mockHandleMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('reject: dispatches resume for on_reject flows when parent is set', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
id: 'run-auto-resume-reject',
|
|
parent_conversation_id: 'parent-conv-uuid',
|
|
user_message: 'Review PR',
|
|
metadata: {
|
|
approval: {
|
|
type: 'approval',
|
|
nodeId: 'review-gate',
|
|
message: 'Approve?',
|
|
onRejectPrompt: 'Fix: $REJECTION_REASON',
|
|
onRejectMaxAttempts: 3,
|
|
},
|
|
rejection_count: 0,
|
|
},
|
|
});
|
|
mockGetConversationById.mockResolvedValueOnce({
|
|
id: 'parent-conv-uuid',
|
|
platform_conversation_id: 'web-plat-xyz',
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-auto-resume-reject/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'tests missing' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { message: string };
|
|
expect(body.message).toContain('Running on-reject prompt');
|
|
expect(mockHandleMessage).toHaveBeenCalled();
|
|
const [, platformConvId, dispatchedMessage] = mockHandleMessage.mock.calls[0] as [
|
|
unknown,
|
|
string,
|
|
string,
|
|
];
|
|
expect(platformConvId).toBe('web-plat-xyz');
|
|
expect(dispatchedMessage).toBe('/workflow run deploy Review PR');
|
|
});
|
|
|
|
test('reject: does NOT dispatch when the run is being cancelled (no on_reject configured)', async () => {
|
|
mockGetWorkflowRun.mockResolvedValueOnce({
|
|
...MOCK_PAUSED_RUN,
|
|
parent_conversation_id: 'parent-conv-uuid', // set, but doesn't matter — reject cancels
|
|
});
|
|
|
|
const { app } = makeApp();
|
|
const response = await app.request('/api/workflows/runs/run-paused-1/reject', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: 'no' }),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
// Cancellation path doesn't auto-resume — nothing to resume to.
|
|
expect(mockHandleMessage).not.toHaveBeenCalled();
|
|
expect(mockCancelWorkflowRun).toHaveBeenCalledWith('run-paused-1');
|
|
});
|
|
});
|