diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index be0de381cb7..95680c44ac7 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -2300,7 +2300,9 @@ describe('createAcpSessionBridge', () => { availableSkills: [], }); await expect( - bridge.getSessionTasksStatus(session.sessionId), + bridge.getSessionTasksStatus(session.sessionId, { + includeWorkflows: true, + }), ).resolves.toMatchObject({ sessionId: session.sessionId, tasks: [], @@ -2324,6 +2326,10 @@ describe('createAcpSessionBridge', () => { 'qwen/status/session/tasks', 'qwen/status/session/lsp', ]); + expect(handles[0]?.agent.extMethodCalls[2]?.params).toMatchObject({ + sessionId: session.sessionId, + includeWorkflows: true, + }); await bridge.shutdown(); }); @@ -15272,6 +15278,19 @@ describe('createAcpSessionBridge', () => { { clientId: 'client-not-issued' }, ), ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.cancelSessionTask(session.sessionId, 'task-1', 'workflow', { + clientId: 'client-not-issued', + }), + ).rejects.toBeInstanceOf(InvalidClientIdError); + await expect( + bridge.controlSessionWorkflowTask( + session.sessionId, + 'task-1', + 'rerun', + { clientId: 'client-not-issued' }, + ), + ).rejects.toBeInstanceOf(InvalidClientIdError); await bridge.shutdown(); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 6a715418b40..cb8a69d3317 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -65,6 +65,7 @@ import { type ServeSessionContextStatus, type ServeSessionLspStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, type ServeWorkspaceMcpResourcesStatus, type ServeWorkspaceMcpStatus, type ServeWorkspaceMcpToolsStatus, @@ -8867,10 +8868,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { return requestSessionStatus( sessionId, SERVE_STATUS_EXT_METHODS.sessionTasks, + { includeWorkflows: opts?.includeWorkflows === true }, ); }, @@ -8885,7 +8887,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return requestSessionTranscriptPage(req); }, - async cancelSessionTask(sessionId, taskId, taskKind) { + async cancelSessionTask(sessionId, taskId, taskKind, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); return requestSessionStatus<{ cancelled: boolean }>( sessionId, SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, @@ -8893,6 +8898,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return requestSessionStatus<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>(sessionId, SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + taskId, + action, + }); + }, + async clearSessionGoal(sessionId) { return requestSessionStatus<{ cleared: boolean; condition?: string }>( sessionId, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index a1b41316bc7..e59964980f0 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -37,6 +37,7 @@ import type { ServeSessionLspStatus, ServeSessionSupportedCommandsStatus, ServeSessionTasksStatus, + ServeSessionWorkflowTaskStatus, ServeWorkspaceExtensionsStatus, ServeWorkspaceHooksStatus, ServeWorkspaceMcpToolsStatus, @@ -1447,7 +1448,10 @@ export interface AcpSessionBridge { ): Promise; /** Read the live background task snapshot for a live session. */ - getSessionTasksStatus(sessionId: string): Promise; + getSessionTasksStatus( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ): Promise; /** Read sanitized LSP server status for a live session. */ getSessionLspStatus(sessionId: string): Promise; @@ -1465,9 +1469,28 @@ export interface AcpSessionBridge { cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ): Promise<{ cancelled: boolean }>; + /** Control a run, delete history, or start a saved workflow definition. */ + controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ): Promise<{ + changed: boolean; + status?: ServeSessionWorkflowTaskStatus['status']; + taskId?: string; + }>; + /** Clear an active goal in a live session without cancelling the running prompt. */ clearSessionGoal( sessionId: string, diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index c88b3cfee35..339efcb28e4 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -175,6 +175,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMemoryDream: 'qwen/control/workspace/memory/dream', // Runtime MCP server mutation ext-methods sessionTaskCancel: 'qwen/control/session/task/cancel', + sessionWorkflowTaskAction: 'qwen/control/session/task/workflow-action', sessionGoalClear: 'qwen/control/session/goal/clear', /** * Read a live session's `/goal` state. The active goal lives only in the @@ -601,6 +602,13 @@ export interface ServeSessionSupportedCommandsStatus { sessionId: string; availableCommands: AvailableCommand[]; availableSkills: string[]; + /** Whether the Workflow tool and its Web Shell surfaces are enabled. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; } export interface ServeLspServerStatus { @@ -712,10 +720,124 @@ export interface ServeSessionMonitorTaskStatus { toolUseId?: string; } +export interface ServeWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type ServeWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface ServeWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: ServeWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface ServeWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface ServeWorkflowEventBase { + id: string; + at: number; +} + +export type ServeWorkflowEvent = + | (ServeWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (ServeWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (ServeWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (ServeWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (ServeWorkflowEventBase & { type: 'log'; message: string }) + | (ServeWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (ServeWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface ServeSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: ServeSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: ServeWorkflowPhaseVisit[]; + dispatches: ServeWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: ServeWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: ServeWorkflowApprovalStatusEntry[]; + error?: string; +} + export type ServeSessionTaskStatus = | ServeSessionAgentTaskStatus | ServeSessionShellTaskStatus - | ServeSessionMonitorTaskStatus; + | ServeSessionMonitorTaskStatus + | ServeSessionWorkflowTaskStatus; export interface ServeSessionTasksStatus { v: typeof STATUS_SCHEMA_VERSION; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c7f5e80e201..6584beeb8e4 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -90,6 +90,13 @@ const { mockPreloadContentGenerator } = vi.hoisted(() => ({ mockPreloadContentGenerator: vi.fn().mockResolvedValue(undefined), })); +const { mockListWorkflowSnapshots } = vi.hoisted(() => ({ + mockListWorkflowSnapshots: vi.fn().mockResolvedValue([]), +})); +const { mockListSavedWorkflows } = vi.hoisted(() => ({ + mockListSavedWorkflows: vi.fn().mockResolvedValue([]), +})); + const { mockExtractDaemonTraceContext, mockSessionStartSpan, @@ -228,6 +235,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ ), initializeTelemetry: vi.fn().mockResolvedValue(undefined), preloadContentGenerator: mockPreloadContentGenerator, + listSavedWorkflows: mockListSavedWorkflows, + listWorkflowSnapshots: mockListWorkflowSnapshots, createDebugLogger: () => mockDebugLogger, extractDaemonTraceContext: mockExtractDaemonTraceContext, withDaemonSpan: mockWithDaemonSpan, @@ -1948,6 +1957,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { appendLiveConversationTranscript: ReturnType; prompt: ReturnType; releaseTodoStopGuardQueuedPromptWait: ReturnType; + deleteWorkflowHistory: ReturnType; } | undefined; let processExitSpy: MockInstance; @@ -3418,6 +3428,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + isWorkflowsEnabled: vi.fn().mockReturnValue(false), + getBareMode: vi.fn().mockReturnValue(false), + getFolderTrustFeature: vi.fn().mockReturnValue(false), + getFolderTrust: vi.fn().mockReturnValue(true), }; } @@ -3651,45 +3665,61 @@ describe('QwenAgent MCP SSE/HTTP support', () => { vi.mocked(loadCliConfig).mockResolvedValue( innerConfig as unknown as Config, ); - vi.mocked(Session).mockImplementation((createdSessionId, createdConfig) => { - const sessionMock = { - getId: vi.fn().mockReturnValue(createdSessionId), - getConfig: vi.fn().mockReturnValue(createdConfig), - sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), - replayHistory: vi.fn().mockResolvedValue(undefined), - installRewriter: vi.fn(), - installGoalTerminalObserver: vi.fn(), - startCronScheduler: vi.fn(), - beginClose: vi.fn().mockReturnValue(vi.fn()), - beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), - waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), - waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), - cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), - enqueueBackgroundNotification: vi - .fn() - .mockResolvedValue({ accepted: true }), - enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), - appendLiveConversationTranscript: vi.fn().mockResolvedValue(undefined), - assertCanStartTurn: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn(), - emitGoalStatus: vi.fn(), - captureHistorySnapshot: vi - .fn() - .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), - restoreHistory: vi.fn(), - rewindToTurn: vi - .fn() - .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), - getRewindableUserTurnCount: vi.fn().mockReturnValue(1), - clearActiveTodoPlanRevision: vi.fn(), - clearTodoStopGuardTrust: vi.fn(), - hardSuspendTodoStopGuard: vi.fn(), - releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), - prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), - }; - lastSessionMock = sessionMock; - return sessionMock as unknown as InstanceType; - }); + vi.mocked(Session).mockImplementation( + ( + createdSessionId, + createdConfig, + _client, + _settings, + _onActiveWorkChanged, + workflowHistory = [], + ) => { + const sessionMock = { + getId: vi.fn().mockReturnValue(createdSessionId), + getConfig: vi.fn().mockReturnValue(createdConfig), + getWorkflowHistory: vi.fn().mockReturnValue(workflowHistory), + refreshWorkflowHistory: vi.fn(() => + mockListWorkflowSnapshots(createdConfig), + ), + deleteWorkflowHistory: vi.fn().mockResolvedValue(false), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + replayHistory: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + beginClose: vi.fn().mockReturnValue(vi.fn()), + beginCloseIfAvailable: vi.fn().mockReturnValue(vi.fn()), + waitForCloseGateToRelease: vi.fn().mockResolvedValue(undefined), + waitForActiveTurnsToSettle: vi.fn().mockResolvedValue(undefined), + cancelPendingPrompt: vi.fn().mockResolvedValue(undefined), + enqueueBackgroundNotification: vi + .fn() + .mockResolvedValue({ accepted: true }), + enableLiveScreenContext: vi.fn().mockResolvedValue(undefined), + appendLiveConversationTranscript: vi + .fn() + .mockResolvedValue(undefined), + assertCanStartTurn: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + emitGoalStatus: vi.fn(), + captureHistorySnapshot: vi + .fn() + .mockReturnValue([{ role: 'user', parts: [{ text: 'before' }] }]), + restoreHistory: vi.fn(), + rewindToTurn: vi + .fn() + .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), + getRewindableUserTurnCount: vi.fn().mockReturnValue(1), + clearActiveTodoPlanRevision: vi.fn(), + clearTodoStopGuardTrust: vi.fn(), + hardSuspendTodoStopGuard: vi.fn(), + releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), + prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + }; + lastSessionMock = sessionMock; + return sessionMock as unknown as InstanceType; + }, + ); return innerConfig; } @@ -6737,6 +6767,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const innerConfig = await setupSessionMocks(sessionId); const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(5_000); Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), getBackgroundTaskRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([ { @@ -6837,6 +6870,19 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ], availableSkills: ['review'], }); + innerConfig.isWorkflowsEnabled.mockReturnValue(true); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + { + name: 'release-check', + source: 'user', + scriptPath: '/home/test/.qwen/workflows/release-check.js', + }, + ]); const agentPromise = runAcpAgent( mockConfig, @@ -6894,6 +6940,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }, ], availableSkills: ['review'], + workflowsEnabled: true, + savedWorkflows: [ + { name: 'deep-review', source: 'project' }, + { name: 'release-check', source: 'user' }, + ], }); expect(tasks).toEqual({ v: 1, @@ -6993,6 +7044,86 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('loads persisted workflow snapshots into the session task history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + list: vi.fn().mockReturnValue([]), + }), + }); + const persistedSnapshot = { + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [], + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + mockListWorkflowSnapshots + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([persistedSnapshot]); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const legacyTasks = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionTasks, + { sessionId }, + ); + const tasks = await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + includeWorkflows: true, + }); + + expect(legacyTasks).toMatchObject({ sessionId, tasks: [] }); + expect(tasks).toMatchObject({ + sessionId, + tasks: [ + { + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'completed', + isHistorical: true, + }, + ], + }); + expect(mockListWorkflowSnapshots).toHaveBeenCalledTimes(2); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('status ext method returns disabled LSP status', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); @@ -8788,7 +8919,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { taskId: 'task-1', taskKind: 'invalid', }), - ).rejects.toThrow('taskKind must be "agent", "shell", or "monitor"'); + ).rejects.toThrow( + 'taskKind must be "agent", "shell", "monitor", or "workflow"', + ); mockConnectionState.resolve(); await agentPromise; @@ -8878,6 +9011,397 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('cancels active workflow tasks', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'paused' as 'paused' | 'cancelled', + }; + let resolveCompletion!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const cancel = vi.fn(() => { + task.status = 'cancelled'; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(task), + getHandle: vi.fn().mockReturnValue({ completion }), + cancel, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + let settled = false; + const request = agent + .extMethod(SERVE_CONTROL_EXT_METHODS.sessionTaskCancel, { + sessionId, + taskId: 'wf-1', + taskKind: 'workflow', + }) + .then((result) => { + settled = true; + return result; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resolveCompletion(); + await expect(request).resolves.toEqual({ + cancelled: true, + status: 'cancelled', + }); + expect(cancel).toHaveBeenCalledWith('wf-1', expect.any(Number)); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('pauses and resumes a background workflow task', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const task = { + id: 'wf-1', + runId: 'wf-1', + kind: 'workflow' as const, + status: 'running' as 'running' | 'pausing' | 'paused', + isBackgrounded: true, + }; + const pause = vi.fn(() => { + task.status = 'pausing'; + return true; + }); + const resume = vi.fn(() => { + task.status = 'running'; + return true; + }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + get: vi.fn(() => task), + pause, + resume, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'pause', + }), + ).resolves.toEqual({ changed: true, status: 'pausing' }); + expect(pause).toHaveBeenCalledWith('wf-1'); + + task.status = 'paused'; + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf-1', + action: 'resume', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(resume).toHaveBeenCalledWith('wf-1'); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('uses the exact run id when a concurrent saved workflow uses the same script', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const tasks: Array<{ + runId: string; + status: 'running'; + scriptPath: string; + }> = []; + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push({ + runId: 'wf_concurrent', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + tasks.push({ + runId: 'wf_5678efab', + status: 'running', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }); + return { llmContent: 'started', workflowRunId: 'wf_5678efab' }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + isWorkflowsEnabled: vi.fn().mockReturnValue(true), + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + mockListSavedWorkflows.mockResolvedValueOnce([ + { + name: 'deep-review', + source: 'project', + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + }, + ]); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'deep-review', + action: 'run-saved', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(build).toHaveBeenCalledWith({ + scriptPath: '/tmp/.qwen/workflows/deep-review.js', + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('retries a failed workflow with its original script, args, and journal', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'retry this path' }, + }; + const registry = { + get: vi.fn(() => task), + getHandle: vi.fn(() => undefined), + }; + const execute = vi.fn().mockImplementation(async () => { + task.status = 'running'; + return { llmContent: 'started' }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_1234abcd', + action: 'retry', + }), + ).resolves.toEqual({ changed: true, status: 'running' }); + expect(build).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + resumeFromRunId: task.runId, + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('reruns a terminal workflow from scratch with a new run id', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const task = { + id: 'wf_1234abcd', + runId: 'wf_1234abcd', + kind: 'workflow' as const, + status: 'failed' as 'failed' | 'running', + script: 'return await agent(args.prompt)', + args: { prompt: 'rerun everything' }, + }; + const rerun = { + ...task, + id: 'wf_5678efab', + runId: 'wf_5678efab', + status: 'running' as const, + }; + const concurrentRerun = { + ...task, + id: 'wf_concurrent', + runId: 'wf_concurrent', + status: 'running' as const, + }; + const tasks = [task]; + const setLineage = vi.fn( + (runId: string, sourceRunId: string, startMode: 'rerun') => { + const entry = tasks.find((candidate) => candidate.runId === runId); + if (!entry) return false; + Object.assign(entry, { sourceRunId, startMode }); + return true; + }, + ); + const registry = { + get: vi.fn((runId: string) => + tasks.find((candidate) => candidate.runId === runId), + ), + getHandle: vi.fn(() => undefined), + setLineage, + }; + const execute = vi.fn().mockImplementation(async () => { + tasks.push(concurrentRerun, rerun); + return { llmContent: 'started', workflowRunId: rerun.runId }; + }); + const build = vi.fn().mockReturnValue({ execute }); + Object.assign(innerConfig, { + getWorkflowRunRegistry: vi.fn().mockReturnValue(registry), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn((name: string) => + name === 'workflow' ? { build } : undefined, + ), + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: task.runId, + action: 'rerun', + }), + ).resolves.toEqual({ + changed: true, + status: 'running', + taskId: rerun.runId, + }); + expect(build).toHaveBeenCalledWith({ + script: task.script, + args: task.args, + run_in_background: true, + }); + expect(execute).toHaveBeenCalledOnce(); + expect(rerun).toMatchObject({ + sourceRunId: task.runId, + startMode: 'rerun', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('deletes a restored workflow from persistent session history', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.deleteWorkflowHistory.mockResolvedValueOnce(true); + + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction, { + sessionId, + taskId: 'wf_abcd', + action: 'delete-history', + }), + ).resolves.toEqual({ changed: true }); + expect(lastSessionMock!.deleteWorkflowHistory).toHaveBeenCalledWith( + 'wf_abcd', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('returns not_running for stopped task cancellation', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index b56d9e9b883..455ec2149fd 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -120,6 +120,10 @@ import { type WorkspaceRememberContextMode, type ChatRecord, type ToolInvocationGuard, + type WorkflowParams, + type WorkflowToolResult, + listSavedWorkflows, + listWorkflowSnapshots, } from '@qwen-code/qwen-code-core'; import { randomUUID, timingSafeEqual } from 'node:crypto'; import { performance } from 'node:perf_hooks'; @@ -7172,19 +7176,41 @@ class QwenAgent implements Agent { sessionId: string, ): Promise { const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); const { availableCommands, availableSkills } = - await buildAvailableCommandsSnapshot(session.getConfig()); + await buildAvailableCommandsSnapshot(config); + const workflowsEnabled = config.isWorkflowsEnabled(); + const savedWorkflows = + workflowsEnabled && + !config.getBareMode() && + (!config.getFolderTrustFeature() || config.getFolderTrust()) + ? (await listSavedWorkflows(config)).map(({ name, source }) => ({ + name, + source, + })) + : []; return { v: STATUS_SCHEMA_VERSION, sessionId, availableCommands, availableSkills: availableSkills ?? [], + workflowsEnabled, + savedWorkflows, }; } - private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus { + private async buildSessionTasksStatus( + sessionId: string, + includeWorkflows = false, + ): Promise { const session = this.sessionOrThrow(sessionId); - return buildSessionTasksStatus(sessionId, session.getConfig()); + return buildSessionTasksStatus( + sessionId, + session.getConfig(), + Date.now(), + includeWorkflows ? await session.refreshWorkflowHistory() : [], + { includeWorkflows }, + ); } private buildSessionLspStatus(sessionId: string): ServeSessionLspStatus { @@ -8161,10 +8187,10 @@ class QwenAgent implements Agent { 'Invalid or missing sessionId', ); } - return this.buildSessionTasksStatus(sessionId) as unknown as Record< - string, - unknown - >; + return (await this.buildSessionTasksStatus( + sessionId, + params['includeWorkflows'] === true, + )) as unknown as Record; } case SERVE_STATUS_EXT_METHODS.sessionLspStatus: { const sessionId = params['sessionId']; @@ -10412,11 +10438,12 @@ class QwenAgent implements Agent { if ( taskKind !== 'agent' && taskKind !== 'shell' && - taskKind !== 'monitor' + taskKind !== 'monitor' && + taskKind !== 'workflow' ) { throw RequestError.invalidParams( undefined, - 'taskKind must be "agent", "shell", or "monitor"', + 'taskKind must be "agent", "shell", "monitor", or "workflow"', ); } debugLogger.info( @@ -10477,12 +10504,162 @@ class QwenAgent implements Agent { ); return { cancelled: true, status: task.status }; } + case 'workflow': { + const registry = config.getWorkflowRunRegistry(); + const task = registry.get(taskId); + if ( + !task || + (task.status !== 'running' && + task.status !== 'pausing' && + task.status !== 'paused') + ) { + const reason = task ? 'not_running' : 'not_found'; + debugLogger.info( + `sessionTaskCancel skipped sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} reason=${reason} status=${task?.status ?? 'missing'}`, + ); + return { cancelled: false, reason, status: task?.status }; + } + const handle = registry.getHandle(taskId); + registry.cancel(taskId, Date.now()); + if (handle) await handle.completion; + debugLogger.info( + `sessionTaskCancel completed sessionId=${sessionId} taskId=${taskId} taskKind=${taskKind} status=${task.status}`, + ); + return { cancelled: true, status: task.status }; + } default: { const exhaustive: never = taskKind; throw new Error(`Unhandled task kind: ${exhaustive}`); } } } + case SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const taskId = params['taskId']; + if (typeof taskId !== 'string' || taskId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing taskId', + ); + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + throw RequestError.invalidParams( + undefined, + 'action must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ); + } + const session = this.sessionOrThrow(sessionId); + if (action === 'delete-history') { + return { changed: await session.deleteWorkflowHistory(taskId) }; + } + const config = session.getConfig(); + const registry = config.getWorkflowRunRegistry(); + if (action === 'run-saved') { + if ( + !config.isWorkflowsEnabled() || + config.getBareMode() || + (config.getFolderTrustFeature() && !config.getFolderTrust()) + ) { + return { changed: false }; + } + const savedWorkflow = (await listSavedWorkflows(config)).find( + (entry) => entry.name === taskId, + ); + if (!savedWorkflow) return { changed: false }; + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!workflowTool) { + throw RequestError.invalidParams( + undefined, + 'The workflow tool is unavailable; cannot run this saved workflow.', + ); + } + const result = (await workflowTool + .build({ + scriptPath: savedWorkflow.scriptPath, + run_in_background: true, + } satisfies WorkflowParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + const startedTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + return startedTask + ? { + changed: true, + status: startedTask.status, + taskId: startedTask.runId, + } + : { changed: false }; + } + const task = registry.get(taskId); + if (!task) return { changed: false }; + if (action === 'retry' || action === 'rerun') { + const canStart = + action === 'retry' + ? task.status === 'failed' && !registry.getHandle(taskId) + : task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'; + if (!canStart || !task.script) { + return { changed: false, status: task.status }; + } + const workflowTool = config + .getToolRegistry() + .getTool(ToolNames.WORKFLOW); + if (!workflowTool) { + throw RequestError.invalidParams( + undefined, + `The workflow tool is unavailable; cannot ${action} this run.`, + ); + } + const startParams: WorkflowParams = { + script: task.script, + args: task.args, + ...(action === 'retry' ? { resumeFromRunId: task.runId } : {}), + run_in_background: true, + }; + const result = (await workflowTool + .build(startParams) + .execute(new AbortController().signal)) as WorkflowToolResult; + if (action === 'rerun') { + const rerunTask = result.workflowRunId + ? registry.get(result.workflowRunId) + : undefined; + if (rerunTask) { + registry.setLineage(rerunTask.runId, task.runId, 'rerun'); + } + return rerunTask + ? { + changed: true, + status: rerunTask.status, + taskId: rerunTask.runId, + } + : { changed: false, status: task.status }; + } + return { + changed: true, + status: registry.get(taskId)?.status, + }; + } + const changed = + action === 'pause' ? registry.pause(taskId) : registry.resume(taskId); + return { changed, status: task.status }; + } case SERVE_CONTROL_EXT_METHODS.sessionGoalClear: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -12139,12 +12316,14 @@ class QwenAgent implements Agent { ); } + const workflowHistory = await listWorkflowSnapshots(config); const session = new Session( sessionId, config, this.connection, settings, () => this.activeWorkReporter?.notifyChanged(), + workflowHistory, ); this.sessions.set(sessionId, session); // The Session set itself is part of the snapshot: publish so the daemon diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 27edbebfa6e..96061d34557 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -207,6 +207,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ snapshot: vi.fn(() => ({})), })), restoreWorktreeContext: mockRestoreWorktreeContext, + listWorkflowSnapshots: vi.fn().mockResolvedValue([]), HookEventName: { PreToolUse: 'PreToolUse', PostToolUse: 'PostToolUse', diff --git a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts index 8369b20e641..96e50659479 100644 --- a/packages/cli/src/acp-integration/session/Session.review-lease.test.ts +++ b/packages/cli/src/acp-integration/session/Session.review-lease.test.ts @@ -154,6 +154,12 @@ describe('Session review-worktree lease sweep', () => { getBackgroundShellRegistry: vi.fn().mockReturnValue({ setNotificationCallback: vi.fn(), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 45106c16344..c8bedacebd7 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -73,6 +73,8 @@ const addToolArgumentsAttributesSpy = vi.hoisted(() => vi.fn()); const addToolCallResultAttributesSpy = vi.hoisted(() => vi.fn()); const logLoopDetectedSpy = vi.hoisted(() => vi.fn()); const logRepeatedToolFailureGuardSpy = vi.hoisted(() => vi.fn()); +const deleteWorkflowSnapshotSpy = vi.hoisted(() => vi.fn()); +const listWorkflowSnapshotsSpy = vi.hoisted(() => vi.fn()); const TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD = 'craft/claimTodoStopGuardContinuation'; // Records every LoopTickResolver construction's deps so a test can assert what @@ -122,6 +124,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { logRepeatedToolFailureGuardSpy(...args); return actual.logRepeatedToolFailureGuard(...args); }, + deleteWorkflowSnapshot: deleteWorkflowSnapshotSpy, + listWorkflowSnapshots: listWorkflowSnapshotsSpy, // Transparent recording wrapper: records the constructor deps, then behaves // exactly like the real resolver (subclass → instanceof + methods preserved). LoopTickResolver: class extends actual.LoopTickResolver { @@ -446,8 +450,12 @@ describe('Session', () => { getFunctionDeclarationsFiltered: ReturnType; }; let mockWorkflowRunRegistry: { + setCompletionCallback: ReturnType; + setStatusChangeCallback: ReturnType; + clearStatusChangeCallback: ReturnType; setApprovalRequestCallback: ReturnType; resolvePendingApproval: ReturnType; + getHandle: ReturnType; }; let mockGoalRuntime: { getSnapshot: ReturnType; @@ -556,6 +564,10 @@ describe('Session', () => { addToolCallResultAttributesSpy.mockClear(); logLoopDetectedSpy.mockReset(); logRepeatedToolFailureGuardSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockReset(); + deleteWorkflowSnapshotSpy.mockResolvedValue(true); + listWorkflowSnapshotsSpy.mockReset(); + listWorkflowSnapshotsSpy.mockResolvedValue([]); runVisionBridgeSpy.mockReset(); bridgeToolResultImagesSpy.mockReset(); bridgeToolResultImagesSpy.mockImplementation( @@ -630,8 +642,12 @@ describe('Session', () => { getAll: vi.fn().mockReturnValue([]), }; mockWorkflowRunRegistry = { + setCompletionCallback: vi.fn(), + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), setApprovalRequestCallback: vi.fn(), resolvePendingApproval: vi.fn().mockResolvedValue(true), + getHandle: vi.fn().mockReturnValue(undefined), }; mockChatRecordingService = { @@ -1916,6 +1932,178 @@ describe('Session', () => { expect(textParts(notificationCall.message)).toContain(reminder); }); + it('delivers background workflow completions through the session queue', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockImplementation(async () => createEmptyStream()); + const callback = mockWorkflowRunRegistry.setCompletionCallback.mock + .calls[0][0] as ( + displayText: string, + modelText: string, + meta: { + runId: string; + status: 'completed' | 'failed'; + todoWorkChainId?: string; + }, + ) => void; + + callback('Workflow completed.', '', { + runId: 'wf_1234abcd', + status: 'completed', + }); + + await vi.waitFor(() => + expect(mockChatRecordingService.recordNotification).toHaveBeenCalledWith( + [{ text: '' }], + 'Workflow completed.', + expect.objectContaining({ + taskId: 'wf_1234abcd', + status: 'completed', + kind: 'workflow', + }), + ), + ); + }); + + it('adds terminal workflow status changes to the session history cache', () => { + const callback = mockWorkflowRunRegistry.setStatusChangeCallback.mock + .calls[0][0] as (entry: core.WorkflowTask) => void; + callback({ + id: 'wf_saved', + kind: 'workflow', + runId: 'wf_saved', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 1_000, + endTime: 2_000, + outputFile: '', + outputOffset: 0, + notified: true, + abortController: new AbortController(), + isBackgrounded: true, + currentPhase: null, + phases: ['Inspect'], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 1, + agentsCompleted: 1, + recentLogs: [], + events: [], + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: new Map(), + pendingApprovals: [], + script: 'return 1;', + }); + + expect(session.getWorkflowHistory()).toEqual([ + expect.objectContaining({ + runId: 'wf_saved', + description: 'Review and fix', + status: 'completed', + }), + ]); + }); + + it('keeps cached history on disk failure and removes it after deletion', async () => { + session.dispose(); + const onActiveWorkChanged = vi.fn(); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + onActiveWorkChanged, + [ + { + runId: 'wf_abcd', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 500, + tokenBudgetTotal: 2_000, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }, + ], + ); + listWorkflowSnapshotsSpy.mockResolvedValue(session.getWorkflowHistory()); + + deleteWorkflowSnapshotSpy.mockResolvedValueOnce(false); + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(false); + expect(session.getWorkflowHistory()).toHaveLength(1); + expect(onActiveWorkChanged).not.toHaveBeenCalled(); + + await expect(session.deleteWorkflowHistory('wf_abcd')).resolves.toBe(true); + + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + 'wf_abcd', + ); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledTimes(2); + expect(session.getWorkflowHistory()).toEqual([]); + expect(onActiveWorkChanged).toHaveBeenCalledOnce(); + }); + + it('keeps history unchanged when the requested saved run is unknown', async () => { + await expect(session.deleteWorkflowHistory('wf_missing')).resolves.toBe( + false, + ); + + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + }); + + it('waits for an active run owner to finish persistence before deletion', async () => { + session.dispose(); + const snapshot = { + runId: 'wf_pending', + meta: null, + status: 'completed' as const, + script: 'return 1;', + phases: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + perPhaseTokens: [], + recentLogs: [], + startTime: 1_000, + endTime: 2_000, + }; + let finishPersistence: (() => void) | undefined; + const completion = new Promise((resolve) => { + finishPersistence = resolve; + }); + mockWorkflowRunRegistry.getHandle.mockReturnValue({ completion }); + listWorkflowSnapshotsSpy.mockResolvedValue([snapshot]); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + [snapshot], + ); + + const deletion = session.deleteWorkflowHistory(snapshot.runId); + await Promise.resolve(); + expect(deleteWorkflowSnapshotSpy).not.toHaveBeenCalled(); + + finishPersistence?.(); + await expect(deletion).resolves.toBe(true); + expect(deleteWorkflowSnapshotSpy).toHaveBeenCalledWith( + mockConfig, + snapshot.runId, + ); + }); + it('does not infer Todo ownership from Todo Stop Guard lineage', async () => { mockChat.sendMessageStream = vi .fn() @@ -24872,6 +25060,12 @@ describe('Session', () => { expect( mockBackgroundShellRegistry.setNotificationCallback, ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.setCompletionCallback, + ).toHaveBeenLastCalledWith(undefined); + expect( + mockWorkflowRunRegistry.clearStatusChangeCallback, + ).toHaveBeenCalledWith(expect.any(Function)); }); it('aborts an active notificationAbortController and nulls the reference', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f068c355e6d..a6f8dc6ff00 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -50,6 +50,8 @@ import type { CronTaskDelivery, InvocationContextV1, WorkflowApproval, + WorkflowSnapshot, + WorkflowTask, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -180,6 +182,11 @@ import { runWithRuntimeContentGenerator, getInvocationContext, runWithInvocationContext, + isTerminalWorkflowStatus, + MAX_RETAINED_SNAPSHOTS, + toSnapshot, + deleteWorkflowSnapshot, + listWorkflowSnapshots, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; import { CHANNEL_PROMPT_META_KEY } from '@qwen-code/channel-base'; @@ -1120,7 +1127,7 @@ export interface BackgroundNotificationQueueItem { modelText: string; taskId: string; status: string; - kind: 'agent' | 'monitor' | 'shell'; + kind: 'agent' | 'monitor' | 'shell' | 'workflow'; toolUseId?: string; todoWorkChainId?: string; } @@ -1639,6 +1646,8 @@ export class Session implements SessionContext { /** The exact status-change callback this Session installed, so dispose can * retract its own and nobody else's. */ #statusChangeCallback: (() => void) | undefined; + #workflowStatusChangeCallback: ((entry?: WorkflowTask) => void) | undefined; + private workflowHistory: WorkflowSnapshot[]; private readonly workflowApprovalAbortController = new AbortController(); private activeTodoPlanRevision?: { planId: string; @@ -1691,8 +1700,10 @@ export class Session implements SessionContext { * a full snapshot; the Session itself keeps no reporting state. */ private readonly onActiveWorkChanged?: () => void, + workflowHistory: readonly WorkflowSnapshot[] = [], ) { this.sessionId = id; + this.workflowHistory = [...workflowHistory]; this.runtimeBaseDir = config.storage.getRuntimeBaseDir(); const todoStopGuardEnabled = this.settings.merged.experimental?.todoStopGuard === true && @@ -2884,6 +2895,41 @@ export class Session implements SessionContext { return this.config; } + getWorkflowHistory(): readonly WorkflowSnapshot[] { + return this.workflowHistory; + } + + async refreshWorkflowHistory(): Promise { + this.workflowHistory = await listWorkflowSnapshots(this.config); + return this.workflowHistory; + } + + async deleteWorkflowHistory(runId: string): Promise { + const handle = this.config.getWorkflowRunRegistry().getHandle(runId); + if (handle) await handle.completion; + await this.refreshWorkflowHistory(); + if (!this.workflowHistory.some((item) => item.runId === runId)) { + return false; + } + if (!(await deleteWorkflowSnapshot(this.config, runId))) return false; + this.workflowHistory = this.workflowHistory.filter( + (item) => item.runId !== runId, + ); + this.#activeWorkChanged(); + return true; + } + + #rememberWorkflowHistory(entry: WorkflowTask): void { + if (!isTerminalWorkflowStatus(entry.status)) return; + const snapshot = toSnapshot(entry); + this.workflowHistory = [ + snapshot, + ...this.workflowHistory.filter((item) => item.runId !== entry.runId), + ] + .sort((a, b) => b.startTime - a.startTime) + .slice(0, MAX_RETAINED_SNAPSHOTS); + } + async assertCanStartTurn(): Promise { if (this.closing) { throw RequestError.invalidParams(undefined, 'Session is closing'); @@ -3108,6 +3154,13 @@ export class Session implements SessionContext { } this.config.getMonitorRegistry().setNotificationCallback(undefined); this.config.getBackgroundShellRegistry().setNotificationCallback(undefined); + this.config.getWorkflowRunRegistry().setCompletionCallback(undefined); + if (this.#workflowStatusChangeCallback) { + this.config + .getWorkflowRunRegistry() + .clearStatusChangeCallback(this.#workflowStatusChangeCallback); + this.#workflowStatusChangeCallback = undefined; + } this.config.getChatRecordingService()?.setTitleRecordedCallback(undefined); this.unsubscribeChatRecordingFailure?.(); this.unsubscribeChatRecordingFailure = undefined; @@ -7176,6 +7229,26 @@ export class Session implements SessionContext { }); }); + const workflowRegistry = this.config.getWorkflowRunRegistry(); + this.#workflowStatusChangeCallback = (entry) => { + this.#activeWorkChanged(); + if (entry) this.#rememberWorkflowHistory(entry); + }; + workflowRegistry.setStatusChangeCallback( + this.#workflowStatusChangeCallback, + ); + workflowRegistry.setCompletionCallback((displayText, modelText, meta) => { + this.#enqueueBackgroundNotification({ + displayText, + modelText, + taskId: meta.runId, + status: meta.status, + kind: 'workflow', + continuesTodoStopGuardWorkChain: meta.todoWorkChainId !== undefined, + todoWorkChainId: meta.todoWorkChainId, + }); + }); + // Session title recorded (auto-generated after a turn, or an in-process // /rename) → notify attached clients. A title update is NOT an ACP // `SessionUpdate` variant (the external @agentclientprotocol/sdk union diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index dfb74ac0897..89c10bf687e 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -174,6 +174,12 @@ describe('Session.pendingWorktreeNotice', () => { getBackgroundShellRegistry: vi.fn().mockReturnValue({ setNotificationCallback: vi.fn(), }), + getWorkflowRunRegistry: vi.fn().mockReturnValue({ + setStatusChangeCallback: vi.fn(), + clearStatusChangeCallback: vi.fn(), + setCompletionCallback: vi.fn(), + setApprovalRequestCallback: vi.fn(), + }), setSubSessionSpawner: vi.fn(), getSubSessionSpawner: vi.fn(), // The Session constructor and Session.prompt both reach for the diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts index 3d34261e020..afff78b1cf6 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.test.ts @@ -5,7 +5,13 @@ */ import { describe, expect, it } from 'vitest'; -import type { AgentTask, Config, MonitorTask } from '@qwen-code/qwen-code-core'; +import type { + AgentTask, + Config, + MonitorTask, + WorkflowSnapshot, + WorkflowTask, +} from '@qwen-code/qwen-code-core'; import { buildSessionTasksStatus } from './tasksSnapshot.js'; import type { ServeSessionAgentTaskStatus } from '@qwen-code/acp-bridge/status'; @@ -25,14 +31,57 @@ function agentTask(overrides: Partial = {}): AgentTask { } as AgentTask; } -function configWith(agents: AgentTask[]): Config { +function configWith( + agents: AgentTask[], + workflows: WorkflowTask[] = [], +): Config { return { getBackgroundTaskRegistry: () => ({ getAll: () => agents }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [] }), + getWorkflowRunRegistry: () => ({ list: () => workflows }), } as unknown as Config; } +function workflowSnapshot( + overrides: Partial = {}, +): WorkflowSnapshot { + return { + runId: 'wf_saved', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'failed', + script: 'return 1;', + phases: ['Inspect'], + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 500, + endedAt: 900, + }, + ], + dispatches: [], + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + tokenBudgetTotal: 4_000, + perPhaseTokens: [], + recentLogs: [], + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + startTime: 500, + endTime: 1_000, + ...overrides, + }; +} + function serializedMonitor( monitor: MonitorTask, ): Extract< @@ -43,6 +92,7 @@ function serializedMonitor( getBackgroundTaskRegistry: () => ({ getAll: () => [] }), getBackgroundShellRegistry: () => ({ getAll: () => [] }), getMonitorRegistry: () => ({ getAll: () => [monitor] }), + getWorkflowRunRegistry: () => ({ list: () => [] }), } as unknown as Config; return buildSessionTasksStatus('session-1', config, 2_000).tasks.find( (task) => task.kind === 'monitor', @@ -125,3 +175,251 @@ describe('buildSessionTasksStatus monitor correlation', () => { expect(task.toolUseId).toBe('monitor-call-1'); }); }); + +describe('buildSessionTasksStatus workflow graph', () => { + it('omits workflow tasks unless the caller opts in', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + ); + + expect(snapshot.tasks).toEqual([]); + }); + + it('exposes phase visits and dispatch dependencies from the workflow registry', () => { + const workflow = { + kind: 'workflow', + id: 'wf_graph', + runId: 'wf_graph', + toolUseId: 'workflow-call-1', + description: 'Review and fix', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'running', + startTime: 1_000, + isBackgrounded: true, + currentPhase: 'Review', + phases: ['Inspect', 'Review'], + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 1_000, + endedAt: 1_200, + }, + { id: 'phase-2', index: 1, title: 'Review', startedAt: 1_200 }, + ], + currentPhaseVisitId: 'phase-2', + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Scope mapper', + prompt: 'Inspect the repository', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }, + { + id: 'dispatch-2', + phaseVisitId: 'phase-2', + label: 'Correctness', + prompt: 'Review correctness', + subagentId: 'correctness-agent-1', + status: 'running', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + startedAt: 1_220, + }, + ], + agentsDispatched: 2, + agentsCompleted: 1, + recentLogs: ['Review started'], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + perPhaseTokens: new Map(), + script: '', + sourceRunId: 'wf_source', + startMode: 'rerun', + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + callId: 'call-1', + name: 'write_file', + description: 'Update the implementation', + confirmationDetails: {} as never, + at: 1_300, + }, + ], + outputOffset: 0, + notified: false, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [], + { includeWorkflows: true }, + ); + const task = snapshot.tasks.find( + (candidate) => candidate.kind === 'workflow', + ); + + expect(task).toMatchObject({ + kind: 'workflow', + id: 'wf_graph', + toolUseId: 'workflow-call-1', + label: 'review-and-fix', + currentPhase: 'Review', + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + sourceRunId: 'wf_source', + startMode: 'rerun', + phaseVisits: [ + { id: 'phase-1', title: 'Inspect' }, + { id: 'phase-2', title: 'Review' }, + ], + dispatches: [ + { id: 'dispatch-1', status: 'completed', dependsOn: [] }, + { + id: 'dispatch-2', + status: 'running', + subagentId: 'correctness-agent-1', + dependsOn: ['dispatch-1'], + }, + ], + pendingApprovalCount: 1, + pendingApprovals: [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'Update the implementation', + }, + ], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_250, + message: 'Review started', + }, + { + id: 'event-2', + type: 'approval-requested', + at: 1_300, + name: 'write_file', + dispatchId: 'dispatch-2', + }, + ], + }); + }); + + it('restores persisted workflow runs as read-only task history', () => { + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + + expect(snapshot.tasks).toEqual([ + expect.objectContaining({ + kind: 'workflow', + id: 'wf_saved', + label: 'review-and-fix', + status: 'failed', + runtimeMs: 500, + isHistorical: true, + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 900, + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_000, + error: 'Review failed', + }, + ], + }), + ]); + }); + + it('prefers the in-memory workflow task over a persisted duplicate', () => { + const workflow = { + kind: 'workflow', + id: 'wf_saved', + runId: 'wf_saved', + description: 'Live entry', + meta: { name: 'review-and-fix', description: 'Review and fix' }, + status: 'completed', + startTime: 500, + endTime: 1_100, + isBackgrounded: true, + currentPhase: null, + phases: [], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], + agentsDispatched: 3, + agentsCompleted: 3, + recentLogs: [], + events: [], + tokensSpent: 1_200, + tokenBudgetTotal: 4_000, + perPhaseTokens: new Map(), + script: '', + pendingApprovals: [], + outputOffset: 0, + notified: true, + outputFile: '', + abortController: new AbortController(), + } as WorkflowTask; + + const snapshot = buildSessionTasksStatus( + 'session-1', + configWith([], [workflow]), + 2_000, + [workflowSnapshot()], + { includeWorkflows: true }, + ); + const workflows = snapshot.tasks.filter( + (candidate) => candidate.kind === 'workflow', + ); + + expect(workflows).toHaveLength(1); + expect(workflows[0]).toMatchObject({ + id: 'wf_saved', + agentsCompleted: 3, + tokensSpent: 1_200, + }); + expect(workflows[0]).not.toHaveProperty('isHistorical'); + }); +}); diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts index 5b41a532d95..bd5c43a132a 100644 --- a/packages/cli/src/acp-integration/session/tasksSnapshot.ts +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -10,6 +10,8 @@ import { type Config, type MonitorTask, type ShellTask, + type WorkflowSnapshot, + type WorkflowTask, } from '@qwen-code/qwen-code-core'; import { STATUS_SCHEMA_VERSION, @@ -18,6 +20,7 @@ import { type ServeSessionShellTaskStatus, type ServeSessionTaskStatus, type ServeSessionTasksStatus, + type ServeSessionWorkflowTaskStatus, } from '@qwen-code/acp-bridge/status'; function runtimeMs( @@ -122,11 +125,99 @@ function serializeMonitorTask( }; } +function serializeWorkflowTask( + entry: WorkflowTask, + now: number, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: entry.runId, + ...optionalField('toolUseId', entry.toolUseId), + ...optionalField('sourceRunId', entry.sourceRunId), + ...optionalField('startMode', entry.startMode), + label: entry.meta?.name ?? entry.description ?? entry.runId, + description: entry.meta?.description ?? entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + ...optionalField('endTime', entry.endTime), + isBackgrounded: entry.isBackgrounded === true, + currentPhase: entry.currentPhase, + phaseVisits: entry.phaseVisits.map((visit) => ({ ...visit })), + dispatches: entry.dispatches.map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: entry.agentsDispatched, + agentsCompleted: entry.agentsCompleted, + tokensSpent: entry.tokensSpent, + tokenBudgetTotal: entry.tokenBudgetTotal, + recentLogs: [...entry.recentLogs], + events: entry.events.map((event) => ({ ...event })), + pendingApprovalCount: entry.pendingApprovals.length, + pendingApprovals: entry.pendingApprovals.map((approval) => ({ + approvalId: approval.approvalId, + subagentId: approval.subagentId, + name: approval.name, + description: approval.description, + at: approval.at, + })), + ...optionalField('error', entry.error), + }; +} + +function serializeWorkflowSnapshot( + snapshot: WorkflowSnapshot, +): ServeSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: snapshot.runId, + isHistorical: true, + ...optionalField('sourceRunId', snapshot.sourceRunId), + ...optionalField('startMode', snapshot.startMode), + label: snapshot.meta?.name ?? snapshot.description ?? snapshot.runId, + description: + snapshot.meta?.description ?? snapshot.description ?? snapshot.runId, + status: snapshot.status, + startTime: snapshot.startTime, + ...optionalField('endTime', snapshot.endTime), + runtimeMs: runtimeMs(snapshot, snapshot.endTime ?? snapshot.startTime), + isBackgrounded: false, + currentPhase: snapshot.phases.at(-1) ?? null, + phaseVisits: (snapshot.phaseVisits ?? []).map((visit) => ({ ...visit })), + dispatches: (snapshot.dispatches ?? []).map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), + agentsDispatched: snapshot.agentsDispatched, + agentsCompleted: snapshot.agentsCompleted, + tokensSpent: snapshot.tokensSpent, + tokenBudgetTotal: snapshot.tokenBudgetTotal, + recentLogs: [...snapshot.recentLogs], + ...optionalField( + 'events', + snapshot.events?.map((event) => ({ ...event })), + ), + pendingApprovalCount: 0, + ...optionalField('error', snapshot.error), + }; +} + export function buildSessionTasksStatus( sessionId: string, config: Config, now = Date.now(), + workflowHistory: readonly WorkflowSnapshot[] = [], + options: { includeWorkflows?: boolean } = {}, ): ServeSessionTasksStatus { + const includeWorkflows = options.includeWorkflows === true; + const workflowTasks = includeWorkflows + ? config.getWorkflowRunRegistry().list() + : []; + const inMemoryWorkflowIds = new Set( + workflowTasks.map((entry) => entry.runId), + ); const tasks: ServeSessionTaskStatus[] = [ ...config .getBackgroundTaskRegistry() @@ -140,6 +231,14 @@ export function buildSessionTasksStatus( .getMonitorRegistry() .getAll() .map((entry) => serializeMonitorTask(entry, now)), + ...(includeWorkflows + ? workflowTasks.map((entry) => serializeWorkflowTask(entry, now)) + : []), + ...(includeWorkflows + ? workflowHistory + .filter((snapshot) => !inMemoryWorkflowIds.has(snapshot.runId)) + .map(serializeWorkflowSnapshot) + : []), ].sort((a, b) => a.startTime - b.startTime); return { diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 5a82e000ba6..b5e7f4df5da 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -262,6 +262,8 @@ const ALL_QWEN_VENDOR_METHODS: readonly string[] = [ `${QWEN_METHOD_NS}session/detach`, `${QWEN_METHOD_NS}session/context_usage`, `${QWEN_METHOD_NS}session/tasks`, + `${QWEN_METHOD_NS}session/tasks/cancel`, + `${QWEN_METHOD_NS}session/tasks/workflow_action`, `${QWEN_METHOD_NS}session/lsp`, `${QWEN_METHOD_NS}session/artifacts`, `${QWEN_METHOD_NS}session/artifacts/add`, @@ -3340,11 +3342,97 @@ export class AcpDispatcher { case `${QWEN_METHOD_NS}session/tasks`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; - const result = await this.bridge.getSessionTasksStatus(sessionId); + const result = await this.bridge.getSessionTasksStatus(sessionId, { + includeWorkflows: params['includeWorkflows'] === true, + }); this.replyConn(conn, id, result as unknown); return; } + case `${QWEN_METHOD_NS}session/tasks/cancel`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const kind = params['kind']; + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`kind` must be "agent", "shell", "monitor", or "workflow"', + ), + ); + } + return; + } + const result = await this.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + + case `${QWEN_METHOD_NS}session/tasks/workflow_action`: { + const sessionId = String(params['sessionId'] ?? ''); + await this.withMutableOwned(conn, sessionId, id, async () => { + const taskId = String(params['taskId'] ?? ''); + if (!taskId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`taskId` is required'), + ); + } + return; + } + const action = params['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + ), + ); + } + return; + } + const result = await this.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + }); + return; + } + case `${QWEN_METHOD_NS}session/lsp`: { const sessionId = String(params['sessionId'] ?? ''); if (!this.requireOwned(conn, sessionId, id)) return; diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index f866b0f5942..9ed1a099a1a 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -439,6 +439,40 @@ class FakeBridge { async getSessionTasksStatus(sessionId: string) { return { sessionId, tasks: [] }; } + lastCancelledTask: + | { + sessionId: string; + taskId: string; + kind: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async cancelSessionTask( + sessionId: string, + taskId: string, + kind: Parameters[2], + context: Parameters[3], + ) { + this.lastCancelledTask = { sessionId, taskId, kind, context }; + return { cancelled: true }; + } + lastWorkflowAction: + | { + sessionId: string; + taskId: string; + action: Parameters[2]; + context: Parameters[3]; + } + | undefined; + async controlSessionWorkflowTask( + sessionId: string, + taskId: string, + action: Parameters[2], + context: Parameters[3], + ) { + this.lastWorkflowAction = { sessionId, taskId, action, context }; + return { changed: true, status: 'running' as const }; + } async getSessionLspStatus(sessionId: string) { return { v: 1, @@ -6838,6 +6872,72 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { }); }); + it('_qwen/session/tasks/cancel forwards the task and trusted client context', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 58, + method: '_qwen/session/tasks/cancel', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ result: { cancelled: true } }); + expect(bridge.lastCancelledTask).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + kind: 'workflow', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + + it('_qwen/session/tasks/workflow_action forwards the action result and trusted client context', async () => { + const connId = await initialize(); + const streamRes = openStream(connId); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 59, + method: '_qwen/session/tasks/workflow_action', + params: { + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + clientId: 'forged-client', + }, + }); + const frames = await takeFrames(await streamRes, 2); + expect(frames[1]).toMatchObject({ + result: { changed: true, status: 'running' }, + }); + expect(bridge.lastWorkflowAction).toEqual({ + sessionId: 'sess-1', + taskId: 'workflow-1', + action: 'retry', + context: { clientId: 'client-1', fromLoopback: true }, + }); + }); + it('_qwen/session/lsp returns status', async () => { const connId = await initialize(); const streamRes = openStream(connId); diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index c0a00a8d9ec..3cdbc417442 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -130,7 +130,7 @@ interface FakeBridge extends AcpSessionBridge { readonly taskCancelCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; }>; readonly goalClearCalls: string[]; readonly continueCalls: Array<{ @@ -671,11 +671,27 @@ function makeBridge( async cancelSessionTask( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', ) { taskCancelCalls.push({ sessionId, taskId, taskKind }); return { cancelled: workspaceCwd === SECONDARY_CWD }; }, + async controlSessionWorkflowTask( + _sessionId: string, + _taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + ) { + return { + changed: workspaceCwd === SECONDARY_CWD, + status: action === 'pause' ? 'pausing' : 'running', + }; + }, async clearSessionGoal(sessionId: string) { goalClearCalls.push(sessionId); return { @@ -1054,13 +1070,20 @@ describe('multi-workspace session dispatch', () => { expect(res.body.features).toContain('workspace_archived_session_export'); expect(res.body.features).toContain('workspace_display_name'); expect(res.body.workspaces).toEqual([ - { id: 'primary-id', cwd: PRIMARY_CWD, primary: true, trusted: true }, + { + id: 'primary-id', + cwd: PRIMARY_CWD, + primary: true, + trusted: true, + workflowsEnabled: false, + }, { id: 'secondary-id', cwd: SECONDARY_CWD, displayName: 'Secondary workspace', primary: false, trusted: true, + workflowsEnabled: false, }, ]); expect(res.body.limits.maxSessionsPerWorkspace).toBe(32); diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index a50fa1fd798..f5fcd2dd79f 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -17,7 +17,10 @@ import { type CapabilitiesEnvelope, type ServeOptions, } from '../types.js'; -import type { WorkspaceRegistry } from '../workspace-registry.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; interface RegisterCapabilitiesRoutesDeps { qwenCodeVersion?: string; @@ -31,6 +34,20 @@ interface RegisterCapabilitiesRoutesDeps { maxPendingPromptsPerSession: ServeOptions['maxPendingPromptsPerSession']; sessionRestoreTimeoutMs: number; languageCodes: string[]; + daemonEnv: Readonly; +} + +function workflowsEnabledForRuntime( + runtime: WorkspaceRuntime | undefined, + daemonEnv: Readonly, +): boolean { + if (!runtime) return false; + const env = + runtime.env.mode === 'runtime-overlay' + ? (runtime.env.effectiveEnv ?? {}) + : (runtime.env.effectiveEnv ?? daemonEnv); + if (env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false; + return env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1'; } export function registerCapabilitiesRoutes( @@ -93,6 +110,10 @@ export function registerCapabilitiesRoutes( primary: entry.primary, trusted: entry.state === 'active' && entry.current?.runtime.trusted === true, + workflowsEnabled: workflowsEnabledForRuntime( + entry.state === 'active' ? entry.current?.runtime : undefined, + deps.daemonEnv, + ), ...(runtimeRemoval ? { removable: entry.removable } : {}), ...(entry.current?.runtime.provenance === 'live-conversation' ? { kind: 'live' as const } diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 7eaaba8f25b..fbd9b02acf9 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -3076,10 +3076,12 @@ export function registerSessionRoutes( '/session/:id/tasks', withOwnerReadSession( 'GET /session/:id/tasks', - async (_req, res, sessionId, runtime) => { - res - .status(200) - .json(await runtime.bridge.getSessionTasksStatus(sessionId)); + async (req, res, sessionId, runtime) => { + res.status(200).json( + await runtime.bridge.getSessionTasksStatus(sessionId, { + includeWorkflows: req.query['includeWorkflows'] === 'true', + }), + ); }, ), ); @@ -3230,16 +3232,72 @@ export function registerSessionRoutes( } const body = safeBody(req); const kind = body['kind']; - if (kind !== 'agent' && kind !== 'shell' && kind !== 'monitor') { - res - .status(400) - .json({ error: '`kind` must be "agent", "shell", or "monitor"' }); + if ( + kind !== 'agent' && + kind !== 'shell' && + kind !== 'monitor' && + kind !== 'workflow' + ) { + res.status(400).json({ + error: '`kind` must be "agent", "shell", "monitor", or "workflow"', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.cancelSessionTask( + sessionId, + taskId, + kind, + clientId !== undefined ? { clientId } : undefined, + ), + ); + }, + ), + ); + + app.post( + '/session/:id/tasks/:taskId/workflow-action', + mutate({ strict: true }), + withOwnerMutableSession( + 'POST /session/:id/tasks/:taskId/workflow-action', + async (req, res, sessionId, runtime) => { + const taskId = req.params['taskId']; + if (!taskId) { + res.status(400).json({ + error: '`taskId` route parameter is required', + }); return; } + const action = safeBody(req)['action']; + if ( + action !== 'pause' && + action !== 'resume' && + action !== 'retry' && + action !== 'rerun' && + action !== 'delete-history' && + action !== 'run-saved' + ) { + res.status(400).json({ + error: + '`action` must be "pause", "resume", "retry", "rerun", "delete-history", or "run-saved"', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; res .status(200) .json( - await runtime.bridge.cancelSessionTask(sessionId, taskId, kind), + await runtime.bridge.controlSessionWorkflowTask( + sessionId, + taskId, + action, + clientId !== undefined ? { clientId } : undefined, + ), ); }, ), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index e845e773045..7feaab93c7c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -753,14 +753,30 @@ interface FakeBridgeOpts { sessionId: string, ) => Promise; sessionStatsImpl?: (sessionId: string) => Promise; - sessionTasksImpl?: (sessionId: string) => Promise; + sessionTasksImpl?: ( + sessionId: string, + opts?: { includeWorkflows?: boolean }, + ) => Promise; sessionLspImpl?: (sessionId: string) => Promise; sessionTranscriptImpl?: AcpSessionBridge['getSessionTranscriptPage']; cancelSessionTaskImpl?: ( sessionId: string, taskId: string, - taskKind: 'agent' | 'shell' | 'monitor', + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow', + context?: BridgeClientRequestContext, ) => Promise<{ cancelled: boolean }>; + controlSessionWorkflowTaskImpl?: ( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + context?: BridgeClientRequestContext, + ) => Promise<{ changed: boolean; status?: string; taskId?: string }>; clearSessionGoalImpl?: ( sessionId: string, ) => Promise<{ cleared: boolean; condition?: string }>; @@ -1039,6 +1055,7 @@ interface FakeBridge extends AcpSessionBridge { sessionSupportedCommandsCalls: string[]; sessionStatsCalls: string[]; sessionTasksCalls: string[]; + sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined>; sessionLspCalls: string[]; sessionTranscriptCalls: Array< Parameters[0] @@ -1046,7 +1063,20 @@ interface FakeBridge extends AcpSessionBridge { cancelSessionTaskCalls: Array<{ sessionId: string; taskId: string; - taskKind: 'agent' | 'shell' | 'monitor'; + taskKind: 'agent' | 'shell' | 'monitor' | 'workflow'; + context?: BridgeClientRequestContext; + }>; + controlSessionWorkflowTaskCalls: Array<{ + sessionId: string; + taskId: string; + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved'; + context?: BridgeClientRequestContext; }>; clearSessionGoalCalls: string[]; continueSessionCalls: string[]; @@ -1206,9 +1236,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionSupportedCommandsCalls: string[] = []; const sessionStatsCalls: string[] = []; const sessionTasksCalls: string[] = []; + const sessionTasksOptions: Array<{ includeWorkflows?: boolean } | undefined> = + []; const sessionLspCalls: string[] = []; const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = []; const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = []; + const controlSessionWorkflowTaskCalls: FakeBridge['controlSessionWorkflowTaskCalls'] = + []; const clearSessionGoalCalls: string[] = []; const continueSessionCalls: string[] = []; const continueSessionContexts: Array = @@ -1548,6 +1582,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { })); const cancelSessionTaskImpl = opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true })); + const controlSessionWorkflowTaskImpl = + opts.controlSessionWorkflowTaskImpl ?? + (async () => ({ changed: true, status: 'pausing' })); const clearSessionGoalImpl = opts.clearSessionGoalImpl ?? (async () => ({ cleared: true })); const continueSessionImpl = @@ -1783,9 +1820,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, sessionStatsCalls, sessionTasksCalls, + sessionTasksOptions, sessionLspCalls, sessionTranscriptCalls, cancelSessionTaskCalls, + controlSessionWorkflowTaskCalls, clearSessionGoalCalls, continueSessionCalls, continueSessionContexts, @@ -2052,9 +2091,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionStatsCalls.push(sessionId); return sessionStatsImpl(sessionId); }, - async getSessionTasksStatus(sessionId) { + async getSessionTasksStatus(sessionId, opts) { sessionTasksCalls.push(sessionId); - return sessionTasksImpl(sessionId); + sessionTasksOptions.push(opts); + return sessionTasksImpl(sessionId, opts); }, async getSessionLspStatus(sessionId) { sessionLspCalls.push(sessionId); @@ -2064,9 +2104,23 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionTranscriptCalls.push(req); return sessionTranscriptImpl(req); }, - async cancelSessionTask(sessionId, taskId, taskKind) { - cancelSessionTaskCalls.push({ sessionId, taskId, taskKind }); - return cancelSessionTaskImpl(sessionId, taskId, taskKind); + async cancelSessionTask(sessionId, taskId, taskKind, context) { + cancelSessionTaskCalls.push({ + sessionId, + taskId, + taskKind, + ...(context ? { context } : {}), + }); + return cancelSessionTaskImpl(sessionId, taskId, taskKind, context); + }, + async controlSessionWorkflowTask(sessionId, taskId, action, context) { + controlSessionWorkflowTaskCalls.push({ + sessionId, + taskId, + action, + ...(context ? { context } : {}), + }); + return controlSessionWorkflowTaskImpl(sessionId, taskId, action, context); }, async clearSessionGoal(sessionId) { clearSessionGoalCalls.push(sessionId); @@ -3607,6 +3661,56 @@ describe('createServeApp', () => { }); describe('GET /capabilities', () => { + it('advertises workflow availability per workspace before a session exists', async () => { + const primaryBridge = fakeBridge(); + const primary = makeWorkspaceRuntimeForTest({ + workspaceId: 'primary-id', + workspaceCwd: WS_BOUND, + primary: true, + bridge: primaryBridge, + }); + const secondary: WorkspaceRuntime = { + ...makeWorkspaceRuntimeForTest({ + workspaceId: 'secondary-id', + workspaceCwd: '/workspace/secondary', + primary: false, + bridge: fakeBridge(), + }), + env: { + mode: 'runtime-overlay', + overlayKeys: [ + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', + ], + effectiveEnv: { + QWEN_CODE_ENABLE_WORKFLOWS: '1', + QWEN_CODE_DISABLE_WORKFLOWS: '1', + }, + }, + }; + const app = createServeApp(baseOpts, undefined, { + bridge: primaryBridge, + workspaceRegistry: createWorkspaceRegistry([primary, secondary]), + daemonEnv: { QWEN_CODE_ENABLE_WORKFLOWS: '1' }, + }); + + const response = await request(app) + .get('/capabilities') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(response.status).toBe(200); + expect(response.body.workspaces).toEqual([ + expect.objectContaining({ + id: 'primary-id', + workflowsEnabled: true, + }), + expect.objectContaining({ + id: 'secondary-id', + workflowsEnabled: false, + }), + ]); + }); + it('advertises the effective session restore timeout', async () => { const defaultResponse = await request( createServeApp(baseOpts, undefined, { bridge: fakeBridge() }), @@ -3759,6 +3863,7 @@ describe('createServeApp', () => { workspaceRegistry: registry, createWorkspaceRuntime: vi.fn(), workspaceRegistrationStore: {} as unknown as WorkspaceRegistrationStore, + daemonEnv: {}, }); const before = await request(app) @@ -3779,6 +3884,7 @@ describe('createServeApp', () => { cwd: WS_BOUND, primary: true, trusted: true, + workflowsEnabled: false, }, ]); @@ -3838,6 +3944,7 @@ describe('createServeApp', () => { displayName: 'Conversations', primary: false, trusted: true, + workflowsEnabled: false, kind: 'live', }); }); @@ -8497,6 +8604,9 @@ describe('createServeApp', () => { const tasksRes = await request(app) .get('/session/s-1/tasks') .set('Host', `127.0.0.1:${baseOpts.port}`); + const workflowTasksRes = await request(app) + .get('/session/s-1/tasks?includeWorkflows=true') + .set('Host', `127.0.0.1:${baseOpts.port}`); const lspRes = await request(app) .get('/session/s-1/lsp') .set('Host', `127.0.0.1:${baseOpts.port}`); @@ -8509,12 +8619,18 @@ describe('createServeApp', () => { expect(statsRes.body).toEqual(stats); expect(tasksRes.status).toBe(200); expect(tasksRes.body).toEqual(tasks); + expect(workflowTasksRes.status).toBe(200); + expect(workflowTasksRes.body).toEqual(tasks); expect(lspRes.status).toBe(200); expect(lspRes.body).toEqual(lsp); expect(bridge.sessionContextCalls).toEqual(['s-1']); expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); expect(bridge.sessionStatsCalls).toEqual(['s-1']); - expect(bridge.sessionTasksCalls).toEqual(['s-1']); + expect(bridge.sessionTasksCalls).toEqual(['s-1', 's-1']); + expect(bridge.sessionTasksOptions).toEqual([ + { includeWorkflows: false }, + { includeWorkflows: true }, + ]); expect(bridge.sessionLspCalls).toEqual(['s-1']); }); @@ -8764,12 +8880,12 @@ describe('createServeApp', () => { expect(res.status).toBe(400); expect(res.body.error).toBe( - '`kind` must be "agent", "shell", or "monitor"', + '`kind` must be "agent", "shell", "monitor", or "workflow"', ); expect(bridge.cancelSessionTaskCalls).toEqual([]); }); - it('cancels a session task through the bridge', async () => { + it('cancels a workflow task through the bridge', async () => { const bridge = fakeBridge({ cancelSessionTaskImpl: async () => ({ cancelled: true }), }); @@ -8784,12 +8900,91 @@ describe('createServeApp', () => { .post('/session/s-1/tasks/task-1/cancel') .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret') - .send({ kind: 'agent' }); + .set('X-Qwen-Client-Id', 'client-1') + .send({ kind: 'workflow' }); expect(res.status).toBe(200); expect(res.body).toEqual({ cancelled: true }); expect(bridge.cancelSessionTaskCalls).toEqual([ - { sessionId: 's-1', taskId: 'task-1', taskKind: 'agent' }, + { + sessionId: 's-1', + taskId: 'task-1', + taskKind: 'workflow', + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('controls live runs, saved definitions, and history through one route', async () => { + const bridge = fakeBridge({ + controlSessionWorkflowTaskImpl: async (_sessionId, _taskId, action) => + action === 'pause' + ? { changed: true, status: 'pausing' } + : { changed: true, status: 'running' }, + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const pauseRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ action: 'pause' }); + const resumeRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'resume' }); + const retryRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'retry' }); + const rerunRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'rerun' }); + const deleteRes = await request(app) + .post('/session/s-1/tasks/task-1/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'delete-history' }); + const runSavedRes = await request(app) + .post('/session/s-1/tasks/deep-review/workflow-action') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'run-saved' }); + + expect(pauseRes.status).toBe(200); + expect(pauseRes.body).toEqual({ changed: true, status: 'pausing' }); + expect(resumeRes.status).toBe(200); + expect(resumeRes.body).toEqual({ changed: true, status: 'running' }); + expect(retryRes.status).toBe(200); + expect(retryRes.body).toEqual({ changed: true, status: 'running' }); + expect(rerunRes.status).toBe(200); + expect(rerunRes.body).toEqual({ changed: true, status: 'running' }); + expect(deleteRes.status).toBe(200); + expect(deleteRes.body).toEqual({ changed: true, status: 'running' }); + expect(runSavedRes.status).toBe(200); + expect(runSavedRes.body).toEqual({ changed: true, status: 'running' }); + expect(bridge.controlSessionWorkflowTaskCalls).toEqual([ + { + sessionId: 's-1', + taskId: 'task-1', + action: 'pause', + context: { clientId: 'client-1' }, + }, + { sessionId: 's-1', taskId: 'task-1', action: 'resume' }, + { sessionId: 's-1', taskId: 'task-1', action: 'retry' }, + { sessionId: 's-1', taskId: 'task-1', action: 'rerun' }, + { sessionId: 's-1', taskId: 'task-1', action: 'delete-history' }, + { sessionId: 's-1', taskId: 'deep-review', action: 'run-saved' }, ]); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f98f6d955d5..d8643ac1310 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1798,6 +1798,7 @@ export function createServeApp( maxPendingPromptsPerSession: opts.maxPendingPromptsPerSession, sessionRestoreTimeoutMs, languageCodes, + daemonEnv: daemonEnvAtBoot, }); if (liveVoiceSurfaceAvailable) { diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index 9262d01748a..3c0a192f488 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(53); + expect(registered).toHaveLength(54); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 3c1ac8536ca..654d7538850 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -794,17 +794,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 53 unique routes with the audited 46/7 attribution split', () => { + it('contains 54 unique routes with the audited 47/7 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(53); - expect(new Set(keys).size).toBe(53); + expect(keys).toHaveLength(54); + expect(new Set(keys).size).toBe(54); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(46); + ).toHaveLength(47); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index f146cd2bf39..bf38bc7604a 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -167,6 +167,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/tasks/:taskId/cancel', }, + { + method: 'POST', + path: '/session/:id/tasks/:taskId/workflow-action', + attribution: 'handler_resolved', + route: 'POST /session/:id/tasks/:taskId/workflow-action', + }, { method: 'POST', path: '/session/:id/goal/clear', diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index e655a6b902d..82709695de5 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -427,6 +427,7 @@ export interface CapabilitiesEnvelope { displayName?: string; primary: boolean; trusted: boolean; + workflowsEnabled?: boolean; removable?: boolean; kind?: 'live'; }>; diff --git a/packages/cli/src/ui/commands/workflowsCommand.test.ts b/packages/cli/src/ui/commands/workflowsCommand.test.ts index bb22bfeeab6..34565eb8e35 100644 --- a/packages/cli/src/ui/commands/workflowsCommand.test.ts +++ b/packages/cli/src/ui/commands/workflowsCommand.test.ts @@ -28,10 +28,14 @@ function entry(overrides: Partial = {}): WorkflowTask { isBackgrounded: true, abortController: new AbortController(), currentPhase: null, + currentPhaseVisitId: null, phases: [], + phaseVisits: [], + dispatches: [], agentsDispatched: 0, agentsCompleted: 0, recentLogs: [], + events: [], tokensSpent: 0, tokenBudgetTotal: null, perPhaseTokens: new Map(), diff --git a/packages/cli/src/ui/commands/workflowsCommand.ts b/packages/cli/src/ui/commands/workflowsCommand.ts index 335dbf08d4c..233ddc7e141 100644 --- a/packages/cli/src/ui/commands/workflowsCommand.ts +++ b/packages/cli/src/ui/commands/workflowsCommand.ts @@ -30,10 +30,14 @@ function snapshotToTask(s: WorkflowSnapshot): WorkflowTask { meta: s.meta, status: s.status, currentPhase: null, + currentPhaseVisitId: null, phases: s.phases ?? [], + phaseVisits: s.phaseVisits ?? [], + dispatches: s.dispatches ?? [], agentsDispatched: s.agentsDispatched ?? 0, agentsCompleted: s.agentsCompleted ?? 0, recentLogs: s.recentLogs ?? [], + events: s.events ?? [], tokensSpent: s.tokensSpent ?? 0, tokenBudgetTotal: s.tokenBudgetTotal ?? null, perPhaseTokens: new Map(s.perPhaseTokens ?? []), @@ -48,7 +52,7 @@ function snapshotToTask(s: WorkflowSnapshot): WorkflowTask { outputOffset: 0, notified: true, abortController: new AbortController(), - } as WorkflowTask; + }; } /** diff --git a/packages/core/src/agents/runtime/workflow-journal.test.ts b/packages/core/src/agents/runtime/workflow-journal.test.ts index e38d1d1c3ed..e9aaadb9c61 100644 --- a/packages/core/src/agents/runtime/workflow-journal.test.ts +++ b/packages/core/src/agents/runtime/workflow-journal.test.ts @@ -126,6 +126,23 @@ describe('WorkflowJournal', () => { expect(replay.started.get('k1')).toHaveLength(1); }); + it('drain waits for fire-and-forget appends', async () => { + const j = new WorkflowJournal(path.join(dir, 'sub', 'journal.jsonl')); + void j.append({ type: 'started', key: 'k1', agentId: '1' }); + void j.append({ + type: 'result', + key: 'k1', + agentId: '1', + result: 'done', + }); + + await j.drain(); + + const replay = await j.load(); + expect(replay.started.get('k1')).toHaveLength(1); + expect(replay.results.get('k1')?.result).toBe('done'); + }); + it('load on a missing file returns empty maps', async () => { const j = new WorkflowJournal(path.join(dir, 'nope.jsonl')); const replay = await j.load(); diff --git a/packages/core/src/agents/runtime/workflow-journal.ts b/packages/core/src/agents/runtime/workflow-journal.ts index 15a250e4c2c..e9fafe1148f 100644 --- a/packages/core/src/agents/runtime/workflow-journal.ts +++ b/packages/core/src/agents/runtime/workflow-journal.ts @@ -168,6 +168,8 @@ export function buildReplay(entries: JournalEntry[]): JournalReplay { * failure must not fail the dispatch). */ export class WorkflowJournal { + private pending = Promise.resolve(); + constructor(readonly path: string) {} /** Load + parse all entries into replay maps. Empty maps if no file. */ @@ -183,6 +185,13 @@ export class WorkflowJournal { /** Append one entry. Rejects only on I/O error (callers `.catch`). */ append(entry: JournalEntry): Promise { - return writeLine(this.path, entry); + const operation = this.pending.then(() => writeLine(this.path, entry)); + this.pending = operation.catch(() => undefined); + return operation; + } + + /** Wait until every append issued so far has settled. */ + drain(): Promise { + return this.pending; } } diff --git a/packages/core/src/agents/runtime/workflow-orchestrator.test.ts b/packages/core/src/agents/runtime/workflow-orchestrator.test.ts index 8af40bbd9b1..507ee5e3539 100644 --- a/packages/core/src/agents/runtime/workflow-orchestrator.test.ts +++ b/packages/core/src/agents/runtime/workflow-orchestrator.test.ts @@ -415,6 +415,170 @@ describe('WorkflowOrchestrator', () => { }); }); + it('records dependency tails across sequential, parallel, and pipeline dispatches', async () => { + const orchestrator = new WorkflowOrchestrator( + async (prompt) => `mock:${prompt}`, + ); + const queued: Array<{ + id: string; + label?: string; + dependsOn: string[]; + }> = []; + + await orchestrator.run({ + script: ` + phase('Inspect'); + await agent('inspect', { label: 'inspect' }); + phase('Review'); + await parallel([ + () => agent('correctness', { label: 'correctness' }), + () => agent('architecture', { label: 'architecture' }), + ]); + phase('Fix'); + await pipeline( + ['a', 'b'], + (_prev, item) => agent('verify ' + item, { label: 'verify-' + item }), + (_prev, item) => agent('fix ' + item, { label: 'fix-' + item }), + ); + `, + args: undefined, + emitter: { + dispatchQueued: (event) => queued.push(event), + }, + }); + + const ids = new Map(queued.map((event) => [event.label, event.id])); + const dependencies = (label: string) => + queued + .find((event) => event.label === label)! + .dependsOn.map((id) => queued.find((event) => event.id === id)!.label); + + expect(dependencies('inspect')).toEqual([]); + expect(dependencies('correctness')).toEqual(['inspect']); + expect(dependencies('architecture')).toEqual(['inspect']); + expect(dependencies('verify-a')).toEqual(['correctness', 'architecture']); + expect(dependencies('verify-b')).toEqual(['correctness', 'architecture']); + expect(dependencies('fix-a')).toEqual(['verify-a']); + expect(dependencies('fix-b')).toEqual(['verify-b']); + expect(new Set(ids.values()).size).toBe(7); + }); + + it.each(['parallel', 'pipeline'] as const)( + 'preserves newer parent dependencies when an un-awaited %s settles', + async (kind) => { + const queued: Array<{ + id: string; + label?: string; + dependsOn: string[]; + }> = []; + const orchestrator = new WorkflowOrchestrator( + async (prompt) => `${prompt}-done`, + ); + const fanout = + kind === 'parallel' + ? `parallel([() => agent('fanout', { label: 'fanout' })])` + : `pipeline([0], () => agent('fanout', { label: 'fanout' }))`; + + await orchestrator.run({ + script: ` + const pending = ${fanout}; + await agent('parent', { label: 'parent' }); + await pending; + await agent('joined', { label: 'joined' }); + `, + args: undefined, + scheduler: new WorkflowDispatchScheduler(2), + emitter: { + dispatchQueued: (event) => queued.push(event), + }, + }); + + const labelsById = new Map( + queued.map((event) => [event.id, event.label]), + ); + const joined = queued.find((event) => event.label === 'joined'); + expect(joined?.dependsOn.map((id) => labelsById.get(id)).sort()).toEqual([ + 'fanout', + 'parent', + ]); + }, + ); + + it('emits queued, started, and settled lifecycle events for one dispatch', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'done'); + const events: string[] = []; + + await orchestrator.run({ + script: `await agent('inspect', { label: 'scope' });`, + args: undefined, + emitter: { + dispatchQueued: ({ id, label }) => events.push(`queued:${id}:${label}`), + dispatchStarted: (id) => events.push(`started:${id}`), + dispatchSettled: (id, error) => + events.push(`settled:${id}:${error ?? 'ok'}`), + }, + }); + + expect(events).toHaveLength(3); + const dispatchId = events[0]!.split(':')[1]; + expect(events).toEqual([ + `queued:${dispatchId}:scope`, + `started:${dispatchId}`, + `settled:${dispatchId}:ok`, + ]); + }); + + it('passes the recorded dispatch id into the production dispatch boundary', async () => { + const receivedIds: Array = []; + const orchestrator = new WorkflowOrchestrator( + async (_prompt, _opts, dispatchId) => { + receivedIds.push(dispatchId); + return 'done'; + }, + ); + + await orchestrator.run({ + script: `await agent('inspect', { label: 'scope' });`, + args: undefined, + }); + + expect(receivedIds).toEqual(['dispatch-1']); + }); + + it('preserves the dependency tail across empty parallel helpers', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'done'); + const queued: Array<{ + id: string; + label?: string; + dependsOn: string[]; + }> = []; + + await orchestrator.run({ + script: ` + await agent('before', { label: 'before' }); + await parallel([]); + await agent('after parallel', { label: 'after-parallel' }); + await pipeline([], () => agent('unused')); + await agent('after pipeline', { label: 'after-pipeline' }); + `, + args: undefined, + emitter: { + dispatchQueued: (event) => queued.push(event), + }, + }); + + const labelById = new Map( + queued.map((event) => [event.id, event.label ?? event.id]), + ); + const dependsOn = (label: string) => + queued + .find((event) => event.label === label)! + .dependsOn.map((id) => labelById.get(id)); + + expect(dependsOn('after-parallel')).toEqual(['before']); + expect(dependsOn('after-pipeline')).toEqual(['after-parallel']); + }); + it('emitter subscriber errors do not break the run (defensive try/catch)', async () => { const orchestrator = new WorkflowOrchestrator( async (prompt) => `mock:${prompt}`, @@ -811,9 +975,13 @@ describe('WorkflowOrchestrator', () => { const orchestrator = new WorkflowOrchestrator(() => Promise.reject(new Error('nested-boom')), ); + const appendedLogs: string[] = []; const outcome = await orchestrator.run({ script: `return 'parent:' + (await workflow('child'));`, args: undefined, + emitter: { + logAppended: (line) => appendedLogs.push(line), + }, resolveSavedWorkflow: async () => ({ // The fire-and-forget dispatch fails but the nested script // still completes — the only trace of the failure is the @@ -825,6 +993,9 @@ describe('WorkflowOrchestrator', () => { expect(outcome.logs).toContain( 'dispatch failed (result not consumed): nested-boom', ); + expect(appendedLogs).toEqual([ + 'dispatch failed (result not consumed): nested-boom', + ]); }); it('keeps a nested agent result behind the shared pause gate', async () => { diff --git a/packages/core/src/agents/runtime/workflow-orchestrator.ts b/packages/core/src/agents/runtime/workflow-orchestrator.ts index 66b425f1941..18b29a26fff 100644 --- a/packages/core/src/agents/runtime/workflow-orchestrator.ts +++ b/packages/core/src/agents/runtime/workflow-orchestrator.ts @@ -5,6 +5,7 @@ */ import { randomBytes } from 'node:crypto'; +import { AsyncLocalStorage } from 'node:async_hooks'; import * as os from 'node:os'; import type { Config } from '../../config/config.js'; import { @@ -317,6 +318,7 @@ export interface WorkflowRunOutcome { export type WorkflowAgentDispatch = ( prompt: string, opts: WorkflowAgentOpts, + dispatchId?: string, ) => Promise; function generateRunId(): string { @@ -378,9 +380,12 @@ export function createProductionDispatch( * just without budget recording. */ onTokens?: (outputTokens: number, opts: WorkflowAgentOpts) => void, - bridgeApprovalEvents?: (emitter: AgentEventEmitter) => () => void, + bridgeApprovalEvents?: ( + emitter: AgentEventEmitter, + dispatchId?: string, + ) => () => void, ): WorkflowAgentDispatch { - return async (prompt, opts) => { + return async (prompt, opts, dispatchId) => { // P-stall: wrap the single-attempt dispatch in the stall watchdog + // retry loop. The wrapper owns the per-attempt AbortController + // AgentEventEmitter; it chains the caller's `signal` into the @@ -394,7 +399,10 @@ export function createProductionDispatch( ); return runStallResilient( async (attemptSignal, emitter) => { - const cleanupApprovalBridge = bridgeApprovalEvents?.(emitter); + const cleanupApprovalBridge = bridgeApprovalEvents?.( + emitter, + dispatchId, + ); try { return await runSingleDispatch( config, @@ -1371,8 +1379,33 @@ export class WorkflowOrchestrator { // cap regardless of launch path (increment-then-check: calls 1..max pass, // the (max+1)th throws), and scheduler.run enforces the dispatch window. let agentCount = 0; + let dispatchTraceCount = 0; const emitter = req.emitter; const budget = req.budget; + const dependencyContext = new AsyncLocalStorage<{ tails: string[] }>(); + const issueDispatchTrace = ( + prompt: string, + opts: WorkflowAgentOpts, + cached = false, + ): string => { + const id = `dispatch-${(dispatchTraceCount += 1)}`; + const store = dependencyContext.getStore(); + const dependsOn = Array.from(new Set(store?.tails ?? [])); + if (store) store.tails = [id]; + try { + emitter?.dispatchQueued?.({ + id, + ...(typeof opts.label === 'string' ? { label: opts.label } : {}), + prompt, + dependsOn, + queuedAt: Date.now(), + ...(cached ? { cached: true } : {}), + }); + } catch (e) { + debugLogger.warn('emitter.dispatchQueued threw:', e); + } + return id; + }; // P6: resume journal state. `prefixHash` chains across sequential // agent() calls; `hadMiss` enforces the "first miss invalidates the @@ -1417,6 +1450,7 @@ export class WorkflowOrchestrator { } const label = typeof opts.label === 'string' ? opts.label : undefined; + const dispatchId = issueDispatchTrace(prompt, opts, true); try { emitter?.agentDispatched?.(label); } catch (e) { @@ -1427,6 +1461,11 @@ export class WorkflowOrchestrator { } catch (e) { debugLogger.warn('emitter.agentCompleted threw:', e); } + try { + emitter?.dispatchSettled?.(dispatchId, undefined, Date.now()); + } catch (e) { + debugLogger.warn('emitter.dispatchSettled threw:', e); + } // Resolve even if the gate aborts: rejecting an already-cached // result at teardown would surface an unobserved rejection for // fire-and-forget calls on a correctly-cancelled run. @@ -1498,6 +1537,7 @@ export class WorkflowOrchestrator { // settles (success or thrown) — defensive try/catch on both so a // subscriber error never propagates into the script. const label = typeof opts.label === 'string' ? opts.label : undefined; + const dispatchId = issueDispatchTrace(prompt, opts); try { emitter?.agentDispatched?.(label); } catch (e) { @@ -1518,10 +1558,20 @@ export class WorkflowOrchestrator { } catch (e) { debugLogger.warn('emitter.agentCompleted threw:', e); } + try { + emitter?.dispatchSettled?.(dispatchId, message, Date.now()); + } catch (e) { + debugLogger.warn('emitter.dispatchSettled threw:', e); + } }; return scheduler .run(async () => { try { + try { + emitter?.dispatchStarted?.(dispatchId, Date.now()); + } catch (e) { + debugLogger.warn('emitter.dispatchStarted threw:', e); + } // P5 R1 (Critical #2): re-check the gate at slot-acquire time so // queued thunks see budget updates from already-completed in- // flight dispatches. Without this, the entry gate above is @@ -1538,7 +1588,7 @@ export class WorkflowOrchestrator { budget.spent(), ); } - const result = await this.dispatch(prompt, opts); + const result = await this.dispatch(prompt, opts, dispatchId); emitCompletion(); // P6: append the live result to the journal so a later resume // serves it from cache. Only JSON-serializable results are @@ -1621,8 +1671,8 @@ export class WorkflowOrchestrator { ); }; - const parallelImpl = makeParallelImpl(signal); - const pipelineImpl = makePipelineImpl(signal); + const parallelImpl = makeParallelImpl(signal, dependencyContext); + const pipelineImpl = makePipelineImpl(signal, dependencyContext); // P-nested: build the host-side `workflow(nameOrRef, args)` impl. Only // wired at the top level (when a resolver is provided). The nested @@ -1664,13 +1714,9 @@ export class WorkflowOrchestrator { // so the parent can try/catch it like any other async failure. return await nestedSandbox.run(resolved.script); } finally { - // Nested logs (script log() lines AND the unconsumed- - // rejection mirror) reach no production surface on their - // own — getLogs() is only ever read on the top-level - // sandbox and the production emitter's logAppended is a - // deliberate no-op. Merge them into the parent run's logs - // at nested settlement (after the nested flush ran) so a - // failed nested dispatch leaves a visible trace. + // The shared emitter already publishes nested logs live. Merge + // them into the parent buffer without re-emitting so the final + // outcome retains the same lines exactly once. for (const line of nestedSandbox.getLogs()) { parentSandboxRef.current?.appendLog(line); } @@ -1692,7 +1738,9 @@ export class WorkflowOrchestrator { }); parentSandboxRef.current = sandbox; try { - const result = await sandbox.run(req.script); + const result = await dependencyContext.run({ tails: [] }, () => + sandbox.run(req.script), + ); return { runId, result, @@ -1808,7 +1856,8 @@ async function settleToNullArray( * array never reaches the script directly. */ function makeParallelImpl( - signal?: AbortSignal, + signal: AbortSignal | undefined, + dependencyContext: AsyncLocalStorage<{ tails: string[] }>, ): (thunks: Array<() => Promise>) => Promise { return (thunks) => { if (!Array.isArray(thunks)) { @@ -1828,7 +1877,28 @@ function makeParallelImpl( ); } } - return settleToNullArray(thunks, signal); + const parent = dependencyContext.getStore(); + const inheritedTails = parent?.tails ?? []; + const branches = thunks.map((thunk) => { + const store = { tails: [...inheritedTails] }; + return { + store, + thunk: () => dependencyContext.run(store, thunk), + }; + }); + return settleToNullArray( + branches.map(({ thunk }) => thunk), + signal, + ).then((result) => { + if (parent && branches.length > 0) { + parent.tails = mergeFanoutTails( + parent.tails, + inheritedTails, + branches.flatMap(({ store }) => store.tails), + ); + } + return result; + }); }; } @@ -1845,7 +1915,8 @@ function makeParallelImpl( * per-element vm-realm revival. */ function makePipelineImpl( - signal?: AbortSignal, + signal: AbortSignal | undefined, + dependencyContext: AsyncLocalStorage<{ tails: string[] }>, ): ( items: unknown[], ...stages: Array< @@ -1870,13 +1941,49 @@ function makePipelineImpl( ); } } - const chains = items.map( - (item, idx) => () => runPipelineChain(item, idx, stages), - ); - return settleToNullArray(chains, signal, 'pipeline'); + const parent = dependencyContext.getStore(); + const inheritedTails = parent?.tails ?? []; + const branches = items.map((item, idx) => { + const store = { tails: [...inheritedTails] }; + return { + store, + thunk: () => + dependencyContext.run(store, () => + runPipelineChain(item, idx, stages), + ), + }; + }); + return settleToNullArray( + branches.map(({ thunk }) => thunk), + signal, + 'pipeline', + ).then((result) => { + if (parent && branches.length > 0) { + parent.tails = mergeFanoutTails( + parent.tails, + inheritedTails, + branches.flatMap(({ store }) => store.tails), + ); + } + return result; + }); }; } +function mergeFanoutTails( + currentParentTails: readonly string[], + inheritedTails: readonly string[], + branchTails: readonly string[], +): string[] { + const inherited = new Set(inheritedTails); + return Array.from( + new Set([ + ...branchTails, + ...currentParentTails.filter((tail) => !inherited.has(tail)), + ]), + ); +} + /** * Run one item through every stage in order. `null` is the universal drop * sentinel: a stage that returns `null` (or throws — surfaced as a rejection diff --git a/packages/core/src/agents/runtime/workflow-runner.test.ts b/packages/core/src/agents/runtime/workflow-runner.test.ts index a306f8ca33b..da5be0117fb 100644 --- a/packages/core/src/agents/runtime/workflow-runner.test.ts +++ b/packages/core/src/agents/runtime/workflow-runner.test.ts @@ -113,6 +113,7 @@ describe('WorkflowRunner', () => { expect(productionBridge).toHaveBeenCalledWith( productionHandle.runId, emitter, + undefined, ); const injected = configWithRegistry(); @@ -131,6 +132,67 @@ describe('WorkflowRunner', () => { expect(injectedBridge).not.toHaveBeenCalled(); }); + it('retains the original args needed to retry a failed run from its journal', async () => { + const { config, registry } = configWithRegistry(); + const args = { target: 'web-shell', checks: ['correctness'] }; + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return args.target', + args, + runInBackground: true, + dispatch: async () => 'unused', + }); + + await handle.completion; + + expect(registry.get(handle.runId)?.args).toEqual(args); + }); + + it('records sandbox logs in the replay event ledger', async () => { + const { config, registry } = configWithRegistry(); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'log("repository loaded"); return "done";', + args: undefined, + runInBackground: true, + dispatch: async () => 'unused', + }); + + await handle.completion; + + expect(registry.get(handle.runId)?.events).toEqual([ + expect.objectContaining({ + type: 'log', + message: 'repository loaded', + }), + expect.objectContaining({ type: 'workflow-completed' }), + ]); + }); + + it('records a journal retry as sourced from the same run', async () => { + const { config, registry } = configWithRegistry(); + const runId = 'wf_1234abcd'; + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'return "retried"', + args: undefined, + resumeFromRunId: runId, + runInBackground: true, + dispatch: async () => 'unused', + }); + + await handle.completion; + + expect(registry.get(runId)).toMatchObject({ + runId, + sourceRunId: runId, + startMode: 'retry', + }); + }); + it('keeps one registry-owned handle through exactly-once completion', async () => { const { config, registry } = configWithRegistry(); const observed = observeSettlement(registry); @@ -247,6 +309,27 @@ describe('WorkflowRunner', () => { expect(observed.abortCount()).toBe(1); }); + it('persists terminal runs without live fire-and-forget dispatches', async () => { + const { config, registry } = configWithRegistry(); + const handle = await WorkflowRunner.start({ + config, + signal: new AbortController().signal, + script: 'agent("fire and forget"); return "done"', + args: undefined, + runInBackground: true, + dispatch: () => new Promise(() => undefined), + }); + + await expect(handle.completion).resolves.toMatchObject({ ok: true }); + + const entry = registry.get(handle.runId); + expect(entry).toMatchObject({ status: 'completed' }); + expect(entry?.dispatches).toEqual([ + expect.objectContaining({ status: 'cancelled' }), + ]); + expect(writeWorkflowSnapshotMock).toHaveBeenCalledWith(config, entry); + }); + it('holds an in-flight agent result until a paused run resumes', async () => { const { config, registry } = configWithRegistry(); let resolveDispatch: ((value: string) => void) | undefined; diff --git a/packages/core/src/agents/runtime/workflow-runner.ts b/packages/core/src/agents/runtime/workflow-runner.ts index cc51e80fb4c..09b6f02966d 100644 --- a/packages/core/src/agents/runtime/workflow-runner.ts +++ b/packages/core/src/agents/runtime/workflow-runner.ts @@ -35,6 +35,7 @@ import { resolveSavedWorkflowScript } from './workflow-saved.js'; export interface WorkflowRunnerOptions { config: Config; signal: AbortSignal; + toolUseId?: string; script?: string; scriptPath?: string; args: unknown; @@ -115,7 +116,8 @@ export class WorkflowRunner { controller.signal, (outputTokens) => budget.recordSpent(outputTokens), registry - ? (emitter) => registry.bridgeApprovalEvents(runId, emitter) + ? (emitter, dispatchId) => + registry.bridgeApprovalEvents(runId, emitter, dispatchId) : undefined, ); const orchestrator = new WorkflowOrchestrator(dispatch); @@ -123,6 +125,7 @@ export class WorkflowRunner { try { entry = registry?.register({ runId, + toolUseId: options.toolUseId, meta: null, status: 'running', startTime: Date.now(), @@ -131,6 +134,13 @@ export class WorkflowRunner { tokenBudgetTotal: budget.total, script, scriptPath, + args: options.args, + ...(options.resumeFromRunId + ? { + sourceRunId: options.resumeFromRunId, + startMode: 'retry' as const, + } + : {}), isBackgrounded: runInBackground, }); } catch (error) { @@ -159,9 +169,21 @@ export class WorkflowRunner { // updates together (avoids 2x TUI redraws per agent). registry?.onAgentCompleted(runId); }, - // Deliberate no-op: logs are snapshotted at terminal via - // setRecentLogs; per-line emit would cause up to 10k TUI redraws. - logAppended: () => {}, + dispatchQueued: (event) => { + registry?.onDispatchQueued(runId, event); + emitUpdate(); + }, + dispatchStarted: (dispatchId, startedAt) => { + registry?.onDispatchStarted(runId, dispatchId, startedAt); + emitUpdate(); + }, + dispatchSettled: (dispatchId, error, endedAt) => { + registry?.onDispatchSettled(runId, dispatchId, error, endedAt); + emitUpdate(); + }, + // The registry records this without firing a status update, avoiding a + // TUI redraw per line while retaining the real replay timestamp. + logAppended: (line) => registry?.onLogAppended(runId, line), budgetUpdated: (spent, total) => { registry?.onBudgetUpdated(runId, spent, total); emitUpdate(); @@ -252,6 +274,7 @@ export class WorkflowRunner { tokens_spent: entry.tokensSpent, duration_ms: (entry.endTime ?? entry.startTime) - entry.startTime, }); + await journal?.drain(); await writeWorkflowSnapshot(config, entry); try { logWorkflowRun(config, telemetryEvent); diff --git a/packages/core/src/agents/runtime/workflow-sandbox.test.ts b/packages/core/src/agents/runtime/workflow-sandbox.test.ts index d4a7762b15f..dd26adb0e0b 100644 --- a/packages/core/src/agents/runtime/workflow-sandbox.test.ts +++ b/packages/core/src/agents/runtime/workflow-sandbox.test.ts @@ -695,14 +695,17 @@ describe('createWorkflowSandbox security', () => { // SEC-I2: log() must cap at MAX_LOG_LINES and add a truncation marker. it('log() caps at MAX_LOG_LINES with a truncation marker', async () => { + const emitted: string[] = []; const sandbox = createWorkflowSandbox({ args: undefined, dispatch: async () => 'ignored', + emitter: { logAppended: (line) => emitted.push(line) }, }); await sandbox.run(`for (let i = 0; i < 10100; i++) log(i); return 0;`); const logs = sandbox.getLogs(); expect(logs.length).toBe(10_001); // 10_000 entries + 1 truncation marker expect(logs[10_000]).toMatch(/truncated/); + expect(emitted.at(-1)).toBe(logs[10_000]); }); // FIX-C5 (SEC-2-I1): same cap pattern for phases array — protects host diff --git a/packages/core/src/agents/runtime/workflow-sandbox.ts b/packages/core/src/agents/runtime/workflow-sandbox.ts index 2eba8def988..4d369d6574e 100644 --- a/packages/core/src/agents/runtime/workflow-sandbox.ts +++ b/packages/core/src/agents/runtime/workflow-sandbox.ts @@ -453,6 +453,19 @@ export interface WorkflowOrchestratorEmitter { agentDispatched?(label?: string): void; /** `dispatch(...)` settled (success or thrown). `error` set on rejection. */ agentCompleted?(label?: string, error?: string): void; + /** A dispatch was issued and joined to the runtime dependency graph. */ + dispatchQueued?(event: { + id: string; + label?: string; + prompt: string; + dependsOn: string[]; + queuedAt: number; + cached?: boolean; + }): void; + /** A queued dispatch acquired a scheduler slot. */ + dispatchStarted?(id: string, startedAt: number): void; + /** A dispatch reached a terminal state. */ + dispatchSettled?(id: string, error?: string, endedAt?: number): void; /** * P5: cumulative `spent` re-snapshot after each successful agent * completion. `total` is `null` when no per-run cap is set @@ -667,13 +680,7 @@ export interface WorkflowSandbox { getPhases(): string[]; /** Log lines emitted by the script in order. */ getLogs(): string[]; - /** - * Append a log line produced by a nested workflow run. Nested logs - * reach no production surface on their own (the nested sandbox's - * buffer is never read by the orchestrator), so the orchestrator - * merges them into the parent run's logs at nested settlement — - * including the nested unconsumed-rejection mirror lines. - */ + /** Merge a nested workflow log into the parent buffer without re-emitting. */ appendLog(line: string): void; /** * The script's `export const meta = {...}` declaration, validated and @@ -734,20 +741,26 @@ export function createWorkflowSandbox(opts: SandboxOptions): WorkflowSandbox { const phases: string[] = []; const logs: string[] = []; - const safeLog = (msg: unknown): void => { + const emitLog = (line: string): void => { + try { + opts.emitter?.logAppended?.(line); + } catch (e) { + debugLogger.warn('emitter.logAppended threw:', e); + } + }; + + const safeLog = (msg: unknown, notify = true): void => { if (logs.length < MAX_LOG_LINES) { const line = String(msg); logs.push(line); // P4b: emit to host-side subscriber (registry). Defensive try/catch // because a subscriber error must not interrupt script execution // — the script body has no business knowing about UI plumbing. - try { - opts.emitter?.logAppended?.(line); - } catch (e) { - debugLogger.warn('emitter.logAppended threw:', e); - } + if (notify) emitLog(line); } else if (logs.length === MAX_LOG_LINES) { - logs.push(`[workflow log truncated at ${MAX_LOG_LINES} lines]`); + const line = `[workflow log truncated at ${MAX_LOG_LINES} lines]`; + logs.push(line); + if (notify) emitLog(line); } }; @@ -1831,7 +1844,7 @@ export function createWorkflowSandbox(opts: SandboxOptions): WorkflowSandbox { }, getPhases: () => [...phases], getLogs: () => [...logs], - appendLog: (line: string) => safeLog(line), + appendLog: (line: string) => safeLog(line, false), getMeta: () => extractedMeta, }; } diff --git a/packages/core/src/agents/workflow-run-registry.test.ts b/packages/core/src/agents/workflow-run-registry.test.ts index cdddaf139a8..0cb95705f6c 100644 --- a/packages/core/src/agents/workflow-run-registry.test.ts +++ b/packages/core/src/agents/workflow-run-registry.test.ts @@ -64,6 +64,49 @@ function approvalEvent( } describe('WorkflowRunRegistry', () => { + it('records rerun lineage and notifies status observers', () => { + const r = new WorkflowRunRegistry(); + const onStatusChange = vi.fn(); + r.setStatusChangeCallback(onStatusChange); + r.register(reg('wf_rerun')); + onStatusChange.mockClear(); + + expect(r.setLineage('wf_rerun', 'wf_source', 'rerun')).toBe(true); + expect(r.get('wf_rerun')).toMatchObject({ + sourceRunId: 'wf_source', + startMode: 'rerun', + }); + expect(onStatusChange).toHaveBeenCalledWith( + expect.objectContaining({ runId: 'wf_rerun' }), + ); + expect(r.setLineage('wf_missing', 'wf_source', 'rerun')).toBe(false); + }); + + it('binds a pending approval to the dispatch that owns its event channel', () => { + const r = new WorkflowRunRegistry(); + r.register(reg('wf_dispatch_approval')); + r.setApprovalChangeCallback(() => {}); + r.onDispatchQueued('wf_dispatch_approval', { + id: 'dispatch-1', + prompt: 'Review the change', + label: 'Correctness', + dependsOn: [], + queuedAt: 1_700_000_000_010, + }); + const emitter = new AgentEventEmitter(); + r.bridgeApprovalEvents('wf_dispatch_approval', emitter, 'dispatch-1'); + + emitter.emit( + AgentEventType.TOOL_WAITING_APPROVAL, + approvalEvent({ subagentId: 'correctness-agent-1' }), + ); + + expect(r.get('wf_dispatch_approval')?.dispatches[0]).toMatchObject({ + id: 'dispatch-1', + subagentId: 'correctness-agent-1', + }); + }); + it('parks a workflow-agent approval and resolves it exactly once', async () => { const r = new WorkflowRunRegistry(); r.register(reg('wf_approval')); @@ -102,6 +145,14 @@ describe('WorkflowRunRegistry', () => { expect(approval).not.toHaveProperty('args'); expect(approval).not.toHaveProperty('respond'); expect(onApprovalChange).toHaveBeenCalledTimes(1); + expect(r.get('wf_approval')?.events).toEqual([ + { + id: 'event-1', + type: 'approval-requested', + at: 1_700_000_000_100, + name: 'Shell', + }, + ]); await expect( r.resolvePendingApproval( @@ -122,6 +173,14 @@ describe('WorkflowRunRegistry', () => { ToolConfirmationOutcome.ProceedOnce, undefined, ); + expect(r.get('wf_approval')?.events[1]).toMatchObject({ + id: 'event-2', + type: 'approval-settled', + name: 'Shell', + }); + expect(r.get('wf_approval')?.events[1]).not.toHaveProperty('approvalId'); + expect(r.get('wf_approval')?.events[1]).not.toHaveProperty('callId'); + expect(r.get('wf_approval')?.events[1]).not.toHaveProperty('description'); cleanup(); }); @@ -788,6 +847,207 @@ describe('WorkflowRunRegistry', () => { expect(e.agentsCompleted).toBe(1); }); + it('records phase visits and dispatch lifecycle without inferring dependencies', () => { + const r = new WorkflowRunRegistry(); + const entry = r.register(reg('wf_graph')); + + r.onPhaseStarted(entry.runId, 'Inspect', 1_100); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-1', + label: 'Scope mapper', + prompt: 'Inspect the repository', + dependsOn: [], + queuedAt: 1_110, + }); + r.onDispatchStarted(entry.runId, 'dispatch-1', 1_120); + r.onDispatchSettled(entry.runId, 'dispatch-1', undefined, 1_180); + r.onPhaseStarted(entry.runId, 'Review', 1_200); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-2', + label: 'Correctness', + prompt: 'Review correctness', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + }); + + expect(entry.phaseVisits).toEqual([ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 1_100, + endedAt: 1_200, + }, + { + id: 'phase-2', + index: 1, + title: 'Review', + startedAt: 1_200, + }, + ]); + expect(entry.dispatches).toEqual([ + expect.objectContaining({ + id: 'dispatch-1', + phaseVisitId: 'phase-1', + status: 'completed', + startedAt: 1_120, + endedAt: 1_180, + dependsOn: [], + }), + expect.objectContaining({ + id: 'dispatch-2', + phaseVisitId: 'phase-2', + status: 'queued', + dependsOn: ['dispatch-1'], + }), + ]); + }); + + it('records the runtime sequence used by workflow replay', () => { + const r = new WorkflowRunRegistry(); + const onStatusChange = vi.fn(); + r.setStatusChangeCallback(onStatusChange); + const entry = r.register(reg('wf_events')); + onStatusChange.mockClear(); + + r.onPhaseStarted(entry.runId, 'Inspect', 1_100); + r.onLogAppended(entry.runId, 'repository loaded', 1_105); + expect(onStatusChange).toHaveBeenCalledTimes(1); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-1', + label: 'Correctness', + prompt: 'Review correctness', + dependsOn: [], + queuedAt: 1_110, + }); + r.onDispatchStarted(entry.runId, 'dispatch-1', 1_120); + r.onDispatchSettled(entry.runId, 'dispatch-1', undefined, 1_180); + r.complete(entry.runId, 'done', 1_200); + + expect(entry.recentLogs).toEqual(['repository loaded']); + expect(entry.events).toEqual([ + { + id: 'event-1', + type: 'phase-started', + at: 1_100, + phaseVisitId: 'phase-1', + title: 'Inspect', + }, + { + id: 'event-2', + type: 'log', + at: 1_105, + message: 'repository loaded', + }, + { + id: 'event-3', + type: 'dispatch-queued', + at: 1_110, + dispatchId: 'dispatch-1', + }, + { + id: 'event-4', + type: 'dispatch-started', + at: 1_120, + dispatchId: 'dispatch-1', + }, + { + id: 'event-5', + type: 'dispatch-completed', + at: 1_180, + dispatchId: 'dispatch-1', + }, + { + id: 'event-6', + type: 'phase-completed', + at: 1_200, + phaseVisitId: 'phase-1', + }, + { + id: 'event-7', + type: 'workflow-completed', + at: 1_200, + }, + ]); + }); + + it('cancels unfinished dispatches before the workflow terminal event', () => { + const r = new WorkflowRunRegistry(); + const entry = r.register(reg('wf_late_dispatch')); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-1', + prompt: 'Fire and forget', + dependsOn: [], + queuedAt: 1_100, + }); + r.onDispatchStarted(entry.runId, 'dispatch-1', 1_200); + r.complete(entry.runId, 'done', 1_300); + + r.onDispatchSettled(entry.runId, 'dispatch-1', undefined, 1_400); + + expect(entry.dispatches[0]).toMatchObject({ + status: 'cancelled', + endedAt: 1_300, + }); + expect(entry.events.at(-1)).toMatchObject({ + type: 'workflow-completed', + at: 1_300, + }); + expect(entry.events.at(-2)).toMatchObject({ + type: 'dispatch-cancelled', + at: 1_300, + }); + }); + + it('cancels unfinished dispatches before a failed workflow is persisted', () => { + const r = new WorkflowRunRegistry(); + const entry = r.register(reg('wf_failed_dispatch')); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-1', + prompt: 'Fire and forget', + dependsOn: [], + queuedAt: 1_100, + }); + r.onDispatchStarted(entry.runId, 'dispatch-1', 1_200); + + r.fail(entry.runId, 'workflow failed', 1_300); + + expect(entry.dispatches[0]).toMatchObject({ + status: 'cancelled', + endedAt: 1_300, + }); + expect(entry.events.at(-2)).toMatchObject({ + type: 'dispatch-cancelled', + at: 1_300, + }); + expect(entry.events.at(-1)).toMatchObject({ + type: 'workflow-failed', + at: 1_300, + }); + }); + + it('marks live dispatches cancelled when the workflow is stopped', () => { + const r = new WorkflowRunRegistry(); + const entry = r.register(reg('wf_graph_cancel')); + r.onPhaseStarted(entry.runId, 'Fix', 1_100); + r.onDispatchQueued(entry.runId, { + id: 'dispatch-1', + label: 'Fix boundary', + prompt: 'Fix it', + dependsOn: [], + queuedAt: 1_110, + }); + r.onDispatchStarted(entry.runId, 'dispatch-1', 1_120); + + r.cancel(entry.runId, 1_200); + + expect(entry.dispatches[0]).toMatchObject({ + status: 'cancelled', + endedAt: 1_200, + }); + expect(entry.phaseVisits[0]).toMatchObject({ endedAt: 1_200 }); + }); + it.each(['running', 'pausing', 'paused'] as const)( 'treats %s workflows as active until a terminal transition', (status) => { diff --git a/packages/core/src/agents/workflow-run-registry.ts b/packages/core/src/agents/workflow-run-registry.ts index fff995c9531..b8c753de1c1 100644 --- a/packages/core/src/agents/workflow-run-registry.ts +++ b/packages/core/src/agents/workflow-run-registry.ts @@ -61,6 +61,7 @@ export type WorkflowTerminalStatus = Extract< WorkflowStatus, 'completed' | 'failed' | 'cancelled' >; +export type WorkflowRunStartMode = 'retry' | 'rerun'; export function isActiveWorkflowStatus( status: WorkflowStatus, @@ -92,6 +93,93 @@ export interface WorkflowApproval { at: number; } +export type WorkflowDispatchTraceStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface WorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export interface WorkflowDispatchTrace { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: WorkflowDispatchTraceStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface WorkflowDispatchQueued { + id: string; + label?: string; + prompt: string; + dependsOn: string[]; + queuedAt: number; + cached?: boolean; +} + +interface WorkflowEventBase { + at: number; +} + +type WorkflowEventPayload = + | (WorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (WorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (WorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (WorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (WorkflowEventBase & { + type: 'log'; + message: string; + }) + | (WorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (WorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (WorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +/** Ordered, JSON-safe facts captured while a workflow runs. */ +export type WorkflowEvent = WorkflowEventPayload & { id: string }; + /** * Workflow kind of `TaskState`. Tracks one orchestrator run — the * top-level `Workflow` tool call, not its internal subagent dispatches @@ -104,6 +192,12 @@ export interface WorkflowTask extends TaskBase { kind: 'workflow'; /** Run identifier (e.g. `wf_<8hex>`); aliased to `TaskBase.id`. */ runId: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Run whose result or journal led to this attempt. */ + sourceRunId?: string; + /** Whether this attempt reused the journal or started from scratch. */ + startMode?: WorkflowRunStartMode; /** * Parsed `export const meta = {...}` from the workflow script, or * `null` if the script had no meta declaration. The pill / dialog @@ -121,12 +215,20 @@ export interface WorkflowTask extends TaskBase { * `MAX_PHASE_ENTRIES` (10_000) by the sandbox. */ phases: string[]; + /** Chronological phase entries; unlike `phases`, each revisit has a stable id. */ + phaseVisits: WorkflowPhaseVisit[]; + /** Current phase visit used to associate newly-issued dispatches. */ + currentPhaseVisitId: string | null; + /** Dispatch-level execution graph for live UI consumers. */ + dispatches: WorkflowDispatchTrace[]; /** Cumulative `agent()` dispatches issued by this run. */ agentsDispatched: number; /** Cumulative `agent()` dispatches that have resolved (success or thrown). */ agentsCompleted: number; /** Most recent log lines from the sandbox's `getLogs()`. Capped at 100 for the UI. */ recentLogs: string[]; + /** Ordered runtime facts used to replay this run after it settles. */ + events: WorkflowEvent[]; /** * P5: cumulative output tokens spent by this run's `agent()` dispatches. * Mirrored from `budget.spent()` after each successful completion via @@ -160,6 +262,8 @@ export interface WorkflowTask extends TaskBase { * don't supply it. */ script: string; + /** Original structured arguments, retained so a failed run can resume the same journal prefix. */ + args?: unknown; /** * P7b: the path the script was loaded from, when the run was launched * from a saved workflow (`Workflow({scriptPath})` or a `/workflow-name` @@ -188,9 +292,13 @@ export type WorkflowTaskRegistration = Omit< TaskRegistration, | 'currentPhase' | 'phases' + | 'phaseVisits' + | 'currentPhaseVisitId' + | 'dispatches' | 'agentsDispatched' | 'agentsCompleted' | 'recentLogs' + | 'events' | 'tokensSpent' | 'tokenBudgetTotal' | 'perPhaseTokens' @@ -309,6 +417,10 @@ export class WorkflowRunRegistry { this.statusChangeCallback = cb; } + clearStatusChangeCallback(cb: WorkflowRunStatusChangeCallback): void { + if (this.statusChangeCallback === cb) this.statusChangeCallback = undefined; + } + setNotificationCallback( cb: WorkflowRunNotificationCallback | undefined, ): void { @@ -408,9 +520,13 @@ export class WorkflowRunRegistry { entry.todoWorkChainId ??= todoWorkChainContext.getStore(); entry.currentPhase = null; entry.phases = []; + entry.phaseVisits = []; + entry.currentPhaseVisitId = null; + entry.dispatches = []; entry.agentsDispatched = 0; entry.agentsCompleted = 0; entry.recentLogs = []; + entry.events = []; entry.tokensSpent = 0; // Preserve a caller-supplied cap; default to "no cap" otherwise. // Note: the registration's optional `tokenBudgetTotal` shape is the @@ -482,16 +598,26 @@ export class WorkflowRunRegistry { if (this.handles.get(runId) === handle) this.handles.delete(runId); } - bridgeApprovalEvents(runId: string, emitter: AgentEventEmitter): () => void { + bridgeApprovalEvents( + runId: string, + emitter: AgentEventEmitter, + dispatchId?: string, + ): () => void { const ownedApprovalIds = new Set(); const seenSources = new Set(); const onWaiting = (event: AgentApprovalRequestEvent) => { + if (dispatchId) { + const dispatch = this.entries + .get(runId) + ?.dispatches.find(({ id }) => id === dispatchId); + if (dispatch) dispatch.subagentId = event.subagentId; + } const sourceKey = JSON.stringify([event.subagentId, event.callId]); // Re-emission of an already-settled call: respond is idempotent via // the runtime's responded set, so silently dropping it is safe. if (seenSources.has(sourceKey)) return; seenSources.add(sourceKey); - const parked = this.parkPendingApproval(runId, event); + const parked = this.parkPendingApproval(runId, event, dispatchId); if (parked === 'duplicate') return; if (parked === 'rejected') { this.rejectResponder(event.respond); @@ -500,7 +626,12 @@ export class WorkflowRunRegistry { ownedApprovalIds.add(parked); }; const onResult = (event: AgentToolResultEvent) => { - this.clearPendingApproval(runId, event.subagentId, event.callId); + this.clearPendingApproval( + runId, + event.subagentId, + event.callId, + event.timestamp, + ); }; emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onWaiting); emitter.on(AgentEventType.TOOL_RESULT, onResult); @@ -526,6 +657,7 @@ export class WorkflowRunRegistry { ); if (!approval) return false; const runtime = this.approvalRuntimes.get(approvalId); + this.appendApprovalEvent(entry, approval, 'approval-settled', Date.now()); entry.pendingApprovals = entry.pendingApprovals.filter( (candidate) => candidate !== approval, ); @@ -566,6 +698,7 @@ export class WorkflowRunRegistry { runId: string, subagentId: string, callId: string, + at = Date.now(), ): boolean { const entry = this.entries.get(runId); const approval = entry?.pendingApprovals.find( @@ -573,6 +706,7 @@ export class WorkflowRunRegistry { candidate.subagentId === subagentId && candidate.callId === callId, ); if (!entry || !approval) return false; + this.appendApprovalEvent(entry, approval, 'approval-settled', at); entry.pendingApprovals = entry.pendingApprovals.filter( (candidate) => candidate !== approval, ); @@ -586,6 +720,7 @@ export class WorkflowRunRegistry { private parkPendingApproval( runId: string, event: AgentApprovalRequestEvent, + dispatchId?: string, ): string | 'duplicate' | 'rejected' { const entry = this.entries.get(runId); if ( @@ -647,6 +782,12 @@ export class WorkflowRunRegistry { requestController, }); entry.pendingApprovals = [...entry.pendingApprovals, approval]; + this.appendEvent(entry, { + type: 'approval-requested', + at: approval.at, + name: approval.name, + ...(dispatchId ? { dispatchId } : {}), + }); this.emitApprovalChange(entry); if ( approvalRequestCallback && @@ -670,6 +811,12 @@ export class WorkflowRunRegistry { }); } catch (error) { debugLogger.error('Workflow approval channel failed:', error); + this.appendApprovalEvent( + entry, + approval, + 'approval-settled', + Date.now(), + ); entry.pendingApprovals = entry.pendingApprovals.filter( (candidate) => candidate.approvalId !== approvalId, ); @@ -690,15 +837,142 @@ export class WorkflowRunRegistry { * @param runId the run to update * @param title the phase title from the sandbox `phase()` call */ - onPhaseStarted(runId: string, title: string): void { + onPhaseStarted(runId: string, title: string, at = Date.now()): void { const entry = this.entries.get(runId); if (!entry || !isActiveWorkflowStatus(entry.status)) return; entry.currentPhase = title; const last = entry.phases[entry.phases.length - 1]; - if (last !== title) entry.phases.push(title); + if (last !== title) { + entry.phases.push(title); + const priorVisit = entry.phaseVisits[entry.phaseVisits.length - 1]; + if (priorVisit && priorVisit.endedAt === undefined) { + this.closeCurrentPhase(entry, at); + } + const index = entry.phaseVisits.length; + const visit: WorkflowPhaseVisit = { + id: `phase-${index + 1}`, + index, + title, + startedAt: at, + }; + entry.phaseVisits.push(visit); + entry.currentPhaseVisitId = visit.id; + this.appendEvent(entry, { + type: 'phase-started', + at, + phaseVisitId: visit.id, + title, + }); + } + this.emitStatusChange(entry); + } + + onDispatchQueued(runId: string, event: WorkflowDispatchQueued): void { + const entry = this.entries.get(runId); + if (!entry || !isActiveWorkflowStatus(entry.status)) return; + if (entry.dispatches.some((dispatch) => dispatch.id === event.id)) return; + const fallbackLabel = `Agent ${entry.dispatches.length + 1}`; + entry.dispatches.push({ + id: event.id, + phaseVisitId: entry.currentPhaseVisitId, + label: + stripAnsiAndControl(event.label ?? '').slice(0, 200) || fallbackLabel, + prompt: stripAnsiAndControl(event.prompt).slice(0, 4_096), + status: event.cached ? 'cached' : 'queued', + dependsOn: Array.from(new Set(event.dependsOn)).filter((id) => + entry.dispatches.some((dispatch) => dispatch.id === id), + ), + queuedAt: event.queuedAt, + ...(event.cached ? { endedAt: event.queuedAt } : {}), + }); + this.appendEvent(entry, { + type: 'dispatch-queued', + at: event.queuedAt, + dispatchId: event.id, + }); + if (event.cached) { + this.appendEvent(entry, { + type: 'dispatch-cached', + at: event.queuedAt, + dispatchId: event.id, + }); + } + this.emitStatusChange(entry); + } + + onDispatchStarted(runId: string, dispatchId: string, at = Date.now()): void { + const entry = this.entries.get(runId); + const dispatch = entry?.dispatches.find(({ id }) => id === dispatchId); + if (!entry || !dispatch || dispatch.status !== 'queued') return; + dispatch.status = 'running'; + dispatch.startedAt = at; + this.appendEvent(entry, { + type: 'dispatch-started', + at, + dispatchId, + }); this.emitStatusChange(entry); } + onDispatchSettled( + runId: string, + dispatchId: string, + error?: string, + at = Date.now(), + ): void { + const entry = this.entries.get(runId); + const dispatch = entry?.dispatches.find(({ id }) => id === dispatchId); + if (!entry || !dispatch || dispatch.endedAt !== undefined) return; + const shouldRecordEvent = isActiveWorkflowStatus(entry.status); + dispatch.status = + entry.status === 'cancelled' + ? 'cancelled' + : error + ? 'failed' + : dispatch.status === 'cached' + ? 'cached' + : 'completed'; + dispatch.endedAt = at; + if (error) dispatch.error = stripAnsiAndControl(error).slice(0, 4_096); + if (!shouldRecordEvent) { + this.emitStatusChange(entry); + return; + } + if (dispatch.status === 'failed') { + this.appendEvent(entry, { + type: 'dispatch-failed', + at, + dispatchId, + error: dispatch.error ?? 'Dispatch failed.', + }); + } else { + this.appendEvent(entry, { + type: + dispatch.status === 'cached' + ? 'dispatch-cached' + : dispatch.status === 'cancelled' + ? 'dispatch-cancelled' + : 'dispatch-completed', + at, + dispatchId, + }); + } + this.emitStatusChange(entry); + } + + /** Record one sandbox log line without forcing a TUI redraw per line. */ + onLogAppended(runId: string, line: string, at = Date.now()): void { + const entry = this.entries.get(runId); + if (!entry || !isActiveWorkflowStatus(entry.status)) return; + if (entry.recentLogs.length === 100) { + entry.recentLogs.shift(); + const firstLog = entry.events.findIndex((event) => event.type === 'log'); + if (firstLog >= 0) entry.events.splice(firstLog, 1); + } + entry.recentLogs.push(line); + this.appendEvent(entry, { type: 'log', at, message: line }); + } + /** Cumulative dispatch counter — incremented before each `agent()` call resolves. */ onAgentDispatched(runId: string): void { const entry = this.entries.get(runId); @@ -794,10 +1068,13 @@ export class WorkflowRunRegistry { complete(runId: string, result: unknown, endTime: number): void { const entry = this.entries.get(runId); if (!entry || !isActiveWorkflowStatus(entry.status)) return; - this.rejectPendingApprovals(runId); + this.rejectPendingApprovals(runId, undefined, endTime); entry.status = 'completed'; entry.endTime = endTime; + this.closeCurrentPhase(entry, endTime); + this.cancelLiveDispatches(entry, endTime); entry.result = result; + this.appendEvent(entry, { type: 'workflow-completed', at: endTime }); entry.notified = true; this.emitStatusChange(entry); this.emitNotification(entry); @@ -808,10 +1085,17 @@ export class WorkflowRunRegistry { fail(runId: string, message: string, endTime: number): void { const entry = this.entries.get(runId); if (!entry || !isActiveWorkflowStatus(entry.status)) return; - this.rejectPendingApprovals(runId); + this.rejectPendingApprovals(runId, undefined, endTime); entry.status = 'failed'; entry.endTime = endTime; + this.closeCurrentPhase(entry, endTime); + this.cancelLiveDispatches(entry, endTime); entry.error = message; + this.appendEvent(entry, { + type: 'workflow-failed', + at: endTime, + error: stripAnsiAndControl(message).slice(0, 4_096), + }); entry.notified = true; this.emitStatusChange(entry); this.emitNotification(entry); @@ -827,9 +1111,12 @@ export class WorkflowRunRegistry { cancel(runId: string, endTime: number): void { const entry = this.entries.get(runId); if (!entry || !isActiveWorkflowStatus(entry.status)) return; - this.rejectPendingApprovals(runId); + this.rejectPendingApprovals(runId, undefined, endTime); entry.status = 'cancelled'; entry.endTime = endTime; + this.closeCurrentPhase(entry, endTime); + this.cancelLiveDispatches(entry, endTime); + this.appendEvent(entry, { type: 'workflow-cancelled', at: endTime }); entry.notified = true; try { (this.handles.get(runId) ?? entry.abortController).abort(); @@ -844,6 +1131,19 @@ export class WorkflowRunRegistry { return this.entries.get(runId); } + setLineage( + runId: string, + sourceRunId: string, + startMode: WorkflowRunStartMode, + ): boolean { + const entry = this.entries.get(runId); + if (!entry) return false; + entry.sourceRunId = sourceRunId; + entry.startMode = startMode; + this.emitStatusChange(entry); + return true; + } + /** All entries (active + terminal, no filter). Iteration order = registration order. */ list(): WorkflowTask[] { return Array.from(this.entries.values()); @@ -922,9 +1222,12 @@ export class WorkflowRunRegistry { let lastCancelled: WorkflowTask | undefined; for (const entry of Array.from(this.entries.values())) { if (!isActiveWorkflowStatus(entry.status)) continue; - this.rejectPendingApprovals(entry.runId); + this.rejectPendingApprovals(entry.runId, undefined, endTime); entry.status = 'cancelled'; entry.endTime = endTime; + this.closeCurrentPhase(entry, endTime); + this.cancelLiveDispatches(entry, endTime); + this.appendEvent(entry, { type: 'workflow-cancelled', at: endTime }); entry.notified = true; try { (this.handles.get(entry.runId) ?? entry.abortController).abort(); @@ -940,6 +1243,62 @@ export class WorkflowRunRegistry { this.evictTerminal(); } + private closeCurrentPhase(entry: WorkflowTask, endTime: number): void { + const current = entry.phaseVisits[entry.phaseVisits.length - 1]; + if (current && current.endedAt === undefined) { + current.endedAt = endTime; + this.appendEvent(entry, { + type: 'phase-completed', + at: endTime, + phaseVisitId: current.id, + }); + } + } + + private cancelLiveDispatches(entry: WorkflowTask, endTime: number): void { + for (const dispatch of entry.dispatches) { + if (dispatch.status !== 'queued' && dispatch.status !== 'running') { + continue; + } + dispatch.status = 'cancelled'; + dispatch.endedAt = endTime; + this.appendEvent(entry, { + type: 'dispatch-cancelled', + at: endTime, + dispatchId: dispatch.id, + }); + } + } + + private appendEvent( + entry: WorkflowTask, + payload: WorkflowEventPayload, + ): void { + const lastId = entry.events.at(-1)?.id; + const nextId = lastId ? Number(lastId.slice('event-'.length)) + 1 : 1; + entry.events.push({ + id: `event-${nextId}`, + ...payload, + }); + } + + private appendApprovalEvent( + entry: WorkflowTask, + approval: WorkflowApproval, + type: 'approval-requested' | 'approval-settled', + at: number, + ): void { + const dispatchId = entry.dispatches.find( + (dispatch) => dispatch.subagentId === approval.subagentId, + )?.id; + this.appendEvent(entry, { + type, + at, + name: approval.name, + ...(dispatchId ? { dispatchId } : {}), + }); + } + /** * Sweep terminal entries when they exceed `MAX_RETAINED_TERMINAL_WORKFLOWS`. * Active entries are always retained. Oldest terminal entries @@ -972,6 +1331,7 @@ export class WorkflowRunRegistry { private rejectPendingApprovals( runId: string, predicate: (approval: WorkflowApproval) => boolean = () => true, + at = Date.now(), ): void { const entry = this.entries.get(runId); if (!entry) return; @@ -980,6 +1340,9 @@ export class WorkflowRunRegistry { const rejectedIds = new Set( rejected.map((approval) => approval.approvalId), ); + for (const approval of rejected) { + this.appendApprovalEvent(entry, approval, 'approval-settled', at); + } entry.pendingApprovals = entry.pendingApprovals.filter( (approval) => !rejectedIds.has(approval.approvalId), ); diff --git a/packages/core/src/agents/workflow-snapshot.test.ts b/packages/core/src/agents/workflow-snapshot.test.ts index 0f1228f49d5..e9330ba3596 100644 --- a/packages/core/src/agents/workflow-snapshot.test.ts +++ b/packages/core/src/agents/workflow-snapshot.test.ts @@ -14,6 +14,7 @@ import { toSnapshot, writeWorkflowSnapshot, listWorkflowSnapshots, + deleteWorkflowSnapshot, MAX_RETAINED_SNAPSHOTS, } from './workflow-snapshot.js'; import type { WorkflowTask } from './workflow-run-registry.js'; @@ -38,9 +39,25 @@ function task(overrides: Partial = {}): WorkflowTask { abortController: new AbortController(), currentPhase: null, phases: ['Plan', 'Build'], + phaseVisits: [], + currentPhaseVisitId: null, + dispatches: [], agentsDispatched: 3, agentsCompleted: 3, recentLogs: ['log1'], + events: [ + { + id: 'event-1', + type: 'log', + at: 1_700_000_004_000, + message: 'log1', + }, + { + id: 'event-2', + type: 'workflow-completed', + at: 1_700_000_005_000, + }, + ], tokensSpent: 450, tokenBudgetTotal: 1000, perPhaseTokens: new Map([ @@ -65,7 +82,13 @@ describe('toSnapshot', () => { ); it('flattens perPhaseTokens Map into [phaseOrNull, tokens] pairs', () => { - const s = toSnapshot(task()); + const s = toSnapshot( + task({ + description: 'Review and fix', + sourceRunId: 'wf_source', + startMode: 'rerun', + }), + ); expect(s.perPhaseTokens).toEqual([ ['Plan', 200], [null, 50], @@ -73,6 +96,11 @@ describe('toSnapshot', () => { expect(s.runId).toBe('wf_a'); expect(s.script).toBe('return 1;'); expect(s.result).toEqual({ answer: 42 }); + expect(s).toMatchObject({ + description: 'Review and fix', + sourceRunId: 'wf_source', + startMode: 'rerun', + }); }); it('replaces a non-JSON-serializable result with a placeholder string', () => { @@ -85,7 +113,9 @@ describe('toSnapshot', () => { const t = task(); const s = toSnapshot(t); t.phases.push('Mutated'); + t.events[0]!.at = 0; expect(s.phases).toEqual(['Plan', 'Build']); + expect(s.events?.[0]?.at).toBe(1_700_000_004_000); }); it('never projects live pending approval data', () => { @@ -119,6 +149,7 @@ describe('toSnapshot', () => { expect(serialized).not.toContain('PRIVATE_DESCRIPTION_SENTINEL'); expect(serialized).not.toContain('PRIVATE_DIFF_SENTINEL'); expect(toSnapshot(live)).not.toHaveProperty('pendingApprovals'); + expect(toSnapshot(live).events).toEqual(live.events); }); }); @@ -142,6 +173,35 @@ describe('writeWorkflowSnapshot + listWorkflowSnapshots', () => { ['Plan', 200], [null, 50], ]); + expect(list[0].events).toEqual([ + { + id: 'event-1', + type: 'log', + at: 1_700_000_004_000, + message: 'log1', + }, + { + id: 'event-2', + type: 'workflow-completed', + at: 1_700_000_005_000, + }, + ]); + }); + + it('loads a legacy snapshot without an event ledger', async () => { + const config = fakeConfig(projectDir); + await writeWorkflowSnapshot(config, task({ runId: 'wf_legacy' })); + const snapshotPath = config.storage.getWorkflowRunSnapshotPath('wf_legacy'); + const parsed = JSON.parse( + await fs.readFile(snapshotPath, 'utf8'), + ) as Record; + delete parsed['events']; + await fs.writeFile(snapshotPath, JSON.stringify(parsed), 'utf8'); + + const list = await listWorkflowSnapshots(config); + + expect(list).toHaveLength(1); + expect(list[0].events).toBeUndefined(); }); it('freezes the snapshot projection before the first fs await', async () => { @@ -198,6 +258,75 @@ describe('writeWorkflowSnapshot + listWorkflowSnapshots', () => { expect(list.map((s) => s.runId)).toEqual(['wf_good']); }); + it('skips parseable files that do not match the snapshot contract', async () => { + const config = fakeConfig(projectDir); + await writeWorkflowSnapshot(config, task({ runId: 'wf_good' })); + const dir = config.storage.getWorkflowRunsDir(); + await fs.writeFile( + path.join(dir, 'wf_invalid.json'), + JSON.stringify({ runId: 'wf_invalid', status: 'completed' }), + 'utf8', + ); + + const list = await listWorkflowSnapshots(config); + + expect(list.map((s) => s.runId)).toEqual(['wf_good']); + }); + + it('deletes one saved run and its resume journal', async () => { + const config = fakeConfig(projectDir); + const runId = 'wf_abcd'; + await writeWorkflowSnapshot(config, task({ runId })); + const journalPath = config.storage.getWorkflowRunJournalPath(runId); + await fs.mkdir(path.dirname(journalPath), { recursive: true }); + await fs.writeFile(journalPath, '{}\n', 'utf8'); + + await expect(deleteWorkflowSnapshot(config, runId)).resolves.toBe(true); + + await expect( + fs.access(config.storage.getWorkflowRunSnapshotPath(runId)), + ).rejects.toThrow(); + await expect(fs.access(path.dirname(journalPath))).rejects.toThrow(); + await expect(listWorkflowSnapshots(config)).resolves.toEqual([]); + }); + + it('keeps the snapshot and reports failure when journal deletion fails', async () => { + const config = fakeConfig(projectDir); + const runId = 'wf_busy'; + await writeWorkflowSnapshot(config, task({ runId })); + const journalPath = config.storage.getWorkflowRunJournalPath(runId); + await fs.mkdir(path.dirname(journalPath), { recursive: true }); + await fs.writeFile(journalPath, '{}\n', 'utf8'); + const rmSpy = vi + .spyOn(fs, 'rm') + .mockRejectedValueOnce( + Object.assign(new Error('busy'), { code: 'EBUSY' }), + ); + + await expect(deleteWorkflowSnapshot(config, runId)).resolves.toBe(false); + + rmSpy.mockRestore(); + await expect( + fs.access(config.storage.getWorkflowRunSnapshotPath(runId)), + ).resolves.toBeUndefined(); + await expect(fs.access(path.dirname(journalPath))).resolves.toBeUndefined(); + }); + + it('rejects traversal-shaped run ids without touching project files', async () => { + const config = fakeConfig(projectDir); + const canary = path.join(projectDir, 'CANARY.txt'); + await fs.writeFile(canary, 'keep', 'utf8'); + + await expect(deleteWorkflowSnapshot(config, '../CANARY')).resolves.toBe( + false, + ); + await expect(deleteWorkflowSnapshot(config, 'wf_bad/path')).resolves.toBe( + false, + ); + + await expect(fs.readFile(canary, 'utf8')).resolves.toBe('keep'); + }); + it('prunes the oldest beyond MAX_RETAINED_SNAPSHOTS, journal dirs too', async () => { const config = fakeConfig(projectDir); const dir = config.storage.getWorkflowRunsDir(); diff --git a/packages/core/src/agents/workflow-snapshot.ts b/packages/core/src/agents/workflow-snapshot.ts index 743c9ab29b5..267441828f4 100644 --- a/packages/core/src/agents/workflow-snapshot.ts +++ b/packages/core/src/agents/workflow-snapshot.ts @@ -19,6 +19,10 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import type { WorkflowMeta } from './runtime/workflow-sandbox.js'; import { isTerminalWorkflowStatus, + type WorkflowDispatchTrace, + type WorkflowEvent, + type WorkflowPhaseVisit, + type WorkflowRunStartMode, type WorkflowTask, type WorkflowTerminalStatus, } from './workflow-run-registry.js'; @@ -31,11 +35,21 @@ export const MAX_RETAINED_SNAPSHOTS = 30; /** JSON-serializable projection of a terminal workflow run. */ export interface WorkflowSnapshot { runId: string; + /** Human-readable fallback when a workflow has no exported meta block. */ + description?: string; + /** Prior run used by retry or rerun. Absent on legacy snapshots. */ + sourceRunId?: string; + /** How this run was started from sourceRunId. */ + startMode?: WorkflowRunStartMode; meta: WorkflowMeta | null; status: WorkflowTerminalStatus; script: string; scriptPath?: string; phases: string[]; + /** Absent on snapshots written before workflow graph tracing existed. */ + phaseVisits?: WorkflowPhaseVisit[]; + /** Absent on snapshots written before workflow graph tracing existed. */ + dispatches?: WorkflowDispatchTrace[]; agentsDispatched: number; agentsCompleted: number; tokensSpent: number; @@ -43,6 +57,8 @@ export interface WorkflowSnapshot { /** `perPhaseTokens` flattened to `[phaseOrNull, tokens]` pairs. */ perPhaseTokens: Array<[string | null, number]>; recentLogs: string[]; + /** Absent on snapshots written before runtime event tracing existed. */ + events?: WorkflowEvent[]; startTime: number; endTime?: number; result?: unknown; @@ -56,17 +72,26 @@ export function toSnapshot(task: WorkflowTask): WorkflowSnapshot { } return { runId: task.runId, + description: task.description, + sourceRunId: task.sourceRunId, + startMode: task.startMode, meta: task.meta, status: task.status, script: task.script ?? '', scriptPath: task.scriptPath, phases: [...task.phases], + phaseVisits: task.phaseVisits.map((visit) => ({ ...visit })), + dispatches: task.dispatches.map((dispatch) => ({ + ...dispatch, + dependsOn: [...dispatch.dependsOn], + })), agentsDispatched: task.agentsDispatched, agentsCompleted: task.agentsCompleted, tokensSpent: task.tokensSpent, tokenBudgetTotal: task.tokenBudgetTotal, perPhaseTokens: Array.from(task.perPhaseTokens.entries()), recentLogs: [...task.recentLogs], + events: task.events.map((event) => ({ ...event })), startTime: task.startTime, endTime: task.endTime, result: safeResult(task.result), @@ -136,7 +161,12 @@ export async function listWorkflowSnapshots( for (const file of files) { try { const raw = await fs.readFile(`${dir}/${file}`, 'utf8'); - snapshots.push(JSON.parse(raw) as WorkflowSnapshot); + const parsed: unknown = JSON.parse(raw); + if (!isWorkflowSnapshot(parsed)) { + debugLogger.warn(`skipping invalid workflow snapshot ${file}`); + continue; + } + snapshots.push(parsed); } catch (e) { debugLogger.warn(`skipping unparseable snapshot ${file}: ${e}`); } @@ -145,6 +175,241 @@ export async function listWorkflowSnapshots( return snapshots; } +/** + * Delete one persisted run summary and its resume journal. The run id must be + * a single path segment because both targets live below the project runs dir. + * Returns true when the safe target is absent after this call. + */ +export async function deleteWorkflowSnapshot( + config: Config, + runId: string, +): Promise { + const storage = config.storage; + if (!storage || !isSafeRunIdSegment(runId)) return false; + try { + await fs.rm(`${storage.getWorkflowRunsDir()}/${runId}`, { + recursive: true, + force: true, + }); + } catch (error) { + debugLogger.warn(`delete workflow journal failed for ${runId}: ${error}`); + return false; + } + try { + await fs.unlink(storage.getWorkflowRunSnapshotPath(runId)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn(`deleteWorkflowSnapshot failed for ${runId}: ${error}`); + return false; + } + } + return true; +} + +function isSafeRunIdSegment(runId: string): boolean { + return ( + runId.length > 0 && + runId !== '.' && + runId !== '..' && + !runId.includes('/') && + !runId.includes('\\') + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === 'string') + ); +} + +function isWorkflowMeta(value: unknown): value is WorkflowMeta | null { + if (value === null) return true; + if (!isRecord(value)) return false; + if ( + typeof value['name'] !== 'string' || + typeof value['description'] !== 'string' || + !isOptionalString(value['whenToUse']) + ) { + return false; + } + const phases = value['phases']; + return ( + phases === undefined || + (Array.isArray(phases) && + phases.every( + (phase) => + isRecord(phase) && + typeof phase['title'] === 'string' && + isOptionalString(phase['detail']) && + isOptionalString(phase['model']), + )) + ); +} + +function isWorkflowPhaseVisit(value: unknown): value is WorkflowPhaseVisit { + return ( + isRecord(value) && + typeof value['id'] === 'string' && + isFiniteNumber(value['index']) && + typeof value['title'] === 'string' && + isFiniteNumber(value['startedAt']) && + (value['endedAt'] === undefined || isFiniteNumber(value['endedAt'])) + ); +} + +function isWorkflowDispatch(value: unknown): value is WorkflowDispatchTrace { + if (!isRecord(value)) return false; + const status = value['status']; + return ( + typeof value['id'] === 'string' && + (value['phaseVisitId'] === null || + typeof value['phaseVisitId'] === 'string') && + typeof value['label'] === 'string' && + typeof value['prompt'] === 'string' && + isOptionalString(value['subagentId']) && + (status === 'queued' || + status === 'running' || + status === 'completed' || + status === 'failed' || + status === 'cancelled' || + status === 'cached') && + isStringArray(value['dependsOn']) && + isFiniteNumber(value['queuedAt']) && + (value['startedAt'] === undefined || isFiniteNumber(value['startedAt'])) && + (value['endedAt'] === undefined || isFiniteNumber(value['endedAt'])) && + isOptionalString(value['error']) + ); +} + +function hasOnlyKeys( + value: Record, + keys: readonly string[], +): boolean { + const allowed = new Set(keys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +function isWorkflowEvent(value: unknown): value is WorkflowEvent { + if ( + !isRecord(value) || + typeof value['id'] !== 'string' || + !isFiniteNumber(value['at']) || + typeof value['type'] !== 'string' + ) { + return false; + } + const base = ['id', 'type', 'at']; + switch (value['type']) { + case 'phase-started': + return ( + hasOnlyKeys(value, [...base, 'phaseVisitId', 'title']) && + typeof value['phaseVisitId'] === 'string' && + typeof value['title'] === 'string' + ); + case 'phase-completed': + return ( + hasOnlyKeys(value, [...base, 'phaseVisitId']) && + typeof value['phaseVisitId'] === 'string' + ); + case 'dispatch-queued': + case 'dispatch-started': + case 'dispatch-completed': + case 'dispatch-cancelled': + case 'dispatch-cached': + return ( + hasOnlyKeys(value, [...base, 'dispatchId']) && + typeof value['dispatchId'] === 'string' + ); + case 'dispatch-failed': + return ( + hasOnlyKeys(value, [...base, 'dispatchId', 'error']) && + typeof value['dispatchId'] === 'string' && + typeof value['error'] === 'string' + ); + case 'log': + return ( + hasOnlyKeys(value, [...base, 'message']) && + typeof value['message'] === 'string' + ); + case 'approval-requested': + case 'approval-settled': + return ( + hasOnlyKeys(value, [...base, 'name', 'dispatchId']) && + typeof value['name'] === 'string' && + isOptionalString(value['dispatchId']) + ); + case 'workflow-completed': + case 'workflow-cancelled': + return hasOnlyKeys(value, base); + case 'workflow-failed': + return ( + hasOnlyKeys(value, [...base, 'error']) && + typeof value['error'] === 'string' + ); + default: + return false; + } +} + +function isWorkflowSnapshot(value: unknown): value is WorkflowSnapshot { + if (!isRecord(value)) return false; + const status = value['status']; + const phaseVisits = value['phaseVisits']; + const dispatches = value['dispatches']; + const events = value['events']; + const perPhaseTokens = value['perPhaseTokens']; + return ( + typeof value['runId'] === 'string' && + value['runId'].length > 0 && + isOptionalString(value['description']) && + isOptionalString(value['sourceRunId']) && + (value['startMode'] === undefined || + value['startMode'] === 'retry' || + value['startMode'] === 'rerun') && + isWorkflowMeta(value['meta']) && + (status === 'completed' || status === 'failed' || status === 'cancelled') && + typeof value['script'] === 'string' && + isOptionalString(value['scriptPath']) && + isStringArray(value['phases']) && + (phaseVisits === undefined || + (Array.isArray(phaseVisits) && + phaseVisits.every(isWorkflowPhaseVisit))) && + (dispatches === undefined || + (Array.isArray(dispatches) && dispatches.every(isWorkflowDispatch))) && + (events === undefined || + (Array.isArray(events) && events.every(isWorkflowEvent))) && + isFiniteNumber(value['agentsDispatched']) && + isFiniteNumber(value['agentsCompleted']) && + isFiniteNumber(value['tokensSpent']) && + (value['tokenBudgetTotal'] === null || + isFiniteNumber(value['tokenBudgetTotal'])) && + Array.isArray(perPhaseTokens) && + perPhaseTokens.every( + (entry) => + Array.isArray(entry) && + entry.length === 2 && + (entry[0] === null || typeof entry[0] === 'string') && + isFiniteNumber(entry[1]), + ) && + isStringArray(value['recentLogs']) && + isFiniteNumber(value['startTime']) && + (value['endTime'] === undefined || isFiniteNumber(value['endTime'])) && + isOptionalString(value['error']) + ); +} + /** Remove the oldest snapshots beyond the retention cap. */ async function pruneSnapshots(dir: string): Promise { let files: string[]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c31508823a..664fe3ccf2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -185,6 +185,7 @@ export { FORK_SUBAGENT_TYPE } from './tools/agent/fork-subagent.js'; export type { WorkflowTool, WorkflowParams, + WorkflowToolResult, } from './tools/workflow/workflow.js'; export type { TodoWriteTool, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index a679238bbe5..707b3aa0e70 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -399,7 +399,7 @@ export interface NotificationRecordPayload { backgroundTask?: { taskId: string; status: string; - kind: 'agent' | 'monitor' | 'shell'; + kind: 'agent' | 'monitor' | 'shell' | 'workflow'; toolUseId?: string; }; } diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index 9a5923fef9c..5ed7de2a6df 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -57,6 +57,12 @@ describe('WorkflowTool', () => { expect(schema.properties.run_in_background.description).toContain( 'cooperatively pause/resume', ); + expect(schema.properties.run_in_background.description).toContain( + 'Web Shell', + ); + expect(schema.properties.run_in_background.description).not.toContain( + 'TUI only', + ); }); // The description is what makes the model pick pipeline() over a barrier @@ -113,6 +119,7 @@ describe('WorkflowTool', () => { // and the base merge conflicted exactly here. Nothing else asserts the // control set, so dropping one on the next merge would be silent. expect(description).toMatch(/cooperative pause\/resume/); + expect(description).toMatch(/active completion channel.*Web Shell/); // #8690 asked the text to speak this project's own vocabulary. Without // a location, "runs a saved workflow" leaves the model no way to reach // one: `workflow('')` is a blind guess and `scriptPath` wants an @@ -185,20 +192,22 @@ describe('WorkflowTool', () => { expect(invocation.getDescription()).toContain('deep-research.js'); }); - it('rejects background runs outside an interactive completion channel', () => { - const headlessRegistry = new WorkflowRunRegistry(); - headlessRegistry.setCompletionCallback(vi.fn()); - const headlessConfig = { + it('accepts background runs owned by a non-interactive completion channel', () => { + const registry = new WorkflowRunRegistry(); + registry.setCompletionCallback(vi.fn()); + const config = { isInteractive: () => false, - getWorkflowRunRegistry: () => headlessRegistry, + getWorkflowRunRegistry: () => registry, } as unknown as Config; expect(() => - new WorkflowTool(headlessConfig).build({ + new WorkflowTool(config).build({ script: 'return 1', run_in_background: true, }), - ).toThrow(/interactive TUI/i); + ).not.toThrow(); + }); + it('uses the completion channel as the background-run capability', () => { const interactiveRegistry = new WorkflowRunRegistry(); const interactiveConfig = { isInteractive: () => true, @@ -222,7 +231,7 @@ describe('WorkflowTool', () => { script: 'return 1', run_in_background: true, }), - ).toThrow(/interactive TUI/i); + ).not.toThrow(); }); it('does not register a background run when the caller is already aborted', async () => { @@ -301,18 +310,25 @@ describe('WorkflowTool', () => { }), }); const updateOutput = vi.fn(); - const execution = tool - .build({ - script: `phase('slow'); return await agent('work');`, - run_in_background: true, - }) - .execute(new AbortController().signal, updateOutput); + const invocation = tool.build({ + script: `phase('slow'); return await agent('work');`, + run_in_background: true, + }); + ( + invocation as unknown as { setCallId: (callId: string) => void } + ).setCallId('workflow-tool-call'); + const execution = invocation.execute( + new AbortController().signal, + updateOutput, + ); await vi.waitFor(() => expect(resolveDispatch).toBeDefined()); const result = await execution; const entry = registry.list()[0]!; expect(entry.status).toBe('running'); expect(entry.isBackgrounded).toBe(true); + expect(entry.toolUseId).toBe('workflow-tool-call'); + expect(result.workflowRunId).toBe(entry.runId); expect(result.llmContent).toEqual([ { text: `Workflow started in background.\nRun ID: ${entry.runId}\nStatus: running`, diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 73c4186f897..3ca3286f182 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -75,6 +75,11 @@ export interface WorkflowToolOptions { dispatch?: WorkflowAgentDispatch; } +export interface WorkflowToolResult extends ToolResult { + /** Exact run started by a successfully admitted background invocation. */ + workflowRunId?: string; +} + const WORKFLOW_PARAM_SCHEMA = { type: 'object', properties: { @@ -154,7 +159,7 @@ const WORKFLOW_PARAM_SCHEMA = { type: 'boolean', default: false, description: - 'Optional. When true, start the workflow under the interactive session and return a run handle immediately. The Background Tasks view can observe, cooperatively pause/resume, or stop it, and completion is delivered to the conversation when the run settles. Interactive TUI only. Defaults to false.', + 'Optional. When true, start the workflow under a session with an active completion channel and return a run handle immediately. The Background Tasks view can observe, cooperatively pause/resume, or stop it, and completion is delivered to the conversation when the run settles. Supported by the interactive TUI and Web Shell. Defaults to false.', }, }, // `script` is required UNLESS `scriptPath` is supplied; this XOR can't be @@ -165,8 +170,10 @@ const WORKFLOW_PARAM_SCHEMA = { class WorkflowToolInvocation extends BaseToolInvocation< WorkflowParams, - ToolResult + WorkflowToolResult > { + private callId?: string; + constructor( private readonly config: Config, private readonly toolOptions: WorkflowToolOptions, @@ -175,6 +182,10 @@ class WorkflowToolInvocation extends BaseToolInvocation< super(params); } + setCallId(callId: string): void { + this.callId = callId; + } + getDescription(): string { if (this.params.scriptPath && this.params.script === undefined) { return `Run saved workflow (${path.basename(this.params.scriptPath)})`; @@ -194,7 +205,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< signal: AbortSignal, updateOutput?: (output: ToolResultDisplay) => void, _shellExecutionConfig?: ShellExecutionConfig, - ): Promise { + ): Promise { const runInBackground = this.params.run_in_background === true; if (runInBackground && signal.aborted) { return backgroundStartCancelledResult(); @@ -204,6 +215,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< handle = await WorkflowRunner.start({ config: this.config, signal, + toolUseId: this.callId, script: this.params.script, scriptPath: this.params.scriptPath, args: this.params.args, @@ -229,6 +241,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< handle.budget.total, ); return { + workflowRunId: handle.runId, llmContent: [ { text: `Workflow started in background.\nRun ID: ${handle.runId}\nStatus: ${status}`, @@ -341,7 +354,7 @@ class WorkflowToolInvocation extends BaseToolInvocation< } } -function backgroundStartCancelledResult(): ToolResult { +function backgroundStartCancelledResult(): WorkflowToolResult { return { llmContent: 'Workflow was cancelled before it could start.', returnDisplay: 'Workflow cancelled.', @@ -541,7 +554,7 @@ Reach for one to be comprehensive (decompose the work and cover every part in pa **Runtime** — see the \`script\` parameter for the detailed authoring contract. -\`phase(title)\`, \`log(msg)\`, \`agent(prompt, opts?)\`, \`parallel(thunks)\`, \`pipeline(items, ...stages)\`, \`workflow(nameOrRef, args?)\`, plus the \`args\` and \`budget\` globals. \`workflow()\` runs a saved workflow inline under this run's caps and nests one level only — a workflow reached through \`workflow()\` cannot call \`workflow()\` itself, and doing so throws. Saved workflows are \`.js\` files under \`/.qwen/workflows\` (project scope, also surfaced as \`/\` slash commands) or \`~/.qwen/workflows\` (user scope, lower precedence when both define the same name); \`workflow('')\` resolves against those two directories, while \`scriptPath\` takes an absolute path to a script anywhere. Default \`max(1, min(16, cpus-2))\` agents in flight per run (\`${MAX_WORKFLOW_CONCURRENCY_ENV}\`), up to ${DEFAULT_MAX_AGENTS_PER_RUN} agents total (\`${MAX_WORKFLOW_AGENTS_ENV}\`), under a 30-minute wall-clock cap per run (\`QWEN_CODE_MAX_WORKFLOW_SECONDS\`) — a fan-out near the agent cap will not fit inside the default cap. A per-run output-token cap may also be in effect: read \`budget.total\` (\`null\` = uncapped) before committing to a large fan-out, because once the cap is reached every further \`agent()\` call is refused — a bare sequential \`await agent()\` sees the rejection, while inside \`parallel()\`/\`pipeline()\` the refused slot becomes \`null\` and the script keeps running on partial results. Per-call \`agent({ schema, agentType, model, isolation: 'worktree' })\` covers structured-output contracts, declarative-agent selection, model override, and git-worktree-isolated subagents. \`resumeFromRunId\` resumes a prior run — agent() calls whose rolling prefix-hash matches the journal are served from cache for the longest unchanged prefix. Runs appear in the background-tasks view and the \`/workflows\` dialog (live phase tree, token usage, cooperative pause/resume, cancel); \`run_in_background: true\` returns a run handle immediately in the interactive TUI and delivers completion through the conversation. Scripts run in a node:vm sandbox with no filesystem or shell access — all I/O happens through the spawned agents. +\`phase(title)\`, \`log(msg)\`, \`agent(prompt, opts?)\`, \`parallel(thunks)\`, \`pipeline(items, ...stages)\`, \`workflow(nameOrRef, args?)\`, plus the \`args\` and \`budget\` globals. \`workflow()\` runs a saved workflow inline under this run's caps and nests one level only — a workflow reached through \`workflow()\` cannot call \`workflow()\` itself, and doing so throws. Saved workflows are \`.js\` files under \`/.qwen/workflows\` (project scope, also surfaced as \`/\` slash commands) or \`~/.qwen/workflows\` (user scope, lower precedence when both define the same name); \`workflow('')\` resolves against those two directories, while \`scriptPath\` takes an absolute path to a script anywhere. Default \`max(1, min(16, cpus-2))\` agents in flight per run (\`${MAX_WORKFLOW_CONCURRENCY_ENV}\`), up to ${DEFAULT_MAX_AGENTS_PER_RUN} agents total (\`${MAX_WORKFLOW_AGENTS_ENV}\`), under a 30-minute wall-clock cap per run (\`QWEN_CODE_MAX_WORKFLOW_SECONDS\`) — a fan-out near the agent cap will not fit inside the default cap. A per-run output-token cap may also be in effect: read \`budget.total\` (\`null\` = uncapped) before committing to a large fan-out, because once the cap is reached every further \`agent()\` call is refused — a bare sequential \`await agent()\` sees the rejection, while inside \`parallel()\`/\`pipeline()\` the refused slot becomes \`null\` and the script keeps running on partial results. Per-call \`agent({ schema, agentType, model, isolation: 'worktree' })\` covers structured-output contracts, declarative-agent selection, model override, and git-worktree-isolated subagents. \`resumeFromRunId\` resumes a prior run — agent() calls whose rolling prefix-hash matches the journal are served from cache for the longest unchanged prefix. Runs appear in the background-tasks view and the \`/workflows\` dialog (live phase tree, token usage, cooperative pause/resume, cancel); \`run_in_background: true\` returns a run handle immediately in clients with an active completion channel, including the interactive TUI and Web Shell, and delivers completion through the conversation. Scripts run in a node:vm sandbox with no filesystem or shell access — all I/O happens through the spawned agents. **Scout first, then orchestrate** @@ -571,7 +584,7 @@ These shapes are a starting point, not a menu; compose the harness the task actu export class WorkflowTool extends BaseDeclarativeTool< WorkflowParams, - ToolResult + WorkflowToolResult > { constructor( private readonly config: Config, @@ -615,12 +628,6 @@ export class WorkflowTool extends BaseDeclarativeTool< return 'WorkflowTool: `resumeFromRunId` must match the generated id format `wf_`.'; } if (params.run_in_background === true) { - if ( - !this.config.isInteractive() || - this.config.getExperimentalZedIntegration?.() === true - ) { - return 'WorkflowTool: `run_in_background` is available only in the interactive TUI.'; - } if (!this.config.getWorkflowRunRegistry().hasCompletionCallback()) { return 'WorkflowTool: `run_in_background` requires an active workflow completion channel.'; } @@ -630,7 +637,7 @@ export class WorkflowTool extends BaseDeclarativeTool< protected createInvocation( params: WorkflowParams, - ): ToolInvocation { + ): ToolInvocation { return new WorkflowToolInvocation(this.config, this.toolOptions, params); } } diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1843b300653..96cbf8277f1 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -2710,7 +2710,7 @@ export class DaemonClient { clientId?: string, ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks?includeWorkflows=true`, { headers: this.headers({}, clientId) }, async (res) => { if (!res.ok) { @@ -2743,21 +2743,60 @@ export class DaemonClient { kind: DaemonSessionTaskStatus['kind'], clientId?: string, ): Promise<{ cancelled: boolean }> { + return await this.sessionTaskMutation<{ cancelled: boolean }>( + sessionId, + taskId, + 'cancel', + { kind }, + clientId, + ); + } + + async sessionWorkflowTaskAction( + sessionId: string, + taskId: string, + action: + | 'pause' + | 'resume' + | 'retry' + | 'rerun' + | 'delete-history' + | 'run-saved', + clientId?: string, + ): Promise<{ + changed: boolean; + status?: Extract['status']; + taskId?: string; + }> { + return await this.sessionTaskMutation<{ + changed: boolean; + status?: Extract['status']; + taskId?: string; + }>(sessionId, taskId, 'workflow-action', { action }, clientId); + } + + private async sessionTaskMutation( + sessionId: string, + taskId: string, + route: 'cancel' | 'workflow-action', + body: object, + clientId?: string, + ): Promise { return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/cancel`, + `${this.baseUrl}/session/${urlEncode(sessionId)}/tasks/${urlEncode(taskId)}/${route}`, { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({ kind }), + body: JSON.stringify(body), }, async (res) => { if (!res.ok) { throw await this.failOnError( res, - 'POST /session/:id/tasks/:taskId/cancel', + `POST /session/:id/tasks/:taskId/${route}`, ); } - return (await res.json()) as { cancelled: boolean }; + return (await res.json()) as T; }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 6cdba2a431a..c9959fba118 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -747,6 +747,22 @@ export class DaemonSessionClient { ); } + async controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: Extract['status']; + taskId?: string; + }> { + return await this.client.sessionWorkflowTaskAction( + this.sessionId, + taskId, + action, + this.clientId, + ); + } + async clearGoal(): Promise<{ cleared: boolean; condition?: string }> { return await this.client.sessionGoalClear(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index f29cd5e408a..efc522ae204 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -431,7 +431,36 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ pattern: /^\/session\/([^/]+)\/tasks$/, mapping: { method: '_qwen/session/tasks', - extractParams: (segs) => ({ sessionId: segs[0] }), + extractParams: (segs, _body, _method, query) => ({ + sessionId: segs[0], + ...boolParam(query, 'includeWorkflows'), + }), + }, + }, + // POST /session/:id/tasks/:taskId/cancel → _qwen/session/tasks/cancel + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/cancel$/, + mapping: { + method: '_qwen/session/tasks/cancel', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), + }, + }, + // POST /session/:id/tasks/:taskId/workflow-action → _qwen/session/tasks/workflow_action + { + httpMethod: 'POST', + pattern: /^\/session\/([^/]+)\/tasks\/([^/]+)\/workflow-action$/, + mapping: { + method: '_qwen/session/tasks/workflow_action', + extractParams: (segs, body) => ({ + ...bodyRecord(body), + sessionId: segs[0], + taskId: segs[1], + }), }, }, // GET /session/:id/lsp -> _qwen/session/lsp diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b474a85cdea..be8891083b0 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -530,6 +530,12 @@ export type { DaemonSessionLspStatus, DaemonSessionAgentTaskStatus, DaemonSessionMonitorTaskStatus, + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowApprovalStatusEntry, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, + DaemonWorkflowEvent, + DaemonWorkflowPhaseVisit, DaemonSessionProcessTaskLifecycleStatus, DaemonSessionContextUsage, DaemonSessionContextUsageStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index f78d3d0ccb5..1c6abb13979 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -34,6 +34,8 @@ export interface DaemonWorkspaceCapability { displayName?: string; primary: boolean; trusted: boolean; + /** Whether new sessions in this workspace can use Workflow. */ + workflowsEnabled?: boolean; /** Whether this runtime can be removed without restarting the daemon. */ removable?: boolean; /** Daemon-owned Live conversation runtime. */ @@ -2245,6 +2247,13 @@ export interface DaemonSessionSupportedCommandsStatus { sessionId: string; availableCommands: DaemonAvailableCommand[]; availableSkills: string[]; + /** Whether the Workflow tool and its Web Shell surfaces are enabled. */ + workflowsEnabled?: boolean; + /** Reusable workflow definitions visible to this session. */ + savedWorkflows?: Array<{ + name: string; + source: 'project' | 'user'; + }>; } export type DaemonSessionTaskLifecycleStatus = @@ -2333,10 +2342,124 @@ export interface DaemonSessionMonitorTaskStatus { toolUseId?: string; } +export interface DaemonWorkflowPhaseVisit { + id: string; + index: number; + title: string; + startedAt: number; + endedAt?: number; +} + +export type DaemonWorkflowDispatchStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'cached'; + +export interface DaemonWorkflowDispatchStatusEntry { + id: string; + phaseVisitId: string | null; + label: string; + prompt: string; + subagentId?: string; + status: DaemonWorkflowDispatchStatus; + dependsOn: string[]; + queuedAt: number; + startedAt?: number; + endedAt?: number; + error?: string; +} + +export interface DaemonWorkflowApprovalStatusEntry { + approvalId: string; + subagentId: string; + name: string; + description: string; + at: number; +} + +interface DaemonWorkflowEventBase { + id: string; + at: number; +} + +export type DaemonWorkflowEvent = + | (DaemonWorkflowEventBase & { + type: 'phase-started'; + phaseVisitId: string; + title: string; + }) + | (DaemonWorkflowEventBase & { + type: 'phase-completed'; + phaseVisitId: string; + }) + | (DaemonWorkflowEventBase & { + type: + | 'dispatch-queued' + | 'dispatch-started' + | 'dispatch-completed' + | 'dispatch-cancelled' + | 'dispatch-cached'; + dispatchId: string; + }) + | (DaemonWorkflowEventBase & { + type: 'dispatch-failed'; + dispatchId: string; + error: string; + }) + | (DaemonWorkflowEventBase & { type: 'log'; message: string }) + | (DaemonWorkflowEventBase & { + type: 'approval-requested' | 'approval-settled'; + name: string; + dispatchId?: string; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-completed' | 'workflow-cancelled'; + }) + | (DaemonWorkflowEventBase & { + type: 'workflow-failed'; + error: string; + }); + +export interface DaemonSessionWorkflowTaskStatus { + kind: 'workflow'; + id: string; + /** Tool call in the parent session that launched this workflow. */ + toolUseId?: string; + /** Restored from the project snapshot store; controls are read-only. */ + isHistorical?: boolean; + sourceRunId?: string; + startMode?: 'retry' | 'rerun'; + label: string; + description: string; + status: DaemonSessionTaskLifecycleStatus | 'pausing'; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + isBackgrounded: boolean; + currentPhase: string | null; + phaseVisits: DaemonWorkflowPhaseVisit[]; + dispatches: DaemonWorkflowDispatchStatusEntry[]; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal: number | null; + recentLogs: string[]; + /** Ordered runtime facts; absent for snapshots created before event tracing. */ + events?: DaemonWorkflowEvent[]; + pendingApprovalCount: number; + pendingApprovals?: DaemonWorkflowApprovalStatusEntry[]; + error?: string; +} + export type DaemonSessionTaskStatus = | DaemonSessionAgentTaskStatus | DaemonSessionShellTaskStatus - | DaemonSessionMonitorTaskStatus; + | DaemonSessionMonitorTaskStatus + | DaemonSessionWorkflowTaskStatus; export interface DaemonSessionTasksStatus { v: 1; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index dca7a42b461..4732599fca4 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -205,6 +205,12 @@ export { type DaemonSessionLspStatus, type DaemonSessionAgentTaskStatus, type DaemonSessionMonitorTaskStatus, + type DaemonSessionWorkflowTaskStatus, + type DaemonWorkflowApprovalStatusEntry, + type DaemonWorkflowDispatchStatus, + type DaemonWorkflowDispatchStatusEntry, + type DaemonWorkflowEvent, + type DaemonWorkflowPhaseVisit, type DaemonSessionProcessTaskLifecycleStatus, type DaemonSessionDiedData, type DaemonSessionDiedEvent, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 8b2106273be..5e50b22dfda 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1336,7 +1336,9 @@ describe('DaemonClient', () => { if (req.url.endsWith('/session/with%2Fslash/supported-commands')) { return jsonResponse(200, supportedCommands); } - if (req.url.endsWith('/session/with%2Fslash/tasks')) { + if ( + req.url.endsWith('/session/with%2Fslash/tasks?includeWorkflows=true') + ) { return jsonResponse(200, tasks); } if (req.url.endsWith('/session/with%2Fslash/lsp')) { @@ -1361,7 +1363,10 @@ describe('DaemonClient', () => { expect(calls.map((c) => [c.method, c.url])).toEqual([ ['GET', 'http://daemon/session/with%2Fslash/context'], ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], - ['GET', 'http://daemon/session/with%2Fslash/tasks'], + [ + 'GET', + 'http://daemon/session/with%2Fslash/tasks?includeWorkflows=true', + ], ['GET', 'http://daemon/session/with%2Fslash/lsp'], ]); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 41b99b9484a..b5d5462b742 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -876,7 +876,7 @@ describe('DaemonSessionClient', () => { availableSkills: ['review'], }); } - if (req.url.endsWith('/session/s-1/tasks')) { + if (req.url.endsWith('/session/s-1/tasks?includeWorkflows=true')) { return jsonResponse(200, { v: 1, sessionId: 's-1', @@ -996,7 +996,7 @@ describe('DaemonSessionClient', () => { 'http://daemon/session/s-1/model', 'http://daemon/session/s-1/context', 'http://daemon/session/s-1/supported-commands', - 'http://daemon/session/s-1/tasks', + 'http://daemon/session/s-1/tasks?includeWorkflows=true', 'http://daemon/session/s-1/lsp', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index 5a13fd66204..3438e3d8897 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -460,6 +460,48 @@ describe('acpRouteTable – matchRoute', () => { const result = matchRoute('/session/s17/tasks', 'GET'); expect(result).not.toBeNull(); expect(result!.mapping.method).toBe('_qwen/session/tasks'); + const params = result!.mapping.extractParams( + result!.segments, + undefined, + 'GET', + new URLSearchParams('includeWorkflows=true'), + ); + expect(params).toEqual({ sessionId: 's17', includeWorkflows: true }); + }); + + it('POST /session/:id/tasks/:taskId/cancel maps to _qwen/session/tasks/cancel', () => { + const result = matchRoute('/session/s17/tasks/task%2F1/cancel', 'POST'); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/cancel'); + const params = result!.mapping.extractParams( + result!.segments, + { kind: 'workflow' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'task/1', + kind: 'workflow', + }); + }); + + it('POST /session/:id/tasks/:taskId/workflow-action maps to _qwen/session/tasks/workflow_action', () => { + const result = matchRoute( + '/session/s17/tasks/workflow%201/workflow-action', + 'POST', + ); + expect(result).not.toBeNull(); + expect(result!.mapping.method).toBe('_qwen/session/tasks/workflow_action'); + const params = result!.mapping.extractParams( + result!.segments, + { action: 'retry' }, + 'POST', + ); + expect(params).toEqual({ + sessionId: 's17', + taskId: 'workflow 1', + action: 'retry', + }); }); it('GET /session/:id/lsp maps to _qwen/session/lsp', () => { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 97b07f652d1..1a03f41a8ee 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -38,6 +38,7 @@ type MockConnection = { models: Array<{ id: string; label?: string }>; commands: unknown[]; skills: string[]; + supportedCommands?: { workflowsEnabled?: boolean }; capabilities: { qwenCodeVersion: string; features: string[] }; loadingTranscript: boolean; catchingUp: boolean; @@ -1397,6 +1398,15 @@ vi.doMock('./components/dialogs/GoalsDialog', async () => { }, }; }); +vi.doMock('./components/workflows/WorkflowRunsPage', async () => { + const React = await import('react'); + return { + WorkflowRunsPage: () => + React.createElement('div', { + 'data-testid': 'workflow-runs-content', + }), + }; +}); vi.doMock('./components/extensions/ExtensionsManagerPage', async () => { const React = await import('react'); return { @@ -1595,7 +1605,7 @@ describe('mergeSideTaskCatalog', () => { }); describe('task activity key', () => { - it('includes background shells in any tool-call state', () => { + it('includes task-bearing tool calls in any state', () => { const messages = [ { id: 'tools', @@ -1639,12 +1649,18 @@ describe('task activity key', () => { status: 'completed', args: { command: 'npm run dev --watch' }, }, + { + callId: 'workflow-call', + toolName: 'workflow', + status: 'in_progress', + args: {}, + }, ], }, ] satisfies Message[]; expect(getTaskActivityKey(messages)).toBe( - 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|monitor-call:completed', + 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|monitor-call:completed|workflow-call:in_progress', ); }); @@ -4411,6 +4427,7 @@ beforeEach(() => { mockConnection.missingSession = false; mockConnection.commands = []; mockConnection.skills = []; + mockConnection.supportedCommands = undefined; mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; mockConnection.capabilities = { @@ -18624,6 +18641,89 @@ describe('App prompt send failure retry', () => { }); }); +describe('App workflow history entry', () => { + it('opens workflows from a session-less enabled workspace', async () => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + ...mockWorkspace.capabilities, + workspaces: [ + { + id: 'primary', + cwd: '/workspace', + primary: true, + workflowsEnabled: true, + }, + ], + } as typeof mockWorkspace.capabilities; + mockConnection.supportedCommands = { workflowsEnabled: false }; + + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + mockConnection.sessionId = 'session-1'; + mockConnection.workspaceCwd = '/workspace'; + mockConnection.supportedCommands = { workflowsEnabled: false }; + rerender(); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + }); + + it('opens the workflow runs page for a bare /workflows command', async () => { + mockConnection.supportedCommands = { workflowsEnabled: true }; + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="workflow-runs-content"]'), + ).not.toBeNull(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + mockConnection.supportedCommands = { workflowsEnabled: false }; + rerender(); + await flush(); + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + }); + + it('does not expose the local workflow page when workflows are disabled', async () => { + const { container } = renderApp(); + await flush(); + + expect( + container.querySelector('button[aria-label="Workflows"]'), + ).toBeNull(); + testState.prompt = '/workflows'; + await clickSubmit(container); + await flush(); + + expect( + container.querySelector('[data-testid="workflow-runs-page"]'), + ).toBeNull(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + '/workflows', + expect.any(Object), + ); + }); +}); + describe('App /goal command', () => { it('opens the Goals page for a bare /goal instead of sending a prompt', async () => { const { container } = renderApp(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 2d0a847462b..75b1d0ec3a4 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -58,7 +58,9 @@ import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { SubagentDetailsProvider } from './subagentDetailsContext'; import { MonitorDetailsProvider } from './monitorDetailsContext'; +import { WorkflowDetailsProvider } from './workflowDetailsContext'; import { findMonitorTaskForTool } from './utils/monitorTasks'; +import { getTaskActivityKey } from './utils/taskActivity'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; import { loadVoiceProviders, @@ -150,6 +152,7 @@ import { } from './utils/splitUrl'; import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { GoalsDialog } from './components/dialogs/GoalsDialog'; +import { WorkflowRunsPage } from './components/workflows/WorkflowRunsPage'; import { goalArgOf, isGoalClearCommand, @@ -1319,37 +1322,7 @@ function parseRenameArgument( return { type: 'manual', displayName: trimmed }; } -function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { - const name = tool.toolName.toLowerCase(); - if (name === 'monitor') return true; - if (tool.args?.is_background !== true) return false; - return ( - name === 'shell' || - name === 'bash' || - name === 'run_shell_command' || - name === 'exec' - ); -} - -export function getTaskActivityKey(messages: readonly Message[]): string { - const parts: string[] = []; - const visit = (tools: readonly ACPToolCall[]) => { - for (const tool of tools) { - if ( - isBackgroundTaskToolCall(tool) || - isBackgroundSubAgentToolCall(tool) - ) { - parts.push(`${tool.callId}:${tool.status}`); - } - if (tool.subTools) visit(tool.subTools); - } - }; - for (const message of messages) { - if (message.role !== 'tool_group') continue; - visit(message.tools); - } - return parts.join('|'); -} +export { getTaskActivityKey } from './utils/taskActivity'; export function mergeMonitorTaskSnapshot( current: DaemonSessionMonitorTaskStatus, @@ -1705,6 +1678,17 @@ function mapToWebShellTaskInfo( pid: task.pid, exitCode: task.exitCode, }; + case 'workflow': + return { + ...base, + kind: 'workflow', + status: task.status, + currentPhase: task.currentPhase ?? undefined, + agentsDispatched: task.agentsDispatched, + agentsCompleted: task.agentsCompleted, + tokensSpent: task.tokensSpent, + tokenBudgetTotal: task.tokenBudgetTotal ?? undefined, + }; default: return task satisfies never; } @@ -2369,6 +2353,13 @@ export function App({ workspaces, ], ); + const workspaceWorkflowsEnabled = + workspaces.find((entry) => entry.cwd === activeWorkspaceCwd) + ?.workflowsEnabled ?? false; + const workflowsEnabled = connection.sessionId + ? (connection.supportedCommands?.workflowsEnabled ?? + workspaceWorkflowsEnabled) + : workspaceWorkflowsEnabled; // Worktree sessions query git status with the worktree path (?cwd= // parameter); the chip prefers the live branch from that status, falling // back to the creation-time sessionWorktree.branch. @@ -4452,12 +4443,11 @@ export function App({ const [gitDialog, setGitDialog] = useState< { workspaceCwd: string; gitCwd?: string; view: GitDialogView } | undefined >(undefined); - // Main content view. The scheduled-tasks page replaces the chat pane inline - // (not a modal overlay), mirroring the reference design; creating or opening - // a chat returns to 'chat'. (Daemon Status is no longer a boolean dialog — it - // is one of the activePanel values below.) + // Main content view. Full-page management views replace the chat pane inline + // (not as modal overlays); creating or opening a chat returns to 'chat'. + // Daemon Status is one of the activePanel values below. const [mainView, setMainView] = useState< - 'chat' | 'scheduledTasks' | 'goals' | 'split' + 'chat' | 'scheduledTasks' | 'workflows' | 'goals' | 'split' >('chat'); const mainViewRef = useRef(mainView); const useFloatingArtifactPanel = @@ -4748,6 +4738,16 @@ export function App({ setActivePanel(null); setMainView('scheduledTasks'); }, []); + const openWorkflows = useCallback(() => { + if (!workflowsEnabled) return; + setActivePanel(null); + setMainView('workflows'); + }, [workflowsEnabled]); + useEffect(() => { + if (mainView === 'workflows' && !workflowsEnabled) { + setMainView('chat'); + } + }, [mainView, workflowsEnabled]); const openGoals = useCallback(() => { setActivePanel(null); setMainView('goals'); @@ -5020,12 +5020,16 @@ export function App({ // need a pending-approval signal plumbed up through SplitView. Escape or // the toolbar exits fullscreen and reveals them. if (artifactPanelFullscreen) setArtifactPanelFullscreen(false); - // The Scheduled Tasks and Goals pages are full-pane overlays + // Scheduled Tasks, Workflows, and Goals are full-pane overlays // (position:absolute) that cover the chat footer too, so dismiss them for // the same reason. The split view is deliberately NOT dismissed: each pane // owns and renders its own session's approval, so an approval on the (outer) // main session must not yank the user out of the panes they are working in. - if (mainView === 'scheduledTasks' || mainView === 'goals') { + if ( + mainView === 'scheduledTasks' || + mainView === 'workflows' || + mainView === 'goals' + ) { setMainView('chat'); } }, [ @@ -8685,6 +8689,14 @@ export function App({ openEnvironmentTasksPanel(); return true; } + if ( + cmd === 'workflows' && + workflowsEnabled && + text.slice(match[0].length).trim().length === 0 + ) { + openWorkflows(); + return true; + } if (cmd === 'goal') { // A bare `/goal` just opens the Goals page; it neither sends a // prompt nor touches the session, so it works mid-turn too. @@ -9581,6 +9593,8 @@ export function App({ closeMobileDrawer, openPanel, openScheduledTasks, + openWorkflows, + workflowsEnabled, openGoals, createNewSession, ensureSessionForPrompt, @@ -11166,6 +11180,10 @@ export function App({ closeMobileDrawer(); openScheduledTasks(); }} + onOpenWorkflows={() => { + closeMobileDrawer(); + openWorkflows(); + }} onOpenGoals={() => { closeMobileDrawer(); openGoals(); @@ -11660,6 +11678,42 @@ export function App({ )} + {mainView === 'workflows' && workflowsEnabled && ( +
+
+ +
+ {t('workflowRuns.title')} +
+
+
+ +
+
+ )} {mainView === 'goals' && (
@@ -11996,11 +12050,16 @@ export function App({ } /> ); + const messageListWithWorkflowDetails = ( + + {messageListContent} + + ); const messageListWithSubagentDetails = ( - {messageListContent} + {messageListWithWorkflowDetails} ); const messageList = monitorDetailsSupported ? ( diff --git a/packages/web-shell/client/adapters/toolClassification.ts b/packages/web-shell/client/adapters/toolClassification.ts index bd22e22f3fb..8717de4a4ca 100644 --- a/packages/web-shell/client/adapters/toolClassification.ts +++ b/packages/web-shell/client/adapters/toolClassification.ts @@ -25,6 +25,7 @@ export function isTaskExecutionRaw(raw: unknown): boolean { export function isSubAgentToolCall(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); + if (name === 'workflow') return false; if (name === 'agent' || name === 'task') return true; if (tool.subTools || tool.subContent) return true; if (isTaskExecutionRaw(tool.rawOutput)) return true; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 884a41c8827..4e0180e9a3f 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -31,6 +31,7 @@ import { import type { ACPToolCall } from '../adapters/types'; import { SubagentDetailsProvider } from '../subagentDetailsContext'; import { MonitorDetailsProvider } from '../monitorDetailsContext'; +import { WorkflowDetailsProvider } from '../workflowDetailsContext'; import { useI18n } from '../i18n'; import { useWebShellCustomization } from '../customization'; import { @@ -40,6 +41,7 @@ import { import { useAnimationFrameTranscriptBlocks } from '../hooks/useAnimationFrameTranscriptBlocks'; import { useMessagesFromBlocks } from '../hooks/useMessages'; import { useSessionArtifacts } from '../hooks/useSessionArtifacts'; +import { useBackgroundTasks } from '../hooks/useBackgroundTasks'; import { extractPendingPermission } from '../adapters/transcriptAdapter'; import type { PromptImage } from '../adapters/promptTypes'; import type { @@ -58,6 +60,7 @@ import { isExitPlanApprovalRequest, } from '../utils/todos'; import { findMonitorTaskForTool } from '../utils/monitorTasks'; +import { getTaskActivityKey } from '../utils/taskActivity'; import { invokeSlashCommandHandler } from '../utils/slash-command-action'; import type { WebShellSlashCommandHandler } from '../App'; import { getModelDisplayName } from '../utils/modelDisplay'; @@ -247,6 +250,15 @@ export function ChatPane({ ); const blocks = useAnimationFrameTranscriptBlocks(); const messages = useMessagesFromBlocks(t, blocks); + const taskActivityKey = useMemo( + () => getTaskActivityKey(messages), + [messages], + ); + const sessionTasks = useBackgroundTasks( + connection.sessionId, + taskActivityKey, + connection.status === 'connected', + ); const transcriptHistory = useTranscriptHistory(); const store = useTranscriptStore(); const streamingState = useStreamingState(); @@ -961,48 +973,52 @@ export function ChatPane({ onOpen={openMonitorDetails} > - + + +
diff --git a/packages/web-shell/client/components/StatusBar.test.tsx b/packages/web-shell/client/components/StatusBar.test.tsx index 55b2e8c38c5..b90bb850d24 100644 --- a/packages/web-shell/client/components/StatusBar.test.tsx +++ b/packages/web-shell/client/components/StatusBar.test.tsx @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import type { DaemonSessionWorkflowTaskStatus } from '@qwen-code/sdk/daemon'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -113,3 +114,34 @@ describe('StatusBar goal pill', () => { expect(document.body.textContent).toContain('/goal active'); }); }); + +describe('StatusBar workflow pill', () => { + it.each(['pausing', 'paused'] as const)( + 'keeps a %s workflow visible as active work', + (status) => { + const task: DaemonSessionWorkflowTaskStatus = { + kind: 'workflow', + id: 'workflow-1', + label: 'review-and-fix', + description: 'Review and fix', + status, + startTime: 1_000, + runtimeMs: 500, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [], + dispatches: [], + agentsDispatched: 2, + agentsCompleted: 1, + tokensSpent: 100, + recentLogs: [], + pendingApprovalCount: 0, + }; + + mount({ tasks: [task], onOpenTasks: vi.fn() }); + + expect(document.body.textContent).toContain('1 workflow'); + expect(document.body.textContent).not.toContain('1 task done'); + }, + ); +}); diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index 177ca79ce0c..26af4e7c3dd 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -102,12 +102,18 @@ export function getTaskPillLabel( const composerTasks = tasks.filter(isComposerTask); if (composerTasks.length === 0) return ''; - const running = composerTasks.filter((task) => task.status === 'running'); + const running = composerTasks.filter( + (task) => + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused', + ); if (running.length > 0) { - const counts = { shell: 0, monitor: 0 }; + const counts = { shell: 0, monitor: 0, workflow: 0 }; for (const task of running) { if (task.kind === 'shell') counts.shell += 1; if (task.kind === 'monitor') counts.monitor += 1; + if (task.kind === 'workflow') counts.workflow += 1; } const parts: string[] = []; if (counts.shell > 0) { @@ -125,6 +131,16 @@ export function getTaskPillLabel( ), ); } + if (counts.workflow > 0) { + parts.push( + formatCount( + counts.workflow, + 'tasks.pill.workflow', + 'tasks.pill.workflows', + t, + ), + ); + } return parts.join(', '); } diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css index 21443e8e0ec..041fecc9f71 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.module.css +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.module.css @@ -48,6 +48,11 @@ cursor: pointer; } +.row:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + .selected { color: var(--agent-blue-500); } @@ -386,6 +391,13 @@ overflow: hidden; } +.embeddedPanel .inlineDetail[data-kind='workflow'] { + margin: 4px 0 10px; + padding: 0; + border: 0; + background: transparent; +} + .embeddedPanel .detail { margin: 0; border-radius: 0; @@ -490,6 +502,12 @@ opacity: 0.55; } +.primaryActionButton { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + .dangerButton { color: var(--error-color); } diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx index 681981f1f19..afbe96ef0c2 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx @@ -7,6 +7,7 @@ import type { DaemonSessionMonitorTaskStatus, DaemonSessionTaskStatus, DaemonSessionTasksStatus, + DaemonSessionWorkflowTaskStatus, } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { I18nProvider } from '../../i18n'; @@ -14,14 +15,18 @@ import { I18nProvider } from '../../i18n'; // The panel only needs getTasks/cancelTask from the daemon SDK; mock the // hook so the unit test doesn't pull the whole connection graph. Hoisted // so tests can assert on / reprogram the mocks across renders. -const { getTasksMock, cancelTaskMock } = vi.hoisted(() => ({ - getTasksMock: vi.fn(), - cancelTaskMock: vi.fn(), -})); +const { getTasksMock, cancelTaskMock, controlWorkflowTaskMock } = vi.hoisted( + () => ({ + getTasksMock: vi.fn(), + cancelTaskMock: vi.fn(), + controlWorkflowTaskMock: vi.fn(), + }), +); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => ({ getTasks: getTasksMock, cancelTask: cancelTaskMock, + controlWorkflowTask: controlWorkflowTaskMock, }), })); @@ -41,6 +46,8 @@ afterEach(() => { mounted.length = 0; getTasksMock.mockReset(); cancelTaskMock.mockReset(); + controlWorkflowTaskMock.mockReset(); + vi.useRealTimers(); }); function agentTask( @@ -80,10 +87,58 @@ function monitorTask( }; } +function workflowTask( + overrides: Partial = {}, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'workflow-1', + label: 'review-and-fix', + description: 'Review and fix', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Review', + startedAt: 1_000, + }, + ], + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Correctness', + prompt: 'Review behavior regressions', + status: 'running', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + }, + ], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 120, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + function renderPanel( tasks: DaemonSessionTaskStatus[], options: { embedded?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + taskView?: 'all' | 'workflow-active' | 'workflow-history'; + sessionId?: string; + onTasksChange?: (snapshot: DaemonSessionTasksStatus) => void; planTodos?: readonly TodoItem[]; agentTools?: readonly ACPToolCall[]; onOpenSubagent?: (tool: ACPToolCall) => void; @@ -92,7 +147,7 @@ function renderPanel( ): HTMLElement { const snapshot: DaemonSessionTasksStatus = { v: 1, - sessionId: 'session-1', + sessionId: options.sessionId ?? 'session-1', now: 10_000, tasks, }; @@ -106,11 +161,15 @@ function renderPanel( , ); @@ -154,6 +213,494 @@ describe('TasksStatusMessage monitor details', () => { }); }); +describe('TasksStatusMessage workflow details', () => { + it('makes embedded workflow rows keyboard-accessible', () => { + const container = renderPanel([workflowTask()], { + embedded: true, + keyboardShortcuts: false, + taskView: 'workflow-active', + }); + const row = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('review-and-fix')); + + expect(row).toBeDefined(); + expect(row?.tabIndex).toBe(0); + expect(row?.getAttribute('aria-expanded')).toBe('false'); + act(() => { + row?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(row?.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('Review behavior regressions'); + expect(container.textContent).not.toContain('Runtime 5s'); + expect(container.textContent?.match(/120 tokens/gi)).toHaveLength(1); + }); + + it('closes filtered detail instead of selecting a different workflow', () => { + const taskA = workflowTask({ + id: 'workflow-a', + label: 'run-a', + startTime: 2_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-a', + prompt: 'prompt-for-a', + }, + ], + }); + const taskB = workflowTask({ + id: 'workflow-b', + label: 'run-b', + startTime: 1_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-b', + prompt: 'prompt-for-b', + }, + ], + }); + const container = renderPanel([taskA, taskB], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + }); + const rowA = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-a')); + act(() => rowA?.click()); + expect(container.textContent).toContain('prompt-for-a'); + + const nextSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-1', + now: 11_000, + tasks: [{ ...taskA, status: 'completed', endTime: 11_000 }, taskB], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + const rowB = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-b')); + expect(rowB?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('prompt-for-a'); + expect(container.textContent).not.toContain('prompt-for-b'); + }); + + it('ignores a stale polling response from the previous session', async () => { + vi.useFakeTimers(); + const onTasksChange = vi.fn(); + let resolveSessionA!: (snapshot: DaemonSessionTasksStatus) => void; + const pendingSessionA = new Promise((resolve) => { + resolveSessionA = resolve; + }); + getTasksMock.mockReturnValueOnce(pendingSessionA); + const sessionATask = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const sessionBTask = workflowTask({ id: 'workflow-b', label: 'run-b' }); + const container = renderPanel([sessionATask], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + sessionId: 'session-a', + onTasksChange, + }); + await act(async () => vi.advanceTimersByTime(3_000)); + + const sessionBSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-b', + now: 11_000, + tasks: [sessionBTask], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + resolveSessionA({ + v: 1, + sessionId: 'session-a', + now: 12_000, + tasks: [sessionATask], + }); + await pendingSessionA; + }); + + expect(container.textContent).toContain('run-b'); + expect(container.textContent).not.toContain('run-a'); + expect(onTasksChange).not.toHaveBeenCalled(); + }); + + it('opens the live graph and stops the workflow through the task API', async () => { + const task = workflowTask(); + cancelTaskMock.mockResolvedValue({ cancelled: true }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_100, + tasks: [{ ...task, status: 'cancelled' }], + }); + const container = renderPanel([task]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + + expect(container.textContent).toContain('Review behavior regressions'); + const stop = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Stop'); + expect(stop).toBeDefined(); + + await act(async () => stop!.click()); + + expect(cancelTaskMock).toHaveBeenCalledWith('workflow-1', 'workflow'); + }); + + it('pauses and resumes a background workflow through the task API', async () => { + const task = workflowTask(); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'pausing', + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_100, + tasks: [{ ...task, status: 'pausing' }], + }); + const container = renderPanel([task]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const pause = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Pause'); + + await act(async () => pause!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'pause'); + + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [{ ...task, status: 'running' }], + }); + const pausedContainer = renderPanel([workflowTask({ status: 'paused' })]); + const pausedRow = Array.from(pausedContainer.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => pausedRow?.click()); + const resume = Array.from( + pausedContainer.querySelectorAll('button'), + ).find((button) => button.textContent === 'Resume'); + + await act(async () => resume!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenLastCalledWith( + 'workflow-1', + 'resume', + ); + }); + + it('ignores a workflow action that settles after switching sessions', async () => { + let resolveControl!: (value: { + changed: boolean; + status: 'pausing'; + }) => void; + controlWorkflowTaskMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveControl = resolve; + }), + ); + const sessionATask = workflowTask({ id: 'workflow-a', label: 'run-a' }); + const sessionBTask = workflowTask({ id: 'workflow-b', label: 'run-b' }); + const container = renderPanel([sessionATask], { + embedded: true, + keyboardShortcuts: false, + syncSnapshot: true, + taskView: 'workflow-active', + sessionId: 'session-a', + }); + const rowA = Array.from( + container.querySelectorAll('[role="button"]'), + ).find((candidate) => candidate.textContent?.includes('run-a')); + act(() => rowA?.click()); + const pause = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Pause'); + act(() => pause?.click()); + + const sessionBSnapshot: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'session-b', + now: 11_000, + tasks: [sessionBTask], + }; + const root = mounted.at(-1)!.root; + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + resolveControl({ changed: true, status: 'pausing' }); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('run-b'); + expect(container.textContent).not.toContain('run-a'); + expect(getTasksMock).not.toHaveBeenCalled(); + }); + + it('retries a failed workflow path and refreshes the graph', async () => { + const failed = workflowTask({ + status: 'failed', + error: 'Architecture review failed', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + status: 'failed', + error: 'Architecture review failed', + }, + ], + }); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [workflowTask()], + }); + const container = renderPanel([failed]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const retry = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Retry failed path'); + expect(retry).toBeDefined(); + + await act(async () => retry!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'retry'); + expect(getTasksMock).toHaveBeenCalledOnce(); + }); + + it('reruns a failed workflow from scratch and opens the new run', async () => { + const failed = workflowTask({ + status: 'failed', + error: 'Architecture review failed', + }); + const rerun = workflowTask({ + id: 'workflow-2', + sourceRunId: failed.id, + startMode: 'rerun', + startTime: 2_000, + dispatches: [ + { + ...workflowTask().dispatches[0]!, + id: 'dispatch-2', + prompt: 'Fresh run agent', + }, + ], + }); + controlWorkflowTaskMock.mockResolvedValue({ + changed: true, + status: 'running', + taskId: rerun.id, + }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 10_200, + tasks: [failed, rerun, agentTask('newer-agent', { startTime: 3_000 })], + }); + const container = renderPanel([failed]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + const buttons = Array.from( + container.querySelectorAll('button'), + ); + expect( + buttons.find((button) => button.textContent === 'Retry failed path'), + ).toBeDefined(); + const rerunAll = buttons.find( + (button) => button.textContent === 'Rerun all', + ); + expect(rerunAll).toBeDefined(); + + await act(async () => rerunAll!.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith('workflow-1', 'rerun'); + expect(container.textContent).toContain('Fresh run agent'); + expect(container.textContent).toContain('Compare runs'); + }); + + it('offers a full rerun, but not a path retry, after completion', () => { + const container = renderPanel([ + workflowTask({ status: 'completed', endTime: 9_000 }), + ]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => row?.click()); + + expect(container.textContent).toContain('Rerun all'); + expect(container.textContent).not.toContain('Retry failed path'); + }); + + it('shows saved workflow history while keeping restored runs read-only', () => { + const current = workflowTask({ id: 'workflow-current' }); + const historical = workflowTask({ + id: 'workflow-saved', + isHistorical: true, + status: 'failed', + startTime: 500, + endTime: 1_000, + runtimeMs: 500, + }); + const container = renderPanel([current, historical]); + const currentRow = Array.from(container.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + + act(() => currentRow?.click()); + const history = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Run history (1)'); + expect(history).toBeDefined(); + act(() => history!.click()); + + expect(container.textContent).toContain('workflow-saved'); + + const savedContainer = renderPanel([historical]); + const savedRow = Array.from(savedContainer.querySelectorAll('span')).find( + (node) => node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => savedRow?.click()); + + expect(savedContainer.textContent).toContain('Saved run · read-only'); + expect(savedContainer.textContent).not.toContain('Retry failed path'); + expect(savedContainer.textContent).not.toContain('Rerun all'); + }); + + it('deletes a restored run after confirmation and refreshes the task list', async () => { + const historical = workflowTask({ + id: 'wf-abcd', + isHistorical: true, + status: 'failed', + startTime: 500, + endTime: 1_000, + runtimeMs: 500, + }); + controlWorkflowTaskMock.mockResolvedValue({ changed: true }); + getTasksMock.mockResolvedValue({ + v: 1, + sessionId: 'session-1', + now: 2_000, + tasks: [], + }); + const container = renderPanel([historical]); + const row = Array.from(container.querySelectorAll('span')).find((node) => + node.textContent?.includes('review-and-fix'), + )?.parentElement; + act(() => row?.click()); + + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete saved run', + ); + act(() => remove?.click()); + expect(controlWorkflowTaskMock).not.toHaveBeenCalled(); + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm delete', + ); + await act(async () => confirm?.click()); + + expect(controlWorkflowTaskMock).toHaveBeenCalledWith( + 'wf-abcd', + 'delete-history', + ); + expect(getTasksMock).toHaveBeenCalledOnce(); + expect(container.textContent).not.toContain('Saved run · read-only'); + }); +}); + +describe('TasksStatusMessage paused agent controls', () => { + it('keeps the abandon hint distinct from workflow stop', () => { + const container = renderPanel([ + agentTask('paused-agent', { status: 'paused' }), + ]); + + expect(container.textContent).toContain('x abandon'); + expect(container.textContent).not.toContain('x stop'); + }); +}); + describe('TasksStatusMessage nested-agent tree', () => { it('leaves workflow and subagent buttons in control of their keyboard input', async () => { const onOpenSubagent = vi.fn(); diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx index d99f28d35f1..e108a9a6fb6 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx @@ -24,6 +24,7 @@ import { formatContextTokens } from '../../utils/formatTokenCount'; import { createSentinelSerializer } from '../../utils/sentinelMessage'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { PlanExecutionView } from './PlanExecutionView'; +import { WorkflowExecutionView } from './WorkflowExecutionView'; import { localizeAgentTypeName, localizeToolDisplayName, @@ -44,6 +45,8 @@ export interface SerializedTasksMessage { snapshot: DaemonSessionTasksStatus; } +export type TasksStatusView = 'all' | 'workflow-active' | 'workflow-history'; + const { serialize: serializeTasksStatusMessage, parse: parseRawTasksStatusMessage, @@ -65,14 +68,18 @@ type TasksPanelStep = 'list' | 'detail'; type TaskStatus = DaemonSessionTaskStatus['status']; -function dispatchActive(id: string, active: boolean): void { +function dispatchActive(id: string, sessionId: string, active: boolean): void { window.dispatchEvent( - new CustomEvent(ACTIVE_EVENT, { detail: { id, active } }), + new CustomEvent(ACTIVE_EVENT, { detail: { id, sessionId, active } }), ); } function isActive(task: DaemonSessionTaskStatus): boolean { - return task.status === 'running' || task.status === 'paused'; + return ( + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused' + ); } function sortTasks( @@ -91,7 +98,7 @@ function sortTasks( * Display order for the panel: active-first sort, then each nested agent * grouped under its parent as a tree. The reorder is a post-pass so a tree * spanning the active/terminal buckets stays contiguous at whichever - * position its root earned. Every `setTasks` site must use this (not bare + * position its root earned. Every visible task list must use this (not bare * `sortTasks`) — selection is index-based, so list order IS the contract. */ function arrangeTasks( @@ -100,12 +107,63 @@ function arrangeTasks( return reorderChildrenUnderParents(sortTasks(tasks)); } +function tasksForView( + tasks: DaemonSessionTaskStatus[], + view: TasksStatusView, +): DaemonSessionTaskStatus[] { + if (view === 'all') return arrangeTasks(tasks); + return arrangeTasks( + tasks.filter( + (task) => + task.kind === 'workflow' && + (view === 'workflow-active' ? isActive(task) : !isActive(task)), + ), + ); +} + +function findWorkflowSourceTask( + task: DaemonSessionTaskStatus, + tasks: DaemonSessionTaskStatus[], +): Extract | undefined { + if ( + task.kind !== 'workflow' || + !task.sourceRunId || + task.sourceRunId === task.id + ) { + return undefined; + } + const source = tasks.find( + (candidate) => + candidate.kind === 'workflow' && candidate.id === task.sourceRunId, + ); + return source?.kind === 'workflow' ? source : undefined; +} + +function findWorkflowHistoryTasks( + task: DaemonSessionTaskStatus, + tasks: DaemonSessionTaskStatus[], +): Array> { + if (task.kind !== 'workflow') return []; + return tasks + .filter( + ( + candidate, + ): candidate is Extract => + candidate.kind === 'workflow' && + candidate.id !== task.id && + candidate.label === task.label, + ) + .sort((a, b) => b.startTime - a.startTime); +} + function statusClassName(status: TaskStatus): string { switch (status) { case 'running': return styles.success; case 'paused': return styles.warning; + case 'pausing': + return styles.warning; case 'completed': return styles.success; case 'failed': @@ -132,6 +190,8 @@ function statusLabel( return t('tasks.cancelled'); case 'paused': return t('tasks.paused'); + case 'pausing': + return t('tasks.pausing'); default: return status; } @@ -141,6 +201,8 @@ function terminalStatusIcon(status: TaskStatus): string | null { switch (status) { case 'paused': return '⏸'; + case 'pausing': + return '⏸'; case 'completed': return '✓'; case 'failed': @@ -171,7 +233,11 @@ function ChevronIcon({ expanded }: { expanded: boolean }) { ); } -function rowLabel(task: DaemonSessionTaskStatus, blocking: boolean): string { +function rowLabel( + task: DaemonSessionTaskStatus, + blocking: boolean, + workflowOnly = false, +): string { switch (task.kind) { case 'agent': // `blocking` comes from computeUserBlockingIds — an agent is tagged @@ -184,6 +250,8 @@ function rowLabel(task: DaemonSessionTaskStatus, blocking: boolean): string { return `[shell] ${task.command}`; case 'monitor': return `[monitor] ${task.description}`; + case 'workflow': + return workflowOnly ? task.label : `[workflow] ${task.label}`; } } @@ -243,6 +311,12 @@ export function TasksStatusMessage({ message, embedded = false, manageActiveEvent = true, + keyboardShortcuts = true, + syncSnapshot = false, + taskView = 'all', + emptyLabel, + onWorkflowRunStarted, + onTasksChange, onClose, planTodos = [], agentTools = [], @@ -252,6 +326,12 @@ export function TasksStatusMessage({ message: SerializedTasksMessage; embedded?: boolean; manageActiveEvent?: boolean; + keyboardShortcuts?: boolean; + syncSnapshot?: boolean; + taskView?: TasksStatusView; + emptyLabel?: string; + onWorkflowRunStarted?: () => void; + onTasksChange?: (snapshot: DaemonSessionTasksStatus) => void; onClose?: () => void; planTodos?: readonly TodoItem[]; agentTools?: readonly ACPToolCall[]; @@ -260,26 +340,45 @@ export function TasksStatusMessage({ }) { const { t } = useI18n(); const actions = useActions(); - const [tasks, setTasks] = useState(() => - arrangeTasks(message.snapshot.tasks), + const [allTasks, setAllTasks] = useState(message.snapshot.tasks); + const tasks = useMemo( + () => tasksForView(allTasks, taskView), + [allTasks, taskView], ); const [isOpen, setIsOpen] = useState(true); const [step, setStep] = useState('list'); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedTaskId, setSelectedTaskId] = useState( + () => tasksForView(message.snapshot.tasks, taskView)[0]?.id ?? null, + ); const [pendingCancelId, setPendingCancelId] = useState(null); const [busy, setBusy] = useState(false); const [refreshError, setRefreshError] = useState(false); const [actionError, setActionError] = useState(null); const panelIdRef = useRef(`tasks-${Math.random().toString(36).slice(2)}`); const refreshInFlightRef = useRef(false); + const expectedSessionIdRef = useRef(message.snapshot.sessionId); + expectedSessionIdRef.current = message.snapshot.sessionId; const initialDetailStatusRef = useRef<{ taskId: string; status: TaskStatus; } | null>(null); - const clampedSelectedIndex = - tasks.length === 0 ? 0 : Math.min(selectedIndex, tasks.length - 1); - const selectedTask = tasks[clampedSelectedIndex] ?? null; + useEffect(() => { + if (syncSnapshot) setAllTasks(message.snapshot.tasks); + }, [message.snapshot, syncSnapshot]); + + useEffect(() => { + setBusy(false); + setActionError(null); + setPendingCancelId(null); + setStep('list'); + }, [message.snapshot.sessionId]); + + const selectedIndex = selectedTaskId + ? tasks.findIndex((task) => task.id === selectedTaskId) + : -1; + const clampedSelectedIndex = selectedIndex >= 0 ? selectedIndex : 0; + const selectedTask = selectedIndex >= 0 ? tasks[selectedIndex] : null; // Tree metadata is computed on the full task list (not the windowed // slice) so a row's indent doesn't shift when the window scrolls past @@ -292,13 +391,22 @@ export function TasksStatusMessage({ const refresh = () => { if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; + const requestedSessionId = expectedSessionIdRef.current; actions .getTasks() .then((snapshot) => { - setTasks(arrangeTasks(snapshot.tasks)); + if ( + expectedSessionIdRef.current !== requestedSessionId || + snapshot.sessionId !== requestedSessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); setRefreshError(false); }) .catch((error: unknown) => { + if (expectedSessionIdRef.current !== requestedSessionId) return; if (isSessionDisconnectedError(error)) { setRefreshError(false); return; @@ -312,16 +420,14 @@ export function TasksStatusMessage({ }; const id = setInterval(refresh, REFRESH_INTERVAL_MS); return () => clearInterval(id); - }, [isOpen, actions]); + }, [isOpen, actions, onTasksChange]); useEffect(() => { - if (tasks.length === 0 && selectedIndex !== 0) { - setSelectedIndex(0); - } - if (selectedIndex >= tasks.length && tasks.length > 0) { - setSelectedIndex(tasks.length - 1); - } - }, [tasks.length, selectedIndex]); + if (selectedIndex >= 0) return; + setPendingCancelId(null); + if (step === 'detail') setStep('list'); + setSelectedTaskId(tasks[0]?.id ?? null); + }, [selectedIndex, step, tasks]); useEffect(() => { if (!isOpen || step !== 'detail') { @@ -344,7 +450,12 @@ export function TasksStatusMessage({ return; } - if (initial.status === 'running' && selectedTask.status !== 'running') { + if ( + (initial.status === 'running' || + initial.status === 'pausing' || + initial.status === 'paused') && + !isActive(selectedTask) + ) { setPendingCancelId(null); setStep('list'); } @@ -353,9 +464,10 @@ export function TasksStatusMessage({ useEffect(() => { if (!manageActiveEvent) return undefined; const id = panelIdRef.current; - dispatchActive(id, isOpen); - return () => dispatchActive(id, false); - }, [isOpen, manageActiveEvent]); + const sessionId = message.snapshot.sessionId; + dispatchActive(id, sessionId, isOpen); + return () => dispatchActive(id, sessionId, false); + }, [isOpen, manageActiveEvent, message.snapshot.sessionId]); useEffect(() => { if (!manageActiveEvent) return undefined; @@ -377,9 +489,11 @@ export function TasksStatusMessage({ const handleCancel = useCallback( async (task: DaemonSessionTaskStatus) => { if (busy) return; + const sessionId = expectedSessionIdRef.current; const isRunning = task.status === 'running'; const isAbandonable = task.kind === 'agent' && task.status === 'paused'; - if (!isRunning && !isAbandonable) return; + const isActiveWorkflow = task.kind === 'workflow' && isActive(task); + if (!isRunning && !isAbandonable && !isActiveWorkflow) return; // Two-step confirm only when cancelling would end the USER's turn — // the same chain-aware verdict as the `[blocking]` row prefix. A // foreground child awaited by a *background* parent unblocks that @@ -395,26 +509,116 @@ export function TasksStatusMessage({ setBusy(true); try { const result = await actions.cancelTask(task.id, task.kind); + if (expectedSessionIdRef.current !== sessionId) return; if (!result.cancelled) { setActionError(t('tasks.alreadyStopped')); return; } const snapshot = await actions.getTasks(); - setTasks(arrangeTasks(snapshot.tasks)); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (taskView === 'workflow-active') setStep('list'); setActionError(null); } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; console.warn('[web-shell] failed to cancel task:', error); setActionError(t('tasks.cancelFailed')); } finally { - setBusy(false); + if (expectedSessionIdRef.current === sessionId) setBusy(false); } }, - [actions, busy, blockingIds, pendingCancelId, t], + [actions, busy, blockingIds, onTasksChange, pendingCancelId, t, taskView], + ); + + const handleWorkflowAction = useCallback( + async ( + task: Extract, + action: 'pause' | 'resume' | 'retry' | 'rerun', + ) => { + if (busy) return; + const sessionId = expectedSessionIdRef.current; + setBusy(true); + try { + const result = await actions.controlWorkflowTask(task.id, action); + if (expectedSessionIdRef.current !== sessionId) return; + if (!result.changed) { + setActionError(t('workflow.action.unavailable')); + return; + } + const snapshot = await actions.getTasks(); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + const nextTasks = tasksForView(snapshot.tasks, taskView); + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (result.taskId) { + onWorkflowRunStarted?.(); + if (nextTasks.some((candidate) => candidate.id === result.taskId)) { + setSelectedTaskId(result.taskId); + } + } + setActionError(null); + } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; + console.warn('[web-shell] failed to control workflow:', error); + setActionError(t('workflow.action.failed')); + } finally { + if (expectedSessionIdRef.current === sessionId) setBusy(false); + } + }, + [actions, busy, onTasksChange, onWorkflowRunStarted, t, taskView], + ); + + const handleWorkflowHistoryDelete = useCallback( + async (runId: string) => { + if (busy) return; + const sessionId = expectedSessionIdRef.current; + setBusy(true); + try { + const result = await actions.controlWorkflowTask( + runId, + 'delete-history', + ); + if (expectedSessionIdRef.current !== sessionId) return; + if (!result.changed) { + setActionError(t('workflow.history.deleteUnavailable')); + return; + } + const snapshot = await actions.getTasks(); + if ( + expectedSessionIdRef.current !== sessionId || + snapshot.sessionId !== sessionId + ) { + return; + } + setAllTasks(snapshot.tasks); + onTasksChange?.(snapshot); + if (selectedTask?.id === runId) setStep('list'); + setActionError(null); + } catch (error: unknown) { + if (expectedSessionIdRef.current !== sessionId) return; + console.warn('[web-shell] failed to delete workflow history:', error); + setActionError(t('workflow.history.deleteFailed')); + } finally { + if (expectedSessionIdRef.current === sessionId) setBusy(false); + } + }, + [actions, busy, onTasksChange, selectedTask?.id, t], ); useDelayedGlobalKeyDown( (event: KeyboardEvent) => { - if (!isOpen) return; + if (!keyboardShortcuts || !isOpen) return; if ( event.key !== 'Escape' && @@ -459,9 +663,11 @@ export function TasksStatusMessage({ event.stopPropagation(); if (tasks.length === 0) return; const delta = event.key === 'ArrowUp' ? -1 : 1; - setSelectedIndex((current) => - Math.min(Math.max(current + delta, 0), tasks.length - 1), + const nextIndex = Math.min( + Math.max(clampedSelectedIndex + delta, 0), + tasks.length - 1, ); + setSelectedTaskId(tasks[nextIndex]?.id ?? null); setPendingCancelId(null); return; } @@ -499,9 +705,11 @@ export function TasksStatusMessage({ }, [ embedded, + keyboardShortcuts, isOpen, step, tasks.length, + clampedSelectedIndex, selectedTask, handleCancel, onOpenMonitor, @@ -523,13 +731,10 @@ export function TasksStatusMessage({ } else { listHints.push(t('tasks.shortcut.select')); listHints.push(t('tasks.shortcut.view')); - if (selectedTask?.status === 'running') { - listHints.push(t('tasks.shortcut.stop')); - } else if ( - selectedTask?.kind === 'agent' && - selectedTask?.status === 'paused' - ) { + if (selectedTask?.kind === 'agent' && selectedTask?.status === 'paused') { listHints.push(t('tasks.shortcut.abandon')); + } else if (selectedTask && isActive(selectedTask)) { + listHints.push(t('tasks.shortcut.stop')); } listHints.push(t('tasks.shortcut.listClose')); } @@ -541,13 +746,10 @@ export function TasksStatusMessage({ } else { detailHints.push(t('tasks.shortcut.detailBack')); detailHints.push(t('tasks.shortcut.detailClose')); - if (selectedTask?.status === 'running') { - detailHints.push(t('tasks.shortcut.stop')); - } else if ( - selectedTask?.kind === 'agent' && - selectedTask?.status === 'paused' - ) { + if (selectedTask?.kind === 'agent' && selectedTask?.status === 'paused') { detailHints.push(t('tasks.shortcut.abandon')); + } else if (selectedTask && isActive(selectedTask)) { + detailHints.push(t('tasks.shortcut.stop')); } } @@ -577,7 +779,9 @@ export function TasksStatusMessage({ onOpenSubagent={onOpenSubagent} />
-
{t('tasks.empty')}
+
+ {emptyLabel ?? t('tasks.empty')} +
{!embedded && (
{t('tasks.shortcut.close')}
@@ -658,6 +862,14 @@ export function TasksStatusMessage({ ? t('tasks.row.from', { parent: task.parentName }) : t('tasks.row.nested') : null; + const activateTask = () => { + setSelectedTaskId(task.id); + if (embedded && task.kind === 'monitor' && onOpenMonitor) { + onOpenMonitor(task); + } else { + setStep(embedded && expanded ? 'list' : 'detail'); + } + }; return (
{ - setSelectedIndex(index); - if (embedded && task.kind === 'monitor' && onOpenMonitor) { - onOpenMonitor(task); - } else { - setStep(embedded && expanded ? 'list' : 'detail'); - } + role="button" + tabIndex={0} + aria-expanded={ + embedded && !(task.kind === 'monitor' && onOpenMonitor) + ? expanded + : undefined + } + onClick={activateTask} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + activateTask(); }} + onFocus={() => setSelectedTaskId(task.id)} onMouseEnter={() => { - if (!embedded) setSelectedIndex(index); + if (!embedded) setSelectedTaskId(task.id); }} > @@ -702,7 +920,11 @@ export function TasksStatusMessage({ {'↳ '} )} - {rowLabel(task, blockingIds.has(task.id))} + {rowLabel( + task, + blockingIds.has(task.id), + taskView !== 'all', + )} {orphanNote && ( {' · '} @@ -718,7 +940,7 @@ export function TasksStatusMessage({
{expanded && ( -
+
void handleCancel(task)} + sourceWorkflowTask={findWorkflowSourceTask( + task, + allTasks, + )} + workflowHistoryTasks={findWorkflowHistoryTasks( + task, + allTasks, + )} + onWorkflowAction={(action) => + task.kind === 'workflow' + ? void handleWorkflowAction(task, action) + : undefined + } + onDeleteWorkflowHistory={(runId) => + void handleWorkflowHistoryDelete(runId) + } onCancelConfirmDismiss={() => setPendingCancelId(null)} />
@@ -750,6 +988,19 @@ export function TasksStatusMessage({ busy={busy} showCancelConfirm={pendingCancelId === selectedTask.id} onCancel={() => void handleCancel(selectedTask)} + sourceWorkflowTask={findWorkflowSourceTask(selectedTask, allTasks)} + workflowHistoryTasks={findWorkflowHistoryTasks( + selectedTask, + allTasks, + )} + onWorkflowAction={(action) => + selectedTask.kind === 'workflow' + ? void handleWorkflowAction(selectedTask, action) + : undefined + } + onDeleteWorkflowHistory={(runId) => + void handleWorkflowHistoryDelete(runId) + } onCancelConfirmDismiss={() => setPendingCancelId(null)} /> @@ -781,6 +1032,8 @@ function detailTitle( return `${t('tasks.kind.shell')} › ${task.command}`; case 'monitor': return `${t('tasks.kind.monitor')} › ${task.description}`; + case 'workflow': + return `${t('tasks.kind.workflow')} › ${task.label}`; } } @@ -1093,6 +1346,10 @@ function TaskDetail({ busy = false, showCancelConfirm = false, onCancel, + sourceWorkflowTask, + workflowHistoryTasks, + onWorkflowAction, + onDeleteWorkflowHistory, onCancelConfirmDismiss, }: { task: DaemonSessionTaskStatus; @@ -1101,12 +1358,35 @@ function TaskDetail({ busy?: boolean; showCancelConfirm?: boolean; onCancel?: () => void; + sourceWorkflowTask?: Extract; + workflowHistoryTasks?: Array< + Extract + >; + onWorkflowAction?: (action: 'pause' | 'resume' | 'retry' | 'rerun') => void; + onDeleteWorkflowHistory?: (runId: string) => void; onCancelConfirmDismiss?: () => void; }) { const terminalIcon = terminalStatusIcon(task.status); const stClass = statusClassName(task.status); const isAbandonable = task.kind === 'agent' && task.status === 'paused'; - const canCancel = task.status === 'running' || isAbandonable; + const canCancel = + task.status === 'running' || + isAbandonable || + (task.kind === 'workflow' && + (task.status === 'pausing' || task.status === 'paused')); + const canPause = + task.kind === 'workflow' && + task.isBackgrounded && + task.status === 'running'; + const canResume = task.kind === 'workflow' && task.status === 'paused'; + const canRetry = + task.kind === 'workflow' && !task.isHistorical && task.status === 'failed'; + const canRerun = + task.kind === 'workflow' && + !task.isHistorical && + (task.status === 'completed' || + task.status === 'failed' || + task.status === 'cancelled'); const cancelLabel = isAbandonable ? t('tasks.action.abandon') : t('tasks.action.stop'); @@ -1139,6 +1419,18 @@ function TaskDetail({ }); } + if (task.kind === 'workflow' && task.tokensSpent > 0) { + subtitleParts.push( + t('tasks.detail.tokens', { + count: formatContextTokens(task.tokensSpent), + }), + ); + compactFields.push({ + label: t('tasks.detail.tokenCount'), + value: formatContextTokens(task.tokensSpent), + }); + } + if (task.kind === 'agent' && task.stats?.toolUses !== undefined) { subtitleParts.push( t('tasks.detail.toolCalls', { @@ -1151,7 +1443,10 @@ function TaskDetail({ }); } - if (task.kind !== 'agent' && task.pid !== undefined) { + if ( + (task.kind === 'shell' || task.kind === 'monitor') && + task.pid !== undefined + ) { subtitleParts.push(`pid ${task.pid}`); } @@ -1174,8 +1469,9 @@ function TaskDetail({ const promptLines = task.kind === 'agent' && task.prompt ? task.prompt.split('\n') : []; const actionControls = - canCancel && onCancel ? ( -
+ (canCancel && onCancel) || + ((canPause || canResume || canRetry || canRerun) && onWorkflowAction) ? ( +
{showCancelConfirm ? ( <> @@ -1198,14 +1494,50 @@ function TaskDetail({ ) : ( - + <> + {(canPause || canResume) && onWorkflowAction && ( + + )} + {canRetry && onWorkflowAction && ( + + )} + {canRerun && onWorkflowAction && ( + + )} + {canCancel && onCancel && ( + + )} + )}
) : null; @@ -1224,7 +1556,7 @@ function TaskDetail({ {subtitleParts.join(' · ')}
- ) : compactFields.length > 0 ? ( + ) : task.kind === 'workflow' ? null : compactFields.length > 0 ? (
{compactFields .map((field) => `${field.label} ${field.value}`) @@ -1349,6 +1681,16 @@ function TaskDetail({
)} + {task.kind === 'workflow' && ( + + )} + {task.error && (
{ const { createContext } = await import('react'); @@ -522,6 +527,18 @@ describe('tool output session links', () => { }); describe('tool expandability', () => { + it('does not mistake workflow live output for a subagent panel', () => { + expect( + isSubAgentToolCall( + makeTool({ + toolName: 'workflow', + status: 'in_progress', + subContent: '{"runId":"wf_expected"}', + }), + ), + ).toBe(false); + }); + it('only marks tools with actual detail views as expandable by output', () => { expect( hasExpandableContent( @@ -611,6 +628,83 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it('expands a workflow tool into its live execution graph', () => { + const tool = makeTool({ + toolName: 'workflow', + status: 'in_progress', + subContent: '```json\n{"runId":"wf_channel","status":"running"}\n```', + }); + const task: DaemonSessionWorkflowTaskStatus = { + kind: 'workflow', + id: 'wf_channel', + label: 'Channel analysis', + description: 'Analyze channel packages', + status: 'running', + startTime: 1_000, + runtimeMs: 200, + isBackgrounded: false, + currentPhase: 'Inspect', + phaseVisits: [ + { + id: 'phase-inspect', + index: 0, + title: 'Inspect', + startedAt: 1_010, + }, + ], + dispatches: [ + { + id: 'dispatch-architecture', + phaseVisitId: 'phase-inspect', + label: 'Architecture Agent', + prompt: 'Inspect the channel architecture', + status: 'running', + dependsOn: [], + queuedAt: 1_020, + startedAt: 1_030, + }, + ], + agentsDispatched: 1, + agentsCompleted: 0, + tokensSpent: 120, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + + + , + ); + }); + mounted.push({ root, container }); + + const summary = container.querySelector('button') as HTMLButtonElement; + const content = container.querySelector( + '[class*="chatSummaryContentClip"]', + ) as HTMLElement; + expect(summary.getAttribute('aria-expanded')).toBe('false'); + expect(content.className).toContain('chatSummaryContentCollapsed'); + expect(content.getAttribute('aria-hidden')).toBe('true'); + expect(content.hasAttribute('inert')).toBe(true); + expect(container.querySelector('[data-workflow-summary]')).toBeNull(); + + act(() => summary.click()); + + expect(summary.getAttribute('aria-expanded')).toBe('true'); + expect(content.className).not.toContain('chatSummaryContentCollapsed'); + expect(content.getAttribute('aria-hidden')).toBe('false'); + expect(content.hasAttribute('inert')).toBe(false); + expect(container.querySelector('[data-workflow-summary]')).not.toBeNull(); + expect(container.textContent).toContain('Architecture Agent'); + }); + it('renders the aggregate summary for a multi-tool group', () => { const container = renderToolGroup([ makeTool({ diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 8fd2c45736a..c756daa8b27 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -30,7 +30,10 @@ import { import { useSharedNow } from '../../hooks/useSharedNow'; import { useSubagentDetails } from '../../subagentDetailsContext'; import { useMonitorDetails } from '../../monitorDetailsContext'; +import { useWorkflowDetails } from '../../workflowDetailsContext'; +import { findWorkflowTaskForTool } from '../../utils/workflowTasks'; import { TodoEventSummary, TodoFullList } from './TodoView'; +import { WorkflowExecutionView } from './WorkflowExecutionView'; import { Markdown } from './Markdown'; import { formatDurationMs, @@ -101,6 +104,7 @@ function openMonitorDetailsOnce( export function hasExpandableContent(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool); + if (name === 'workflow') return true; // write_file shows content from args even before completion if (name === 'write_file' || name === 'writefile') { return !!getWriteContent(tool) || hasEditContent(tool); @@ -141,7 +145,8 @@ function hasDetailView(tool: ACPToolCall): boolean { name === 'read_file' || name === 'readfile' || isSkillToolName(name) || - isAskUserQuestionToolName(tool.toolName) + isAskUserQuestionToolName(tool.toolName) || + name === 'workflow' ); } @@ -416,10 +421,40 @@ interface ToolLineProps { summaryOnly?: boolean; forceExpanded?: boolean; forceExpandable?: boolean; + detailsVisible?: boolean; hideHeader?: boolean; hideCollapsedOutput?: boolean; } +function WorkflowToolDetail({ + tool, + displayName, + detail, + result, +}: { + tool: ACPToolCall; + displayName: string; + detail: string; + result: string; +}) { + const { t } = useI18n(); + const workflowDetails = useWorkflowDetails(); + const workflowTask = workflowDetails + ? findWorkflowTaskForTool(workflowDetails.tasks, tool) + : undefined; + return workflowTask ? ( + + ) : ( + +
+ {tool.status === 'pending' || tool.status === 'in_progress' + ? t('workflow.inline.loading') + : result || t('workflow.inline.unavailable')} +
+
+ ); +} + function getAgentDisplayInfo( tool: ACPToolCall, now?: number, @@ -973,6 +1008,7 @@ function areToolLinePropsEqual( if (prev.summaryOnly !== next.summaryOnly) return false; if (prev.forceExpanded !== next.forceExpanded) return false; if (prev.forceExpandable !== next.forceExpandable) return false; + if (prev.detailsVisible !== next.detailsVisible) return false; if (prev.hideHeader !== next.hideHeader) return false; if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false; const a = prev.tool; @@ -1073,6 +1109,7 @@ export const ToolLine = memo(function ToolLine({ summaryOnly = false, forceExpanded = false, forceExpandable = false, + detailsVisible = true, hideHeader = false, hideCollapsedOutput = false, }: ToolLineProps) { @@ -1090,6 +1127,7 @@ export const ToolLine = memo(function ToolLine({ // Set once the user explicitly toggles this row, so auto-collapse-on- // completion never silently overrides their choice. const userToggledRef = useRef(false); + const isWorkflow = tool.toolName.toLowerCase() === 'workflow'; useEffect( () => { @@ -1439,15 +1477,24 @@ export const ToolLine = memo(function ToolLine({ {renderWithSessionLinks(result, transcriptRenderMode)}
)} - {!isTodo && expanded && detailView && ( + {!isTodo && expanded && detailView && (!isWorkflow || detailsVisible) && (
- {isRead ? ( + {isWorkflow ? ( + + ) : isRead ? ( ) : ( @@ -1588,6 +1635,8 @@ export const ToolGroup = memo(function ToolGroup({ />
))} diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css b/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css new file mode 100644 index 00000000000..35252f5bd7f --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.module.css @@ -0,0 +1,829 @@ +.root { + container-type: inline-size; + display: grid; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--background); +} + +.historyBar { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 10px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 24%, var(--background)); + color: var(--muted-foreground); + font-size: 10px; +} + +.historyLead { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; +} + +.historyActions { + display: flex; + flex: 0 0 auto; + gap: 6px; +} + +.historyLead > span:not(.historyMark, .cachedBadge) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cachedBadge { + flex: 0 0 auto; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--success-color) 32%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--success-color) 8%, transparent); + color: var(--success-color); + font-family: var(--font-mono); + font-size: 9px; +} + +.compareButton { + flex: 0 0 auto; + padding: 3px 7px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; +} + +.compareButton:hover { + border-color: var(--border); + background: var(--background); + color: var(--foreground); +} + +.compareButton:disabled { + cursor: default; + opacity: 0.45; +} + +.compareButton:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + +.historyLedger { + display: grid; + max-height: 214px; + overflow-y: auto; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 18%, var(--background)); +} + +.historyTools { + position: sticky; + z-index: 2; + top: 0; + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: color-mix(in srgb, var(--muted) 76%, var(--background)); + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 9px; +} + +.historyTools label { + display: flex; + align-items: center; + gap: 6px; +} + +.historyTools select { + min-width: 92px; + padding: 3px 20px 3px 6px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--background); + color: var(--foreground); + font: inherit; +} + +.historyTools select:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: 2px; +} + +.historyTools > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyEmpty { + padding: 14px 10px; + color: var(--muted-foreground); + font-size: 10px; +} + +.historyRun { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); +} + +.historyRun:last-child { + border-bottom: 0; +} + +.historyRun:hover, +.historyRun[data-selected='true'] { + background: color-mix(in srgb, var(--primary) 7%, transparent); +} + +.historyRunSelect { + display: grid; + min-width: 0; + grid-template-columns: minmax(148px, 1fr) 62px 46px 42px 54px; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font-family: var(--font-mono); + font-size: 9px; + text-align: left; +} + +.historyRunSelect:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 55%, transparent); + outline-offset: -2px; +} + +.historyDeleteButton, +.confirmDeleteButton { + align-self: center; + margin-right: 8px; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font-family: var(--font-mono); + font-size: 8px; + white-space: nowrap; +} + +.historyDeleteButton:hover { + border-color: var(--error-color); + color: var(--error-color); +} + +.confirmDeleteButton { + border-color: var(--error-color); + background: color-mix(in srgb, var(--error-color) 12%, transparent); + color: var(--error-color); +} + +.confirmDeleteButton:disabled, +.historyDeleteButton:disabled { + cursor: default; + opacity: 0.45; +} + +.historyDeleteButton:focus-visible, +.confirmDeleteButton:focus-visible { + outline: 2px solid color-mix(in srgb, var(--error-color) 52%, transparent); + outline-offset: 2px; +} + +.historyRunIdentity { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; +} + +.historyRunIdentity code { + overflow: hidden; + color: var(--foreground); + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyRunIdentity small { + flex: 0 0 auto; + color: var(--muted-foreground); + font-size: 8px; +} + +.historyRunStatus { + color: var(--foreground); +} + +.historyRun[data-status='running'] .historyRunStatus { + color: var(--agent-blue-500); +} + +.historyRun[data-status='completed'] .historyRunStatus { + color: var(--success-color); +} + +.historyRun[data-status='failed'] .historyRunStatus { + color: var(--error-color); +} + +.historyRun[data-status='paused'] .historyRunStatus, +.historyRun[data-status='pausing'] .historyRunStatus, +.historyRun[data-status='cancelled'] .historyRunStatus { + color: var(--warning-color); +} + +.comparison { + display: grid; + min-width: 0; + grid-template-columns: minmax(66px, 0.6fr) repeat(2, minmax(88px, 1fr)); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 24%, var(--background)); + font-size: 9px; +} + +.comparison > * { + min-width: 0; + padding: 6px 8px; + border-right: 1px solid var(--border); + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, transparent); +} + +.comparison > :nth-child(3n) { + border-right: 0; +} + +.comparison > :nth-last-child(-n + 3) { + border-bottom: 0; +} + +.comparisonRun { + display: flex; + flex-direction: column; + gap: 2px; +} + +.comparisonRun strong { + color: var(--foreground); + font-weight: 600; +} + +.comparisonRun code { + overflow: hidden; + color: var(--muted-foreground); + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.comparisonMetric { + color: var(--muted-foreground); +} + +.comparisonValue { + color: var(--foreground); + font-family: var(--font-mono); +} + +.comparisonValue[data-status='running'] { + color: var(--agent-blue-500); +} + +.comparisonValue[data-status='completed'] { + color: var(--success-color); +} + +.comparisonValue[data-status='failed'] { + color: var(--error-color); +} + +.comparisonValue[data-status='paused'], +.comparisonValue[data-status='pausing'], +.comparisonValue[data-status='cancelled'] { + color: var(--warning-color); +} + +.summary { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 5px 14px; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 16%, var(--background)); + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 10px; +} + +.summary strong { + color: var(--foreground); + font-weight: 600; +} + +.graphOmission { + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--warning-color) 7%, var(--background)); + color: var(--muted-foreground); + font-size: 10px; +} + +.approvalMetric, +.approvalMetric strong { + color: var(--warning-color); +} + +.workbench { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) 224px; +} + +.viewport { + min-width: 0; + min-height: 224px; + overflow: auto; + background: + radial-gradient( + circle at 66% 42%, + color-mix(in srgb, var(--agent-blue-500) 6%, transparent), + transparent 34% + ), + var(--background); +} + +.canvas { + position: relative; + min-width: 100%; + min-height: 224px; + background-image: radial-gradient( + color-mix(in srgb, var(--muted-foreground) 20%, transparent) 0.7px, + transparent 0.7px + ); + background-size: 18px 18px; +} + +.lane { + position: absolute; + top: 0; + bottom: 0; + border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--muted) 45%, transparent), + transparent 95px + ); +} + +.lane[data-active='true'] { + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--agent-blue-500) 10%, transparent), + transparent 112px + ); +} + +.lane[data-active='true'] .laneHeading strong { + color: var(--agent-blue-400); +} + +.laneHeading { + display: flex; + min-width: 0; + align-items: baseline; + gap: 7px; + padding: 11px 12px 0; +} + +.laneHeading span, +.lane > small { + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 9px; +} + +.laneHeading strong { + overflow: hidden; + color: var(--foreground); + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lane > small { + display: block; + padding: 3px 12px 0 36px; +} + +.edges { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.edges marker path { + fill: var(--muted-foreground); +} + +.edge { + fill: none; + stroke: color-mix(in srgb, var(--muted-foreground) 58%, transparent); + stroke-width: 1.25px; + opacity: 0.78; + transition: + opacity 120ms ease, + stroke-width 120ms ease, + filter 120ms ease; + vector-effect: non-scaling-stroke; +} + +.edge[data-status='running'] { + stroke: var(--agent-blue-500); + stroke-width: 1.7px; + filter: drop-shadow( + 0 0 3px color-mix(in srgb, var(--agent-blue-500) 55%, transparent) + ); +} + +.edge[data-status='queued'] { + stroke-dasharray: 4 5; +} + +.edge[data-status='completed'], +.edge[data-status='cached'] { + stroke: color-mix(in srgb, var(--success-color) 72%, transparent); +} + +.edge[data-status='failed'] { + stroke: var(--error-color); +} + +.edge[data-path-emphasis='related'] { + stroke-width: 2.3px; + opacity: 1; + filter: drop-shadow( + 0 0 3px color-mix(in srgb, currentColor 48%, transparent) + ); +} + +.edge[data-path-emphasis='dimmed'] { + opacity: 0.1; + filter: none; +} + +.node { + --workflow-node-accent: var(--muted-foreground); + position: absolute; + display: grid; + width: var(--workflow-node-width); + height: var(--workflow-node-height); + grid-template-columns: 23px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px 9px; + border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--background) 96%, var(--muted)); + box-shadow: 0 5px 14px color-mix(in srgb, black 14%, transparent); + color: var(--foreground); + cursor: pointer; + text-align: left; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease; +} + +.node:hover, +.node[aria-pressed='true'] { + border-color: var(--workflow-node-accent); +} + +.node[aria-pressed='true'] { + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--workflow-node-accent) 20%, transparent), + 0 10px 24px color-mix(in srgb, black 24%, transparent); +} + +.node[data-status='running'] { + --workflow-node-accent: var(--agent-blue-500); + background: linear-gradient( + 110deg, + color-mix(in srgb, var(--agent-blue-500) 10%, var(--background)), + var(--background) 56% + ); +} + +.node[data-workflow-approval] { + --workflow-node-accent: var(--warning-color); + opacity: 1; + background: linear-gradient( + 110deg, + color-mix(in srgb, var(--warning-color) 12%, var(--background)), + var(--background) 62% + ); +} + +.node[data-status='completed'], +.node[data-status='cached'] { + --workflow-node-accent: var(--success-color); +} + +.node[data-status='failed'] { + --workflow-node-accent: var(--error-color); +} + +.node[data-status='queued'], +.node[data-status='cancelled'] { + opacity: 0.62; +} + +.node[data-path-emphasis='active'] { + border-color: var(--workflow-node-accent); + opacity: 1; + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--workflow-node-accent) 20%, transparent), + 0 10px 24px color-mix(in srgb, black 24%, transparent); +} + +.node[data-path-emphasis='related'] { + opacity: 0.9; +} + +.node[data-path-emphasis='dimmed'] { + opacity: 0.22; + box-shadow: none; +} + +.nodeState { + display: grid; + width: 23px; + height: 23px; + place-items: center; + border: 1px solid + color-mix(in srgb, var(--workflow-node-accent) 65%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--workflow-node-accent) 10%, transparent); + color: var(--workflow-node-accent); + font-size: 10px; + font-weight: 800; +} + +.node[data-status='running'] .nodeState::after { + width: 8px; + height: 8px; + border: 1px solid color-mix(in srgb, var(--agent-blue-500) 35%, transparent); + border-top-color: var(--agent-blue-500); + border-radius: 50%; + animation: workflow-spin 0.8s linear infinite; + content: ''; +} + +.node[data-status='queued'] .nodeState::after { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.nodeCopy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.nodeCopy strong { + overflow: hidden; + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodeCopy small, +.nodeTime { + color: var(--muted-foreground); + font-size: 9px; +} + +.nodeTime { + align-self: end; + font-family: var(--font-mono); +} + +.inspector { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; + padding: 13px; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--muted) 30%, var(--background)); +} + +.inspectorHeading { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; + gap: 4px; + padding-right: 72px; +} + +.inspectorHeading > span { + color: var(--muted-foreground); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inspectorHeading > strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspectorHeading > small { + position: absolute; + top: 19px; + right: 0; + color: var(--muted-foreground); + font-size: 9px; +} + +.inspectorHeading > small[data-status='running'] { + color: var(--agent-blue-500); +} + +.inspectorHeading > small[data-status='completed'], +.inspectorHeading > small[data-status='cached'] { + color: var(--success-color); +} + +.inspectorHeading > small[data-status='failed'] { + color: var(--error-color); +} + +.inspectorPrompt { + max-height: 132px; + margin: 0; + padding-right: 5px; + overflow-y: auto; + overscroll-behavior: contain; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.5; + overflow-wrap: anywhere; + scrollbar-gutter: stable; +} + +.inspector dl { + display: grid; + gap: 1px; + margin: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--border); +} + +.inspector dl > div { + min-width: 0; + padding: 8px; + background: var(--background); +} + +.inspector dt { + color: var(--muted-foreground); + font-size: 9px; +} + +.inspector dd { + margin: 3px 0 0; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 10px; +} + +.dispatchError { + padding: 8px; + border-left: 2px solid var(--error-color); + background: color-mix(in srgb, var(--error-color) 9%, transparent); + color: var(--error-color); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.approvalCallout { + display: flex; + flex-direction: column; + gap: 4px; + padding: 9px; + border-left: 2px solid var(--warning-color); + background: color-mix(in srgb, var(--warning-color) 9%, transparent); + font-size: 10px; + line-height: 1.45; +} + +.approvalCallout strong { + color: var(--warning-color); +} + +.approvalCallout span { + overflow-wrap: anywhere; +} + +.approvalCallout small { + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 9px; +} + +.empty { + padding: 12px; + color: var(--muted-foreground); + font-size: 11px; +} + +@container (max-width: 680px) { + .historyBar { + align-items: flex-start; + flex-wrap: wrap; + } + + .historyActions { + width: 100%; + } + + .historyTools { + grid-template-columns: minmax(0, 1fr) auto; + } + + .historyTools > span { + display: none; + } + + .historyRunSelect { + grid-template-columns: minmax(128px, 1fr) 58px 42px; + } + + .historyRunSelect > :nth-child(4), + .historyRunSelect > :nth-child(5) { + display: none; + } + + .workbench { + grid-template-columns: 1fr; + } + + .inspector { + border-top: 1px solid var(--border); + border-left: 0; + } +} + +@keyframes workflow-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .edge, + .node, + .nodeState::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx b/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx new file mode 100644 index 00000000000..d456cc6fa40 --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.test.tsx @@ -0,0 +1,621 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DaemonSessionWorkflowTaskStatus } from '@qwen-code/sdk/daemon'; +import { I18nProvider } from '../../i18n'; +import { + buildWorkflowGraphLayout, + WORKFLOW_GRAPH_RENDER_LIMITS, + WorkflowExecutionView, +} from './WorkflowExecutionView'; + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +afterEach(() => { + for (const { root, container } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function workflowTask( + overrides: Partial = {}, +): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id: 'wf-1', + label: 'review-and-fix', + description: 'Review and fix', + status: 'running', + startTime: 1_000, + runtimeMs: 2_000, + isBackgrounded: true, + currentPhase: 'Review', + phaseVisits: [ + { + id: 'phase-1', + index: 0, + title: 'Inspect', + startedAt: 1_000, + endedAt: 1_200, + }, + { id: 'phase-2', index: 1, title: 'Review', startedAt: 1_200 }, + ], + dispatches: [ + { + id: 'dispatch-1', + phaseVisitId: 'phase-1', + label: 'Scope mapper', + prompt: 'Inspect repository boundaries', + status: 'completed', + dependsOn: [], + queuedAt: 1_010, + startedAt: 1_020, + endedAt: 1_100, + }, + { + id: 'dispatch-2', + phaseVisitId: 'phase-2', + label: 'Correctness', + prompt: 'Review behavior regressions', + subagentId: 'correctness-agent-1', + status: 'running', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + startedAt: 1_220, + }, + { + id: 'dispatch-3', + phaseVisitId: 'phase-2', + label: 'Architecture', + prompt: 'Review ownership boundaries', + status: 'queued', + dependsOn: ['dispatch-1'], + queuedAt: 1_210, + }, + ], + agentsDispatched: 3, + agentsCompleted: 1, + tokensSpent: 1_200, + tokenBudgetTotal: 8_000, + recentLogs: [], + pendingApprovalCount: 0, + pendingApprovals: [], + ...overrides, + }; +} + +describe('WorkflowExecutionView', () => { + it('shows a saved run as its final graph without replay controls', () => { + const task = workflowTask({ + isHistorical: true, + status: 'completed', + endTime: 3_000, + agentsCompleted: 3, + dispatches: workflowTask().dispatches.map((dispatch) => ({ + ...dispatch, + status: 'completed' as const, + endedAt: dispatch.endedAt ?? 2_000, + })), + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('[data-run-replay]')).toBeNull(); + expect(container.querySelector('input[type="range"]')).toBeNull(); + expect(container.textContent).not.toContain('Run replay'); + expect( + container.querySelector('[data-workflow-summary]')?.textContent, + ).toContain('3/3 agents'); + expect( + container.querySelector('[data-workflow-edge="dispatch-1:dispatch-2"]'), + ).not.toBeNull(); + }); + + it('builds edges only from recorded dispatch dependencies', () => { + const task = workflowTask(); + task.dispatches[2]!.dependsOn = ['dispatch-1', 'missing']; + + const layout = buildWorkflowGraphLayout(task); + + expect(layout.lanes.map((lane) => lane.title)).toEqual([ + 'Inspect', + 'Review', + ]); + expect(layout.edges.map(({ from, to }) => [from, to])).toEqual([ + ['dispatch-1', 'dispatch-2'], + ['dispatch-1', 'dispatch-3'], + ]); + }); + + it('keeps a large workflow graph bounded and reports omitted content', () => { + const phaseVisits = Array.from({ length: 80 }, (_, index) => ({ + id: `large-phase-${index}`, + index, + title: `Phase ${index}`, + startedAt: 1_000 + index, + endedAt: 2_000 + index, + })); + const dispatches = Array.from({ length: 300 }, (_, index) => ({ + id: `large-dispatch-${index}`, + phaseVisitId: `large-phase-${index % phaseVisits.length}`, + label: `Agent ${index}`, + prompt: `Prompt ${index}`, + status: 'completed' as const, + dependsOn: Array.from( + { length: index }, + (_, dependencyIndex) => `large-dispatch-${dependencyIndex}`, + ), + queuedAt: 1_000 + index, + startedAt: 1_100 + index, + endedAt: 1_200 + index, + })); + const task = workflowTask({ + phaseVisits, + dispatches, + agentsDispatched: dispatches.length, + agentsCompleted: dispatches.length, + }); + + const layout = buildWorkflowGraphLayout(task); + + expect(layout.lanes).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.lanes); + expect(layout.nodes).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.nodes); + expect(layout.edges).toHaveLength(WORKFLOW_GRAPH_RENDER_LIMITS.edges); + expect(layout.omittedLanes).toBe(16); + expect(layout.omittedNodes).toBe(60); + expect(layout.omittedEdges).toBeGreaterThan(0); + expect(layout.dispatchCountByLaneId.get('large-phase-0')).toBe(4); + expect(layout.dispatchStatusById.get('large-dispatch-299')).toBe( + 'completed', + ); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelectorAll('[data-workflow-lane]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.lanes, + ); + expect(container.querySelectorAll('[data-workflow-dispatch]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.nodes, + ); + expect(container.querySelectorAll('[data-workflow-edge]')).toHaveLength( + WORKFLOW_GRAPH_RENDER_LIMITS.edges, + ); + const omission = container.querySelector('[data-workflow-graph-omission]'); + expect(omission?.textContent).toContain('16 phases'); + expect(omission?.textContent).toContain('60 agents'); + expect(omission?.textContent).toContain( + `${layout.omittedEdges} connections`, + ); + }); + + it('shows the selected dispatch prompt when a node is chosen', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + const architecture = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Architecture')); + expect(architecture).toBeDefined(); + act(() => architecture!.click()); + + expect(container.textContent).toContain('Review ownership boundaries'); + expect( + container.querySelector('[data-selected-dispatch="dispatch-3"]'), + ).not.toBeNull(); + expect(container.querySelectorAll('[data-workflow-edge]')).toHaveLength(2); + expect( + container.querySelector('[data-active="true"] strong')?.textContent, + ).toBe('Review'); + expect(container.querySelector('[data-workflow-prompt]')).not.toBeNull(); + }); + + it('focuses direct graph connections on hover and keyboard focus', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + + const correctness = container.querySelector( + '[data-workflow-dispatch="dispatch-2"]', + )!; + const architecture = container.querySelector( + '[data-workflow-dispatch="dispatch-3"]', + )!; + const correctnessEdge = container.querySelector( + '[data-workflow-edge="dispatch-1:dispatch-2"]', + )!; + const architectureEdge = container.querySelector( + '[data-workflow-edge="dispatch-1:dispatch-3"]', + )!; + + act(() => { + correctness.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + expect(correctness.getAttribute('data-path-emphasis')).toBe('active'); + expect( + container + .querySelector('[data-workflow-dispatch="dispatch-1"]') + ?.getAttribute('data-path-emphasis'), + ).toBe('related'); + expect(architecture.getAttribute('data-path-emphasis')).toBe('dimmed'); + expect(correctnessEdge.getAttribute('data-path-emphasis')).toBe('related'); + expect(architectureEdge.getAttribute('data-path-emphasis')).toBe('dimmed'); + + act(() => { + correctness.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); + }); + expect(correctnessEdge.hasAttribute('data-path-emphasis')).toBe(false); + expect(architectureEdge.hasAttribute('data-path-emphasis')).toBe(false); + + act(() => { + architecture.focus(); + }); + expect(architecture.getAttribute('data-path-emphasis')).toBe('active'); + expect(correctnessEdge.getAttribute('data-path-emphasis')).toBe('dimmed'); + expect(architectureEdge.getAttribute('data-path-emphasis')).toBe('related'); + + act(() => architecture.blur()); + expect(correctnessEdge.hasAttribute('data-path-emphasis')).toBe(false); + expect(architectureEdge.hasAttribute('data-path-emphasis')).toBe(false); + }); + + it('locates a pending permission on its dispatch without duplicating approval controls', () => { + const task = workflowTask(); + task.pendingApprovalCount = 1; + task.pendingApprovals = [ + { + approvalId: 'wfap-1', + subagentId: 'correctness-agent-1', + name: 'write_file', + description: 'Update the implementation', + at: 1_300, + }, + ]; + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect( + container.querySelector('[data-workflow-approval="wfap-1"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('Approval needed'); + expect(container.textContent).toContain('Update the implementation'); + expect(container.textContent).toContain('Respond in chat'); + expect(container.querySelectorAll('button')).toHaveLength(3); + }); + + it('shows how many dispatches were restored from a retry journal', () => { + const task = workflowTask({ + sourceRunId: 'wf-1', + startMode: 'retry', + dispatches: [ + { + ...workflowTask().dispatches[0]!, + status: 'cached', + }, + ...workflowTask().dispatches.slice(1), + ], + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain('Retried from wf-1'); + expect(container.textContent).toContain('1 cached'); + }); + + it('expands a source and current run comparison for a full rerun', () => { + const sourceTask = workflowTask({ + id: 'wf-source', + status: 'failed', + runtimeMs: 5_000, + agentsDispatched: 4, + agentsCompleted: 3, + tokensSpent: 4_000, + }); + const task = workflowTask({ + id: 'wf-current', + sourceRunId: sourceTask.id, + startMode: 'rerun', + runtimeMs: 2_000, + agentsDispatched: 3, + agentsCompleted: 1, + tokensSpent: 1_200, + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + const compare = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Compare runs'); + expect(compare).toBeDefined(); + expect(compare?.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[data-run-comparison]')).toBeNull(); + + act(() => compare!.click()); + + const comparison = container.querySelector('[data-run-comparison]'); + expect(compare?.getAttribute('aria-expanded')).toBe('true'); + expect(comparison).not.toBeNull(); + expect(comparison?.textContent).toContain('wf-source'); + expect(comparison?.textContent).toContain('wf-current'); + expect(comparison?.textContent).toContain('3/4'); + expect(comparison?.textContent).toContain('1/3'); + expect(comparison?.textContent).toContain('4.0k'); + expect(comparison?.textContent).toContain('1.2k'); + }); + + it('opens saved run history and compares a selected historical run', () => { + const older = workflowTask({ + id: 'wf-older', + isHistorical: true, + status: 'completed', + startTime: 500, + runtimeMs: 4_000, + agentsDispatched: 2, + agentsCompleted: 2, + tokensSpent: 700, + }); + const failed = workflowTask({ + id: 'wf-failed', + isHistorical: true, + status: 'failed', + startTime: 1_000, + runtimeMs: 5_000, + agentsDispatched: 4, + agentsCompleted: 3, + tokensSpent: 4_000, + }); + const current = workflowTask({ id: 'wf-current', startTime: 2_000 }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + + const history = Array.from( + container.querySelectorAll('button'), + ).find((button) => button.textContent === 'Run history (2)'); + expect(history).toBeDefined(); + act(() => history!.click()); + + expect( + container.querySelector('[data-run-history]')?.textContent, + ).toContain('wf-failed'); + const failedRun = container.querySelector( + '[data-history-run="wf-failed"]', + ); + expect(failedRun).not.toBeNull(); + act(() => failedRun!.click()); + + const comparison = container.querySelector('[data-run-comparison]'); + expect(comparison?.textContent).toContain('wf-failed'); + expect(comparison?.textContent).toContain('wf-current'); + expect(comparison?.textContent).toContain('3/4'); + expect(comparison?.textContent).toContain('4.0k'); + + act(() => failedRun!.click()); + + expect(container.querySelector('[data-run-comparison]')).toBeNull(); + expect(failedRun?.getAttribute('aria-pressed')).toBe('false'); + }); + + it('filters saved runs and exports only the visible history', async () => { + const completed = workflowTask({ + id: 'wf-completed', + isHistorical: true, + status: 'completed', + startTime: 2_000, + endTime: 3_000, + }); + const failed = workflowTask({ + id: 'wf-failed', + isHistorical: true, + status: 'failed', + startTime: 1_000, + endTime: 1_500, + events: [ + { + id: 'event-1', + type: 'workflow-failed', + at: 1_500, + error: 'Verification failed', + }, + ], + }); + const createObjectURL = vi.fn(() => 'blob:workflow-history'); + vi.stubGlobal('URL', { + ...URL, + createObjectURL, + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => { + root.render( + + + , + ); + }); + const history = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Run history (2)', + ); + act(() => history?.click()); + const filter = container.querySelector( + '[aria-label="Filter runs"]', + ); + expect(filter).not.toBeNull(); + act(() => { + filter!.value = 'failed'; + filter!.dispatchEvent(new Event('change', { bubbles: true })); + }); + + expect( + container.querySelector('[data-history-run="wf-failed"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-history-run="wf-completed"]'), + ).toBeNull(); + const exportButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Export visible', + ); + act(() => exportButton?.click()); + + expect(createObjectURL).toHaveBeenCalledOnce(); + const blob = createObjectURL.mock.calls[0]![0] as Blob; + const text = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + reader.onload = () => resolve(String(reader.result)); + reader.readAsText(blob); + }); + const exported = JSON.parse(text) as { + runs: Array<{ + id: string; + events?: unknown[]; + dispatches: Array>; + }>; + }; + expect(exported.runs.map((run) => run.id)).toEqual(['wf-failed']); + expect(exported.runs[0]?.events).toEqual(failed.events); + expect(exported.runs[0]?.dispatches).not.toContainEqual( + expect.objectContaining({ prompt: expect.anything() }), + ); + + act(() => { + filter!.value = 'cancelled'; + filter!.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(container.textContent).toContain('No saved runs match this filter.'); + expect(exportButton?.disabled).toBe(true); + }); + + it('requires confirmation before deleting an individual saved run', () => { + const onDeleteHistory = vi.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + + + , + ); + }); + const history = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Run history (1)', + ); + act(() => history?.click()); + const remove = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Delete', + ); + act(() => remove?.click()); + expect(onDeleteHistory).not.toHaveBeenCalled(); + + const confirm = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Confirm delete', + ); + act(() => confirm?.click()); + + expect(onDeleteHistory).toHaveBeenCalledWith('wf-abcd'); + }); +}); diff --git a/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx b/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx new file mode 100644 index 00000000000..cd78f17a5cc --- /dev/null +++ b/packages/web-shell/client/components/messages/WorkflowExecutionView.tsx @@ -0,0 +1,855 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type CSSProperties, +} from 'react'; +import type { + DaemonSessionWorkflowTaskStatus, + DaemonWorkflowDispatchStatus, + DaemonWorkflowDispatchStatusEntry, +} from '@qwen-code/sdk/daemon'; +import { useI18n } from '../../i18n'; +import { formatRuntime } from '../../utils/formatRuntime'; +import { formatContextTokens } from '../../utils/formatTokenCount'; +import { formatTimestamp } from '../MessageTimestamp'; +import styles from './WorkflowExecutionView.module.css'; + +const LANE_WIDTH = 214; +const LANE_HEADER_HEIGHT = 54; +const NODE_WIDTH = 172; +const NODE_HEIGHT = 58; +const NODE_GAP = 22; +const CANVAS_PADDING = 22; +export const WORKFLOW_GRAPH_RENDER_LIMITS = { + lanes: 64, + nodes: 240, + edges: 400, +} as const; +type WorkflowHistoryFilter = 'all' | 'completed' | 'failed' | 'cancelled'; + +interface WorkflowGraphNode { + dispatch: DaemonWorkflowDispatchStatusEntry; + x: number; + y: number; +} + +interface WorkflowGraphEdge { + from: string; + to: string; + d: string; +} + +export interface WorkflowGraphLayout { + width: number; + height: number; + lanes: Array<{ id: string | null; title: string; index: number }>; + nodes: WorkflowGraphNode[]; + edges: WorkflowGraphEdge[]; + dispatchCountByLaneId: ReadonlyMap; + dispatchStatusById: ReadonlyMap; + omittedLanes: number; + omittedNodes: number; + omittedEdges: number; +} + +export function buildWorkflowGraphLayout( + task: DaemonSessionWorkflowTaskStatus, +): WorkflowGraphLayout { + const hasUnphased = task.dispatches.some( + (dispatch) => dispatch.phaseVisitId === null, + ); + const phaseLaneLimit = Math.max( + 0, + WORKFLOW_GRAPH_RENDER_LIMITS.lanes - (hasUnphased ? 1 : 0), + ); + const lanes = [ + ...(hasUnphased ? [{ id: null, title: 'No phase', index: 0 }] : []), + ...task.phaseVisits.slice(0, phaseLaneLimit).map((visit, index) => ({ + id: visit.id, + title: visit.title, + index: index + (hasUnphased ? 1 : 0), + })), + ]; + if (lanes.length === 0) lanes.push({ id: null, title: 'No phase', index: 0 }); + + const laneIndexById = new Map(lanes.map((lane) => [lane.id, lane.index])); + const dispatchCountByLaneId = new Map(); + const dispatchStatusById = new Map(); + for (const dispatch of task.dispatches) { + dispatchCountByLaneId.set( + dispatch.phaseVisitId, + (dispatchCountByLaneId.get(dispatch.phaseVisitId) ?? 0) + 1, + ); + dispatchStatusById.set(dispatch.id, dispatch.status); + } + const rowByLane = new Map(); + const nodes: WorkflowGraphNode[] = []; + for (const dispatch of task.dispatches) { + if (nodes.length >= WORKFLOW_GRAPH_RENDER_LIMITS.nodes) break; + const laneIndex = laneIndexById.get(dispatch.phaseVisitId); + if (laneIndex === undefined && dispatch.phaseVisitId !== null) continue; + const visibleLaneIndex = laneIndex ?? 0; + const row = rowByLane.get(visibleLaneIndex) ?? 0; + rowByLane.set(visibleLaneIndex, row + 1); + nodes.push({ + dispatch, + x: visibleLaneIndex * LANE_WIDTH + CANVAS_PADDING, + y: LANE_HEADER_HEIGHT + CANVAS_PADDING + row * (NODE_HEIGHT + NODE_GAP), + }); + } + const nodeById = new Map(nodes.map((node) => [node.dispatch.id, node])); + const allDispatchIds = new Set(task.dispatches.map(({ id }) => id)); + let totalEdgeCount = 0; + for (const dispatch of task.dispatches) { + for (const dependencyId of dispatch.dependsOn) { + if (allDispatchIds.has(dependencyId)) totalEdgeCount += 1; + } + } + const edges: WorkflowGraphEdge[] = []; + for (const target of nodes) { + for (const dependencyId of target.dispatch.dependsOn) { + if (edges.length >= WORKFLOW_GRAPH_RENDER_LIMITS.edges) break; + const source = nodeById.get(dependencyId); + if (!source) continue; + const startX = source.x + NODE_WIDTH; + const startY = source.y + NODE_HEIGHT / 2; + const endX = target.x; + const endY = target.y + NODE_HEIGHT / 2; + const bend = Math.max(34, Math.abs(endX - startX) * 0.45); + edges.push({ + from: dependencyId, + to: target.dispatch.id, + d: `M ${startX} ${startY} C ${startX + bend} ${startY}, ${endX - bend} ${endY}, ${endX} ${endY}`, + }); + } + } + const maxRows = Math.max(1, ...rowByLane.values()); + return { + width: lanes.length * LANE_WIDTH, + height: + LANE_HEADER_HEIGHT + + CANVAS_PADDING * 2 + + maxRows * NODE_HEIGHT + + Math.max(0, maxRows - 1) * NODE_GAP, + lanes, + nodes, + edges, + dispatchCountByLaneId, + dispatchStatusById, + omittedLanes: Math.max( + 0, + task.phaseVisits.length + (hasUnphased ? 1 : 0) - lanes.length, + ), + omittedNodes: Math.max(0, task.dispatches.length - nodes.length), + omittedEdges: Math.max(0, totalEdgeCount - edges.length), + }; +} + +function statusLabel( + status: DaemonWorkflowDispatchStatus, + t: ReturnType['t'], +): string { + return t(`workflow.dispatch.${status}`); +} + +function statusGlyph(status: DaemonWorkflowDispatchStatus): string { + if (status === 'completed' || status === 'cached') return '✓'; + if (status === 'failed') return '!'; + if (status === 'cancelled') return '×'; + return ''; +} + +function dispatchRuntime( + task: DaemonSessionWorkflowTaskStatus, + dispatch: DaemonWorkflowDispatchStatusEntry, +): string { + if (dispatch.startedAt === undefined) return '—'; + const now = task.startTime + task.runtimeMs; + return formatRuntime((dispatch.endedAt ?? now) - dispatch.startedAt); +} + +function initialDispatchId(task: DaemonSessionWorkflowTaskStatus): string { + const approvalSubagentIds = new Set( + (task.pendingApprovals ?? []).map((approval) => approval.subagentId), + ); + return ( + task.dispatches.find( + (dispatch) => + dispatch.subagentId && approvalSubagentIds.has(dispatch.subagentId), + )?.id ?? + task.dispatches.find((dispatch) => dispatch.status === 'failed')?.id ?? + task.dispatches.find((dispatch) => dispatch.status === 'running')?.id ?? + task.dispatches.find((dispatch) => dispatch.status === 'queued')?.id ?? + task.dispatches[0]?.id ?? + '' + ); +} + +function downloadWorkflowHistory( + task: DaemonSessionWorkflowTaskStatus, + runs: readonly DaemonSessionWorkflowTaskStatus[], +): void { + const content = JSON.stringify( + { + schemaVersion: 1, + workflow: task.label, + exportedAt: new Date().toISOString(), + runs: runs.map((run) => ({ + id: run.id, + sourceRunId: run.sourceRunId, + startMode: run.startMode, + label: run.label, + description: run.description, + status: run.status, + startTime: run.startTime, + endTime: run.endTime, + runtimeMs: run.runtimeMs, + currentPhase: run.currentPhase, + phaseVisits: run.phaseVisits, + dispatches: run.dispatches.map( + ({ prompt: _prompt, ...dispatch }) => dispatch, + ), + agentsDispatched: run.agentsDispatched, + agentsCompleted: run.agentsCompleted, + tokensSpent: run.tokensSpent, + tokenBudgetTotal: run.tokenBudgetTotal, + recentLogs: run.recentLogs, + events: run.events, + error: run.error, + })), + }, + null, + 2, + ); + const url = URL.createObjectURL( + new Blob([content], { type: 'application/json' }), + ); + try { + const link = document.createElement('a'); + link.href = url; + link.download = `workflow-${task.id.replace(/[^a-zA-Z0-9._-]/g, '-')}-history.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(url); + } +} + +export function WorkflowExecutionView({ + task, + sourceTask, + historyTasks = [], + historyActionBusy = false, + onDeleteHistory, +}: { + task: DaemonSessionWorkflowTaskStatus; + sourceTask?: DaemonSessionWorkflowTaskStatus; + historyTasks?: readonly DaemonSessionWorkflowTaskStatus[]; + historyActionBusy?: boolean; + onDeleteHistory?: (runId: string) => void; +}) { + const { t } = useI18n(); + const markerId = `workflow-arrow-${useId().replaceAll(':', '')}`; + const layout = useMemo(() => buildWorkflowGraphLayout(task), [task]); + const [selectedId, setSelectedId] = useState(() => initialDispatchId(task)); + const [hoveredDispatchId, setHoveredDispatchId] = useState(''); + const [focusedDispatchId, setFocusedDispatchId] = useState(''); + const [showComparison, setShowComparison] = useState(false); + const [showHistory, setShowHistory] = useState(false); + const [historyFilter, setHistoryFilter] = + useState('all'); + const [pendingDeleteRunId, setPendingDeleteRunId] = useState(''); + const [comparisonRunId, setComparisonRunId] = useState( + () => task.sourceRunId ?? '', + ); + const latestApprovalIdRef = useRef(task.pendingApprovals?.at(-1)?.approvalId); + const lastPhaseVisit = task.phaseVisits.at(-1); + const activePhaseVisitId = + lastPhaseVisit?.endedAt === undefined ? lastPhaseVisit?.id : undefined; + + useEffect(() => { + if (task.dispatches.some((dispatch) => dispatch.id === selectedId)) return; + setSelectedId(initialDispatchId(task)); + }, [selectedId, task]); + + useEffect(() => { + setShowComparison(false); + setShowHistory(false); + setHistoryFilter('all'); + setPendingDeleteRunId(''); + setComparisonRunId(task.sourceRunId ?? ''); + setHoveredDispatchId(''); + setFocusedDispatchId(''); + }, [task.id, task.sourceRunId]); + + useEffect(() => { + const latest = task.pendingApprovals?.at(-1); + if (!latest || latest.approvalId === latestApprovalIdRef.current) return; + latestApprovalIdRef.current = latest.approvalId; + const owner = task.dispatches.find( + (dispatch) => dispatch.subagentId === latest.subagentId, + ); + if (owner) setSelectedId(owner.id); + }, [task.dispatches, task.pendingApprovals]); + + const selected = + task.dispatches.find((dispatch) => dispatch.id === selectedId) ?? null; + const emphasizedDispatchId = hoveredDispatchId || focusedDispatchId; + const emphasizedDispatchIds = useMemo(() => { + const related = new Set(); + if (!emphasizedDispatchId) return related; + related.add(emphasizedDispatchId); + for (const edge of layout.edges) { + if (edge.from === emphasizedDispatchId) related.add(edge.to); + if (edge.to === emphasizedDispatchId) related.add(edge.from); + } + return related; + }, [emphasizedDispatchId, layout.edges]); + const approvalBySubagentId = new Map( + (task.pendingApprovals ?? []).map((approval) => [ + approval.subagentId, + approval, + ]), + ); + const selectedApproval = selected?.subagentId + ? approvalBySubagentId.get(selected.subagentId) + : undefined; + const labelById = new Map( + task.dispatches.map((dispatch) => [dispatch.id, dispatch.label]), + ); + const running = task.dispatches.filter( + (dispatch) => dispatch.status === 'running', + ).length; + const queued = task.dispatches.filter( + (dispatch) => dispatch.status === 'queued', + ).length; + const cached = task.dispatches.filter( + (dispatch) => dispatch.status === 'cached', + ).length; + const tokenText = task.tokenBudgetTotal + ? `${formatContextTokens(task.tokensSpent)} / ${formatContextTokens(task.tokenBudgetTotal)}` + : formatContextTokens(task.tokensSpent); + const historicalRuns = useMemo(() => { + const byId = new Map(); + if (sourceTask && sourceTask.id !== task.id) { + byId.set(sourceTask.id, sourceTask); + } + for (const historicalTask of historyTasks) { + if (historicalTask.id !== task.id) { + byId.set(historicalTask.id, historicalTask); + } + } + return [...byId.values()].sort((a, b) => b.startTime - a.startTime); + }, [historyTasks, sourceTask, task.id]); + const filteredHistoricalRuns = historicalRuns.filter( + (historicalTask) => + historyFilter === 'all' || historicalTask.status === historyFilter, + ); + const comparableSource = historicalRuns.find( + (historicalTask) => historicalTask.id === task.sourceRunId, + ); + const comparisonTask = + historicalRuns.find( + (historicalTask) => historicalTask.id === comparisonRunId, + ) ?? comparableSource; + const hasLineage = Boolean(task.sourceRunId && task.startMode); + const history = (task.isHistorical || + hasLineage || + historicalRuns.length > 0) && ( + <> +
+
+ + {hasLineage + ? t( + task.startMode === 'retry' + ? 'workflow.history.retry' + : 'workflow.history.rerun', + { runId: task.sourceRunId ?? '' }, + ) + : task.isHistorical + ? t('workflow.history.restored') + : t('workflow.history.saved', { + count: historicalRuns.length, + })} + + {cached > 0 && ( + + {t('workflow.history.cached', { count: cached })} + + )} +
+
+ {task.isHistorical && onDeleteHistory && ( + + )} + {historicalRuns.length > 0 && ( + + )} + {comparableSource && ( + + )} +
+
+ {showHistory && historicalRuns.length > 0 && ( +
+
+ + + {t('workflow.history.visibleCount', { + count: filteredHistoricalRuns.length, + total: historicalRuns.length, + })} + + +
+ {filteredHistoricalRuns.length === 0 ? ( +
+ {t('workflow.history.filterEmpty')} +
+ ) : ( + filteredHistoricalRuns.map((historicalTask) => ( +
+ + {historicalTask.isHistorical && onDeleteHistory && ( + + )} +
+ )) + )} +
+ )} + {showComparison && comparisonTask && ( +
+ +
+ + {comparisonTask.id === task.sourceRunId + ? t('workflow.history.source') + : t('workflow.history.compared')} + + {comparisonTask.id} +
+
+ {t('workflow.history.current')} + {task.id} +
+ + + {t('workflow.history.status')} + + + {t(`tasks.${comparisonTask.status}`)} + + + {t(`tasks.${task.status}`)} + + + + {t('tasks.detail.runtime')} + + + {formatRuntime(comparisonTask.runtimeMs)} + + + {formatRuntime(task.runtimeMs)} + + + + {t('workflow.history.agents')} + + + {comparisonTask.agentsCompleted}/{comparisonTask.agentsDispatched} + + + {task.agentsCompleted}/{task.agentsDispatched} + + + + {t('tasks.detail.tokenCount')} + + + {formatContextTokens(comparisonTask.tokensSpent)} + + + {formatContextTokens(task.tokensSpent)} + +
+ )} + + ); + + if (task.dispatches.length === 0) { + return ( +
+ {history} +
+ {task.isHistorical + ? t('workflow.graph.notRecorded') + : t('workflow.graph.waiting')} +
+
+ ); + } + + return ( +
+ {history} +
+ + + {task.agentsCompleted}/{task.agentsDispatched} + {' '} + {t('workflow.metric.agents')} + + {running > 0 && ( + + {running} {t('workflow.metric.running')} + + )} + {queued > 0 && ( + + {queued} {t('workflow.metric.queued')} + + )} + + {tokenText} {t('workflow.metric.tokens')} + + {task.pendingApprovalCount > 0 && ( + + {task.pendingApprovalCount}{' '} + {t('workflow.approvalNeeded')} + + )} +
+ {(layout.omittedLanes > 0 || + layout.omittedNodes > 0 || + layout.omittedEdges > 0) && ( +
+ {t('workflow.graph.omitted', { + lanes: layout.omittedLanes, + nodes: layout.omittedNodes, + edges: layout.omittedEdges, + })} +
+ )} +
+
+
+ {layout.lanes.map((lane) => { + const dispatchCount = + layout.dispatchCountByLaneId.get(lane.id) ?? 0; + return ( +
+
+ {String(lane.index + 1).padStart(2, '0')} + + {lane.id === null ? t('workflow.noPhase') : lane.title} + +
+ + {t('workflow.dispatchCount', { count: dispatchCount })} + +
+ ); + })} + + {layout.nodes.map(({ dispatch, x, y }) => { + const approval = dispatch.subagentId + ? approvalBySubagentId.get(dispatch.subagentId) + : undefined; + const pathEmphasis = emphasizedDispatchId + ? dispatch.id === emphasizedDispatchId + ? 'active' + : emphasizedDispatchIds.has(dispatch.id) + ? 'related' + : 'dimmed' + : undefined; + return ( + + ); + })} +
+
+ {selected && ( + + )} +
+
+ ); +} diff --git a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css index 4f5be7e9858..2bdd2ddb230 100644 --- a/packages/web-shell/client/components/messages/tools/ToolChrome.module.css +++ b/packages/web-shell/client/components/messages/tools/ToolChrome.module.css @@ -24,6 +24,17 @@ min-width: 0; } +.workflowLineDetail { + padding: 8px 0 2px; +} + +.workflowFallback { + padding: 10px 12px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} + .chatSummary { width: 100%; margin: 0; diff --git a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx index 794b716e819..73c62a923a4 100644 --- a/packages/web-shell/client/components/panels/EnvironmentPanel.tsx +++ b/packages/web-shell/client/components/panels/EnvironmentPanel.tsx @@ -8,6 +8,7 @@ import { BotIcon, ChevronRightIcon, CircleCheckIcon, + CirclePauseIcon, CircleStopIcon, CircleXIcon, FileDiffIcon, @@ -16,6 +17,7 @@ import { LoaderCircleIcon, SquareActivityIcon, SquareTerminalIcon, + WorkflowIcon, } from 'lucide-react'; import type { WebShellEnvironmentPanelItem } from '../../customization'; import { useI18n } from '../../i18n'; @@ -65,6 +67,8 @@ function taskLabel(task: DaemonSessionTaskStatus): string { return task.command; case 'monitor': return task.description; + case 'workflow': + return task.label; } } @@ -76,6 +80,8 @@ function taskIcon(task: DaemonSessionTaskStatus) { return ; case 'monitor': return ; + case 'workflow': + return ; } } @@ -85,11 +91,12 @@ function taskStatusKey(status: DaemonSessionTaskStatus['status']) { function taskStatusIcon(status: DaemonSessionTaskStatus['status']) { if (status === 'completed') return ; - if (status === 'running') { + if (status === 'running' || status === 'pausing') { return ; } if (status === 'failed') return ; if (status === 'cancelled') return ; + if (status === 'paused') return ; return null; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index 3ec777b0c85..f4282f29f2e 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -223,6 +223,7 @@ function renderSidebar() { onOpenSettings={() => {}} onOpenDaemonStatus={() => {}} onOpenScheduledTasks={() => {}} + onOpenWorkflows={() => {}} onOpenGoals={() => {}} onOpenSessions={() => {}} onOpenSplitView={() => {}} diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 78c006c374a..3b04d3129dd 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -57,6 +57,7 @@ import { SquarePenIcon, SunIcon, TargetIcon, + WorkflowIcon, } from 'lucide-react'; import { WebShellThemeId, type WebShellTheme } from '../../themeContext'; import { useI18n } from '../../i18n'; @@ -173,6 +174,7 @@ export type WebShellSidebarPrimaryNavItem = | 'plugins' | 'channels' | 'scheduledTasks' + | 'workflows' | 'goals'; export interface WebShellSidebarPrimaryNavOptions { @@ -204,6 +206,7 @@ const DEFAULT_PRIMARY_NAV_ITEMS: readonly WebShellSidebarPrimaryNavItem[] = [ 'plugins', 'channels', 'scheduledTasks', + 'workflows', 'goals', ]; @@ -298,6 +301,7 @@ interface WebShellSidebarProps { onOpenChannels: () => void; onOpenDaemonStatus: () => void; onOpenScheduledTasks: () => void; + onOpenWorkflows: () => void; onOpenGoals: () => void; onOpenSessions: () => void; /** @@ -532,6 +536,7 @@ export function WebShellSidebar({ onOpenChannels, onOpenDaemonStatus, onOpenScheduledTasks, + onOpenWorkflows, onOpenGoals, onOpenSessions, canOpenSessionsOverview, @@ -636,6 +641,16 @@ export function WebShellSidebar({ workspaces.find((entry) => entry.primary)?.cwd ?? workspace.capabilities?.workspaceCwd ?? connection.workspaceCwd; + const workflowWorkspaceCwd = connection.sessionId + ? connection.workspaceCwd + : (lockedWorkspaceCwd ?? selectedWorkspaceCwd ?? primaryWorkspaceCwd); + const workspaceWorkflowsEnabled = + workspaces.find((entry) => entry.cwd === workflowWorkspaceCwd) + ?.workflowsEnabled ?? false; + const workflowsEnabled = connection.sessionId + ? (connection.supportedCommands?.workflowsEnabled ?? + workspaceWorkflowsEnabled) + : workspaceWorkflowsEnabled; const lockedWorkspace = lockedWorkspaceCwd ? workspaces.find((entry) => entry.cwd === lockedWorkspaceCwd) : undefined; @@ -4251,6 +4266,20 @@ export function WebShellSidebar({ {!collapsed && {t('sidebar.scheduledTasks')}} )} + {primaryNavItems.has('workflows') && workflowsEnabled && ( + + )} {primaryNavItems.has('goals') && ( +
+ + {loadError && ( +
+ {t('workflowRuns.loadFailed')} +
+ )} + + {!connection.sessionId ? ( +
{t('workflowRuns.noSession')}
+ ) : loading && !snapshot ? ( +
{t('workflowRuns.loading')}
+ ) : ( + snapshot && ( + <> + + {startError && ( +
+ {t('workflowRuns.startFailed')} +
+ )} + {savedWorkflows.length === 0 ? ( +
+
+ ) : ( +
+ {savedWorkflows.map((workflow) => ( +
+
+
+ /{workflow.name} +
+
+ {workflow.source === 'project' + ? t('workflowRuns.projectDescription') + : t('workflowRuns.userDescription')} +
+
+ + {workflow.source === 'project' + ? t('workflowRuns.project') + : t('workflowRuns.user')} + + +
+ ))} +
+ )} +
+ + setTab('active')} + /> + + + setTab('active')} + /> + + + ) + )} + +
+ ); +} diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index ce7b2c85b8b..203f3caea61 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -416,10 +416,27 @@ export interface WebShellMonitorTask extends WebShellTaskBase { exitCode?: number; } +export interface WebShellWorkflowTask extends WebShellTaskBase { + kind: 'workflow'; + status: + | 'running' + | 'pausing' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled'; + currentPhase?: string; + agentsDispatched: number; + agentsCompleted: number; + tokensSpent: number; + tokenBudgetTotal?: number; +} + export type WebShellTaskInfo = | WebShellAgentTask | WebShellShellTask - | WebShellMonitorTask; + | WebShellMonitorTask + | WebShellWorkflowTask; // ---- Model info (public type for footer renderer) ---- diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx index e7bd5d7d68e..8a24ff75747 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx +++ b/packages/web-shell/client/hooks/useBackgroundTasks.test.tsx @@ -12,6 +12,7 @@ import type { DaemonSessionTasksStatus, DaemonSessionTaskStatus, } from '@qwen-code/sdk/daemon'; +import { TASKS_STATUS_ACTIVE_EVENT } from '../components/messages/TasksStatusMessage'; import { useBackgroundTasks } from './useBackgroundTasks'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -132,6 +133,71 @@ afterEach(async () => { }); describe('useBackgroundTasks', () => { + it('keeps polling while an active workflow is waiting to register', async () => { + taskActivityKey = 'workflow-call:in_progress'; + const runningWorkflow = { + kind: 'workflow' as const, + id: 'wf-live', + label: 'Live workflow', + description: 'Live workflow', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + isBackgrounded: false, + currentPhase: null, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; + sdkMock.actions.getTasks + .mockResolvedValueOnce(snapshot('session-a')) + .mockResolvedValueOnce(snapshot('session-a')) + .mockResolvedValue(snapshot('session-a', [runningWorkflow])); + + await renderHarness(); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(3); + expect(latestTasks).toEqual([runningWorkflow]); + }); + + it('only pauses polling for a task panel in the same session', async () => { + const runningMonitor = monitor('monitor-a', 'running'); + sdkMock.actions.getTasks.mockResolvedValue( + snapshot('session-a', [runningMonitor]), + ); + await renderHarness(); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-b' }, + }), + ); + await vi.advanceTimersByTimeAsync(3000); + }); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + + await act(async () => { + window.dispatchEvent( + new CustomEvent(TASKS_STATUS_ACTIVE_EVENT, { + detail: { active: true, sessionId: 'session-a' }, + }), + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(sdkMock.actions.getTasks).toHaveBeenCalledTimes(2); + }); + it('keeps polling after a transient task refresh failure', async () => { const runningMonitor = monitor('monitor-a', 'running'); sdkMock.actions.getTasks diff --git a/packages/web-shell/client/hooks/useBackgroundTasks.ts b/packages/web-shell/client/hooks/useBackgroundTasks.ts index 33485f39f62..b41e50468fe 100644 --- a/packages/web-shell/client/hooks/useBackgroundTasks.ts +++ b/packages/web-shell/client/hooks/useBackgroundTasks.ts @@ -10,9 +10,16 @@ import { isSessionDisconnectedError } from '../utils/sessionErrors'; const TASKS_POLL_INTERVAL_MS = 3000; const MAX_EMPTY_TASK_POLLS = 2; +function hasActiveTaskActivity(taskActivityKey: string): boolean { + return /:(?:pending|in_progress)(?:\||$)/.test(taskActivityKey); +} + function hasActiveTask(tasks: readonly DaemonSessionTaskStatus[]): boolean { return tasks.some( - (task) => task.status === 'running' || task.status === 'paused', + (task) => + task.status === 'running' || + task.status === 'pausing' || + task.status === 'paused', ); } @@ -67,6 +74,7 @@ export function useBackgroundTasks( return; setTasks(snapshot.tasks); if (snapshot.tasks.length === 0) { + if (hasActiveTaskActivity(taskActivityKey)) return; emptyPollsRef.current += 1; if (emptyPollsRef.current >= MAX_EMPTY_TASK_POLLS) { setPollingActive(false); @@ -99,14 +107,25 @@ export function useBackgroundTasks( disposed = true; clearInterval(id); }; - }, [actions, connected, owner, pollingActive, sessionId, tasksPanelActive]); + }, [ + actions, + connected, + owner, + pollingActive, + sessionId, + taskActivityKey, + tasksPanelActive, + ]); const tasksRef = useRef(tasks); tasksRef.current = tasks; useEffect(() => { const onTasksPanelActive = (event: Event) => { - const detail = (event as CustomEvent<{ active?: boolean }>).detail; + const detail = ( + event as CustomEvent<{ active?: boolean; sessionId?: string }> + ).detail; + if (detail?.sessionId !== sessionId) return; const active = detail?.active === true; setTasksPanelActive(active); if (!active && hasActiveTask(tasksRef.current)) { @@ -116,7 +135,7 @@ export function useBackgroundTasks( window.addEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); return () => window.removeEventListener(TASKS_STATUS_ACTIVE_EVENT, onTasksPanelActive); - }, []); + }, [sessionId]); return tasksOwnerRef.current === owner ? tasks : []; } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 825f01aee60..29c1d25981a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1068,6 +1068,29 @@ const EN: Messages = { 'scheduledTasks.namePlaceholder': 'Optional — defaults to the prompt', 'scheduledTasks.prompt': 'Prompt', 'scheduledTasks.promptPlaceholder': 'What should this task do?', + 'workflowRuns.title': 'Workflows', + 'workflowRuns.saved': 'Saved', + 'workflowRuns.active': 'Running', + 'workflowRuns.history': 'History', + 'workflowRuns.refresh': 'Refresh', + 'workflowRuns.loading': 'Loading workflow runs…', + 'workflowRuns.loadFailed': 'Failed to load workflow runs.', + 'workflowRuns.noSession': + 'Open a session in this project to view its workflow runs.', + 'workflowRuns.emptyActive': 'No workflows are currently running.', + 'workflowRuns.emptyHistory': 'No saved workflow runs yet.', + 'workflowRuns.emptySaved': 'No reusable workflows saved yet.', + 'workflowRuns.emptySavedHint': + 'Save a completed run from the terminal, or add a .js file under .qwen/workflows.', + 'workflowRuns.project': 'Project', + 'workflowRuns.user': 'User', + 'workflowRuns.projectDescription': 'Available in this project', + 'workflowRuns.userDescription': 'Available across projects', + 'workflowRuns.run': 'Run', + 'workflowRuns.starting': 'Starting…', + 'workflowRuns.runNamed': (v) => `Run ${v?.name ?? ''}`, + 'workflowRuns.startFailed': + 'The saved workflow could not be started. Refresh and try again.', 'scheduledTasks.reference.extension': 'Extensions', 'scheduledTasks.reference.skill': 'Skills', 'scheduledTasks.reference.mcp': 'MCP', @@ -1291,6 +1314,7 @@ const EN: Messages = { 'sidebar.settings': 'Settings', 'sidebar.daemonStatus': 'Daemon Status', 'sidebar.scheduledTasks': 'Scheduled Tasks', + 'sidebar.workflows': 'Workflows', 'sidebar.goals': 'Goals', 'sidebar.themeLight': 'Switch to light theme', 'sidebar.themeDark': 'Switch to dark theme', @@ -2386,12 +2410,70 @@ const EN: Messages = { 'tasks.moreAbove': (v) => `^ ${v?.count ?? 0} more above`, 'tasks.moreBelow': (v) => `v ${v?.count ?? 0} more below`, 'tasks.running': 'Running', + 'tasks.pausing': 'Pausing', 'tasks.completed': 'Completed', 'tasks.failed': 'Failed', 'tasks.cancelled': 'Stopped', 'tasks.paused': 'Paused', 'tasks.kind.shell': 'Shell', 'tasks.kind.monitor': 'Monitor', + 'tasks.kind.workflow': 'Workflow', + 'workflow.graph.waiting': 'Waiting for the first agent dispatch…', + 'workflow.graph.notRecorded': 'No execution graph was saved for this run.', + 'workflow.inline.loading': 'Loading live workflow execution…', + 'workflow.inline.unavailable': + 'This workflow run is no longer available in the current session.', + 'workflow.graph.omitted': (v) => + `Graph limited for performance: ${v?.lanes ?? 0} phases, ${v?.nodes ?? 0} agents, and ${v?.edges ?? 0} connections omitted.`, + 'workflow.noPhase': 'No phase', + 'workflow.dispatchCount': (v) => + `${v?.count ?? 0} dispatch${v?.count === 1 ? '' : 'es'}`, + 'workflow.selectedDispatch': 'Selected dispatch', + 'workflow.dependencies': 'Depends on', + 'workflow.action.pause': 'Pause', + 'workflow.action.resume': 'Resume', + 'workflow.action.retry': 'Retry failed path', + 'workflow.action.rerun': 'Rerun all', + 'workflow.action.unavailable': 'Workflow state changed before the action.', + 'workflow.action.failed': 'Could not update the workflow.', + 'workflow.history.retry': (v) => `Retried from ${v?.runId ?? ''}`, + 'workflow.history.rerun': (v) => `Rerun from ${v?.runId ?? ''}`, + 'workflow.history.cached': (v) => `${v?.count ?? 0} cached`, + 'workflow.history.saved': (v) => `${v?.count ?? 0} saved runs`, + 'workflow.history.restored': 'Saved run · read-only', + 'workflow.history.showRuns': (v) => `Run history (${v?.count ?? 0})`, + 'workflow.history.hideRuns': 'Hide history', + 'workflow.history.filter': 'Filter runs', + 'workflow.history.filterAll': 'All statuses', + 'workflow.history.visibleCount': (v) => + `${v?.count ?? 0} of ${v?.total ?? 0}`, + 'workflow.history.filterEmpty': 'No saved runs match this filter.', + 'workflow.history.exportVisible': 'Export visible', + 'workflow.history.delete': 'Delete', + 'workflow.history.deleteSaved': 'Delete saved run', + 'workflow.history.confirmDelete': 'Confirm delete', + 'workflow.history.deleteUnavailable': 'The saved run is no longer available.', + 'workflow.history.deleteFailed': 'Could not delete the saved run.', + 'workflow.history.compareRun': (v) => `Compare run ${v?.runId ?? ''}`, + 'workflow.history.compare': 'Compare runs', + 'workflow.history.hideComparison': 'Hide comparison', + 'workflow.history.source': 'Source run', + 'workflow.history.compared': 'Compared run', + 'workflow.history.current': 'Current run', + 'workflow.history.status': 'Status', + 'workflow.history.agents': 'Agents', + 'workflow.approvalNeeded': 'Approval needed', + 'workflow.respondInChat': 'Respond in chat', + 'workflow.metric.agents': 'agents', + 'workflow.metric.running': 'running', + 'workflow.metric.queued': 'queued', + 'workflow.metric.tokens': 'tokens', + 'workflow.dispatch.queued': 'Queued', + 'workflow.dispatch.running': 'Running', + 'workflow.dispatch.completed': 'Completed', + 'workflow.dispatch.failed': 'Failed', + 'workflow.dispatch.cancelled': 'Cancelled', + 'workflow.dispatch.cached': 'Cached', 'tasks.action.stop': 'Stop', 'tasks.action.abandon': 'Abandon', 'tasks.action.confirmStop': 'Confirm stop', @@ -2407,6 +2489,8 @@ const EN: Messages = { 'tasks.pill.monitors': (v) => `${v?.count ?? 0} monitors`, 'tasks.pill.shell': (v) => `${v?.count ?? 0} shell`, 'tasks.pill.shells': (v) => `${v?.count ?? 0} shells`, + 'tasks.pill.workflow': (v) => `${v?.count ?? 0} workflow`, + 'tasks.pill.workflows': (v) => `${v?.count ?? 0} workflows`, 'tasks.confirmStop': 'x again to confirm stop · ends the blocking turn', 'tasks.shortcut.select': '↑/↓ select', 'tasks.shortcut.view': 'Enter view', @@ -3881,6 +3965,27 @@ const ZH: Messages = { 'scheduledTasks.namePlaceholder': '可选 —— 默认取提示词', 'scheduledTasks.prompt': '提示词', 'scheduledTasks.promptPlaceholder': '这个任务要做什么?', + 'workflowRuns.title': '工作流', + 'workflowRuns.saved': '已保存', + 'workflowRuns.active': '运行中', + 'workflowRuns.history': '历史', + 'workflowRuns.refresh': '刷新', + 'workflowRuns.loading': '正在加载工作流…', + 'workflowRuns.loadFailed': '工作流加载失败。', + 'workflowRuns.noSession': '请先打开本项目中的任一会话,再查看工作流记录。', + 'workflowRuns.emptyActive': '当前没有运行中的工作流。', + 'workflowRuns.emptyHistory': '暂时没有已保存的工作流记录。', + 'workflowRuns.emptySaved': '还没有可复用的工作流。', + 'workflowRuns.emptySavedHint': + '可以在终端中保存一次已完成的运行,或在 .qwen/workflows 下添加 .js 文件。', + 'workflowRuns.project': '项目', + 'workflowRuns.user': '用户', + 'workflowRuns.projectDescription': '仅在当前项目中可用', + 'workflowRuns.userDescription': '在所有项目中可用', + 'workflowRuns.run': '运行', + 'workflowRuns.starting': '正在启动…', + 'workflowRuns.runNamed': (v) => `运行 ${v?.name ?? ''}`, + 'workflowRuns.startFailed': '工作流未能启动,请刷新后重试。', 'scheduledTasks.reference.extension': '扩展', 'scheduledTasks.reference.skill': '技能', 'scheduledTasks.reference.mcp': 'MCP', @@ -4093,6 +4198,7 @@ const ZH: Messages = { 'sidebar.settings': '设置', 'sidebar.daemonStatus': 'Daemon 状态', 'sidebar.scheduledTasks': '定时任务', + 'sidebar.workflows': '工作流', 'sidebar.goals': '目标', 'sidebar.themeLight': '切换到浅色主题', 'sidebar.themeDark': '切换到深色主题', @@ -5101,12 +5207,67 @@ const ZH: Messages = { 'tasks.moreAbove': (v) => `^ 上方还有 ${v?.count ?? 0} 个`, 'tasks.moreBelow': (v) => `v 下方还有 ${v?.count ?? 0} 个`, 'tasks.running': '运行中', + 'tasks.pausing': '暂停中', 'tasks.completed': '已完成', 'tasks.failed': '失败', 'tasks.cancelled': '已停止', 'tasks.paused': '已暂停', 'tasks.kind.shell': 'Shell', 'tasks.kind.monitor': '监控', + 'tasks.kind.workflow': '工作流', + 'workflow.graph.waiting': '等待第一个 Agent 调度…', + 'workflow.graph.notRecorded': '这次运行没有保存执行图。', + 'workflow.inline.loading': '正在加载 Workflow 实时执行状态…', + 'workflow.inline.unavailable': '当前会话中已无法读取这次 Workflow 运行。', + 'workflow.graph.omitted': (v) => + `为保证性能,执行图已省略 ${v?.lanes ?? 0} 个阶段、${v?.nodes ?? 0} 个 Agent 和 ${v?.edges ?? 0} 条连线。`, + 'workflow.noPhase': '未分阶段', + 'workflow.dispatchCount': (v) => `${v?.count ?? 0} 个调度`, + 'workflow.selectedDispatch': '已选调度', + 'workflow.dependencies': '依赖', + 'workflow.action.pause': '暂停', + 'workflow.action.resume': '继续', + 'workflow.action.retry': '重试失败路径', + 'workflow.action.rerun': '全部重跑', + 'workflow.action.unavailable': '操作前工作流状态已发生变化。', + 'workflow.action.failed': '无法更新工作流状态。', + 'workflow.history.retry': (v) => `从 ${v?.runId ?? ''} 续跑`, + 'workflow.history.rerun': (v) => `从 ${v?.runId ?? ''} 全部重跑`, + 'workflow.history.cached': (v) => `${v?.count ?? 0} 个缓存命中`, + 'workflow.history.saved': (v) => `已保存 ${v?.count ?? 0} 次运行`, + 'workflow.history.restored': '已保存运行 · 只读', + 'workflow.history.showRuns': (v) => `运行历史(${v?.count ?? 0})`, + 'workflow.history.hideRuns': '收起历史', + 'workflow.history.filter': '筛选运行', + 'workflow.history.filterAll': '全部状态', + 'workflow.history.visibleCount': (v) => `${v?.count ?? 0} / ${v?.total ?? 0}`, + 'workflow.history.filterEmpty': '没有符合当前筛选条件的历史运行。', + 'workflow.history.exportVisible': '导出当前结果', + 'workflow.history.delete': '删除', + 'workflow.history.deleteSaved': '删除已保存运行', + 'workflow.history.confirmDelete': '确认删除', + 'workflow.history.deleteUnavailable': '这次历史运行已不存在。', + 'workflow.history.deleteFailed': '无法删除这次历史运行。', + 'workflow.history.compareRun': (v) => `对比运行 ${v?.runId ?? ''}`, + 'workflow.history.compare': '对比两次运行', + 'workflow.history.hideComparison': '收起对比', + 'workflow.history.source': '来源运行', + 'workflow.history.compared': '对比运行', + 'workflow.history.current': '当前运行', + 'workflow.history.status': '状态', + 'workflow.history.agents': 'Agents', + 'workflow.approvalNeeded': '等待审批', + 'workflow.respondInChat': '请在对话中处理', + 'workflow.metric.agents': 'Agents', + 'workflow.metric.running': '运行中', + 'workflow.metric.queued': '排队中', + 'workflow.metric.tokens': 'Tokens', + 'workflow.dispatch.queued': '排队中', + 'workflow.dispatch.running': '运行中', + 'workflow.dispatch.completed': '已完成', + 'workflow.dispatch.failed': '失败', + 'workflow.dispatch.cancelled': '已取消', + 'workflow.dispatch.cached': '已缓存', 'tasks.action.stop': '停止', 'tasks.action.abandon': '放弃', 'tasks.action.confirmStop': '确认停止', @@ -5122,6 +5283,8 @@ const ZH: Messages = { 'tasks.pill.monitors': (v) => `${v?.count ?? 0} 个监控`, 'tasks.pill.shell': (v) => `${v?.count ?? 0} 个 shell`, 'tasks.pill.shells': (v) => `${v?.count ?? 0} 个 shell`, + 'tasks.pill.workflow': (v) => `${v?.count ?? 0} 个工作流`, + 'tasks.pill.workflows': (v) => `${v?.count ?? 0} 个工作流`, 'tasks.confirmStop': '再按 x 确认停止 · 会终结当前阻塞轮次', 'tasks.shortcut.select': '↑/↓ 选择', 'tasks.shortcut.view': 'Enter 查看', diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 4ffd92fe0d4..cb4a91f02fa 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -219,6 +219,7 @@ export type { WebShellAgentTask, WebShellShellTask, WebShellMonitorTask, + WebShellWorkflowTask, WebShellModelInfo, WebShellSkillInfo, } from './customization'; diff --git a/packages/web-shell/client/utils/composerTasks.test.ts b/packages/web-shell/client/utils/composerTasks.test.ts index 06812c82be2..337283f52fc 100644 --- a/packages/web-shell/client/utils/composerTasks.test.ts +++ b/packages/web-shell/client/utils/composerTasks.test.ts @@ -52,6 +52,21 @@ describe('isComposerTask', () => { }, true, ], + [ + { + ...base, + kind: 'workflow', + isBackgrounded: true, + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + recentLogs: [], + pendingApprovalCount: 0, + }, + true, + ], ]; for (const [task, expected] of tasks) { diff --git a/packages/web-shell/client/utils/taskActivity.ts b/packages/web-shell/client/utils/taskActivity.ts new file mode 100644 index 00000000000..f33e9ad59d7 --- /dev/null +++ b/packages/web-shell/client/utils/taskActivity.ts @@ -0,0 +1,34 @@ +import type { ACPToolCall, Message } from '../adapters/types'; +import { isBackgroundSubAgentToolCall } from '../adapters/toolClassification'; + +function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'monitor' || name === 'workflow') return true; + if (tool.args?.is_background !== true) return false; + return ( + name === 'shell' || + name === 'bash' || + name === 'run_shell_command' || + name === 'exec' + ); +} + +export function getTaskActivityKey(messages: readonly Message[]): string { + const parts: string[] = []; + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isBackgroundTaskToolCall(tool) || + isBackgroundSubAgentToolCall(tool) + ) { + parts.push(`${tool.callId}:${tool.status}`); + } + if (tool.subTools) visit(tool.subTools); + } + }; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + visit(message.tools); + } + return parts.join('|'); +} diff --git a/packages/web-shell/client/utils/workflowTasks.test.ts b/packages/web-shell/client/utils/workflowTasks.test.ts new file mode 100644 index 00000000000..fb69da3a5f2 --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { + DaemonSessionTaskStatus, + DaemonSessionWorkflowTaskStatus, +} from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../adapters/types'; +import { findWorkflowTaskForTool } from './workflowTasks'; + +function workflowTask(id: string): DaemonSessionWorkflowTaskStatus { + return { + kind: 'workflow', + id, + label: 'Channel analysis', + description: 'Analyze channel packages', + status: 'running', + startTime: 1_000, + runtimeMs: 200, + isBackgrounded: false, + currentPhase: 'Inspect', + phaseVisits: [], + dispatches: [], + agentsDispatched: 0, + agentsCompleted: 0, + tokensSpent: 0, + tokenBudgetTotal: null, + recentLogs: [], + pendingApprovalCount: 0, + }; +} + +function workflowTool(overrides: Partial): ACPToolCall { + return { + callId: 'workflow-call', + toolName: 'workflow', + status: 'in_progress', + ...overrides, + }; +} + +describe('findWorkflowTaskForTool', () => { + const tasks: DaemonSessionTaskStatus[] = [ + workflowTask('wf_expected'), + workflowTask('wf_other'), + ]; + + it('matches a live workflow update by its runId payload', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + subContent: + '```json\n{"runId":"wf_expected","status":"running"}\n```', + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('prefers the parent tool identity before live output arrives', () => { + const linked = workflowTask('wf_expected'); + linked.toolUseId = 'workflow-call'; + expect( + findWorkflowTaskForTool( + [workflowTask('wf_other'), linked], + workflowTool({ subContent: undefined }), + )?.id, + ).toBe('wf_expected'); + }); + + it('matches a completed background workflow by its result text', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ + status: 'completed', + rawOutput: + 'Workflow started in background.\nRun ID: wf_expected\nStatus: running', + }), + )?.id, + ).toBe('wf_expected'); + }); + + it('does not guess when the tool contains no run identity', () => { + expect( + findWorkflowTaskForTool( + tasks, + workflowTool({ subContent: 'Starting agents' }), + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/web-shell/client/utils/workflowTasks.ts b/packages/web-shell/client/utils/workflowTasks.ts new file mode 100644 index 00000000000..4ab0d2a0f1e --- /dev/null +++ b/packages/web-shell/client/utils/workflowTasks.ts @@ -0,0 +1,47 @@ +import type { + DaemonSessionTaskStatus, + DaemonSessionWorkflowTaskStatus, +} from '@qwen-code/sdk/daemon'; +import type { ACPToolCall } from '../adapters/types'; + +function workflowRunIdFromTool(tool: ACPToolCall): string | undefined { + const rawOutput = (() => { + if (typeof tool.rawOutput === 'string') return tool.rawOutput; + if (!tool.rawOutput) return ''; + try { + return JSON.stringify(tool.rawOutput); + } catch { + return ''; + } + })(); + const text = [ + rawOutput, + tool.subContent ?? '', + ...(tool.content ?? []).map((item) => item.content?.text ?? ''), + ].join('\n'); + const runId = + text.match(/"runId"\s*:\s*"([^"]+)"/)?.[1] ?? + text.match(/\bRun ID:\s*([^\s]+)/i)?.[1] ?? + text.match(/\bWorkflow\s+([^\s]+)\s+(?:started|—|-)/i)?.[1]; + if (runId) return runId; + return typeof tool.args?.resumeFromRunId === 'string' + ? tool.args.resumeFromRunId + : undefined; +} + +export function findWorkflowTaskForTool( + tasks: readonly DaemonSessionTaskStatus[], + tool: ACPToolCall, +): DaemonSessionWorkflowTaskStatus | undefined { + const linked = tasks.find( + (task): task is DaemonSessionWorkflowTaskStatus => + task.kind === 'workflow' && task.toolUseId === tool.callId, + ); + if (linked) return linked; + const runId = workflowRunIdFromTool(tool); + if (!runId) return undefined; + return tasks.find( + (task): task is DaemonSessionWorkflowTaskStatus => + task.kind === 'workflow' && task.id === runId, + ); +} diff --git a/packages/web-shell/client/workflowDetailsContext.tsx b/packages/web-shell/client/workflowDetailsContext.tsx new file mode 100644 index 00000000000..5132a189e09 --- /dev/null +++ b/packages/web-shell/client/workflowDetailsContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; + +interface WorkflowDetailsContextValue { + tasks: readonly DaemonSessionTaskStatus[]; +} + +const WorkflowDetailsContext = createContext< + WorkflowDetailsContextValue | undefined +>(undefined); + +export function WorkflowDetailsProvider({ + tasks, + children, +}: { + tasks: readonly DaemonSessionTaskStatus[]; + children: ReactNode; +}) { + const value = useMemo(() => ({ tasks }), [tasks]); + return ( + + {children} + + ); +} + +export function useWorkflowDetails(): WorkflowDetailsContextValue | undefined { + return useContext(WorkflowDetailsContext); +} diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 1d01be3fb4c..b221c39ae2e 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -1119,6 +1119,68 @@ describe('createDaemonSessionActions', () => { expect(addNotice).not.toHaveBeenCalled(); }); + it('starts a saved workflow through the session workflow action', async () => { + const session = createMockSession('session-a'); + session.client.sessionWorkflowTaskAction.mockResolvedValueOnce({ + changed: true, + status: 'running', + taskId: 'wf_5678efab', + }); + const { actions } = createActionsHarness({ session }); + + await expect(actions.runSavedWorkflow('deep-review')).resolves.toEqual({ + started: true, + status: 'running', + taskId: 'wf_5678efab', + }); + expect(session.client.sessionWorkflowTaskAction).toHaveBeenCalledWith( + 'session-a', + 'deep-review', + 'run-saved', + 'client-session-a', + ); + }); + + it('suppresses a stale workflow-control failure after switching sessions', async () => { + const sessionA = createMockSession('session-a'); + const sessionB = createMockSession('session-b'); + const pending = createDeferred(); + sessionA.controlWorkflowTask.mockReturnValueOnce(pending.promise); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.controlWorkflowTask('wf-1', 'pause'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old workflow failed')); + + await expect(result).rejects.toThrow('old workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + + it('suppresses a stale saved-workflow failure after switching sessions', async () => { + const sessionA = createMockSession('session-a'); + const sessionB = createMockSession('session-b'); + const pending = createDeferred(); + sessionA.client.sessionWorkflowTaskAction.mockReturnValueOnce( + pending.promise, + ); + const addNotice = vi.fn((notice) => notice); + const { actions, sessionRef } = createActionsHarness({ + addNotice, + session: sessionA, + }); + + const result = actions.runSavedWorkflow('deep-review'); + sessionRef.current = sessionB as unknown as DaemonSessionClient; + pending.reject(new Error('old saved workflow failed')); + + await expect(result).rejects.toThrow('old saved workflow failed'); + expect(addNotice).not.toHaveBeenCalled(); + }); + it('aborts active prompts and rejects pending session loads when clearing', async () => { const controller = new AbortController(); const session = createMockSession('session-a'); @@ -1631,6 +1693,7 @@ function createMockSession( })), listWorkspaceSessions: vi.fn(), closeSession: vi.fn(), + sessionWorkflowTaskAction: vi.fn(), }, cancel: vi.fn(async () => undefined), context: vi.fn(async () => contextStatus(sessionId)), @@ -1640,6 +1703,7 @@ function createMockSession( submitPrompt: vi.fn(async () => ({ promptId: 'prompt-1' })), supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)), tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })), + controlWorkflowTask: vi.fn(), }; } diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index b50ddbcadbc..f2d54045630 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -1520,6 +1520,59 @@ export function createDaemonSessionActions({ } }, + async controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Control workflow failed', + 'control_workflow', + ); + try { + return await withActionTimeout( + session.controlWorkflowTask(taskId, action), + 'Control workflow timed out', + ); + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Control workflow failed', + error, + 'control_workflow', + ); + } + }, + + async runSavedWorkflow(name: string) { + const session = requireSessionForAction( + addNotice, + sessionRef.current, + 'Run saved workflow failed', + 'run_saved_workflow', + ); + try { + const { changed, ...result } = await withActionTimeout( + session.client.sessionWorkflowTaskAction( + session.sessionId, + name, + 'run-saved', + session.clientId, + ), + 'Run saved workflow timed out', + ); + return { started: changed, ...result }; + } catch (error) { + throw dispatchActionError( + noticeForSession(session), + 'Run saved workflow failed', + error, + 'run_saved_workflow', + ); + } + }, + async clearGoal() { requireStableSession(); const session = requireSessionForAction( diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index d1f5bce1a58..ad4cb2791ef 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -212,6 +212,8 @@ export type DaemonNoticeOperation = | 'load_tasks' | 'load_artifacts' | 'cancel_task' + | 'control_workflow' + | 'run_saved_workflow' | 'clear_goal' | 'load_stats' | 'rewind_snapshots' @@ -475,6 +477,19 @@ export interface DaemonSessionActions { taskId: string, kind: DaemonSessionTaskStatus['kind'], ): Promise<{ cancelled: boolean }>; + controlWorkflowTask( + taskId: string, + action: 'pause' | 'resume' | 'retry' | 'rerun' | 'delete-history', + ): Promise<{ + changed: boolean; + status?: Extract['status']; + taskId?: string; + }>; + runSavedWorkflow(name: string): Promise<{ + started: boolean; + status?: Extract['status']; + taskId?: string; + }>; clearGoal(): Promise<{ cleared: boolean; condition?: string }>; getStats(): Promise; loadArtifacts(): Promise;