diff --git a/backend/src/services/opencode-restart-coordinator.test.ts b/backend/src/services/opencode-restart-coordinator.test.ts index a3aab0e0..27a2b6a6 100644 --- a/backend/src/services/opencode-restart-coordinator.test.ts +++ b/backend/src/services/opencode-restart-coordinator.test.ts @@ -116,23 +116,21 @@ describe('OpenCodeRestartCoordinator', () => { // Assert interleaved ordering: aborts → restart → resumes expect(events).toEqual([ - 'forward:/session/s1/abort', - 'forward:/session/s2/abort', + 'forward:/api/session/s1/interrupt', + 'forward:/api/session/s2/interrupt', 'restart', - 'forward:/session/s1/prompt_async', - 'forward:/session/s2/prompt_async', + 'forward:/session/s1/message', + 'forward:/session/s2/message', ]) // Aborts called first for both sessions expect(forward).toHaveBeenNthCalledWith(1, { method: 'POST', - path: '/session/s1/abort', - directory: '/a', + path: '/api/session/s1/interrupt', }) expect(forward).toHaveBeenNthCalledWith(2, { method: 'POST', - path: '/session/s2/abort', - directory: '/b', + path: '/api/session/s2/interrupt', }) expect(restart).toHaveBeenCalledOnce() @@ -141,19 +139,23 @@ describe('OpenCodeRestartCoordinator', () => { expect(forward).toHaveBeenCalledTimes(4) expect(forward).toHaveBeenNthCalledWith(3, { method: 'POST', - path: '/session/s1/prompt_async', + path: '/session/s1/message', directory: '/a', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts: [{ type: 'text', text: 'continue' }] }), }) expect(forward).toHaveBeenNthCalledWith(4, { method: 'POST', - path: '/session/s2/prompt_async', + path: '/session/s2/message', directory: '/b', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts: [{ type: 'text', text: 'continue' }] }), }) + // Verify resume body deserializes correctly + const resumeBody = JSON.parse(forward.mock.calls[2]![0].body as string) + expect(resumeBody).toEqual({ parts: [{ type: 'text', text: 'continue' }] }) + expect(result).toEqual({ healthy: true, resumedSessionIDs: ['s1', 's2'] }) }) @@ -172,16 +174,15 @@ describe('OpenCodeRestartCoordinator', () => { // Aborts still happen expect(forward).toHaveBeenNthCalledWith(1, { method: 'POST', - path: '/session/s1/abort', - directory: '/a', + path: '/api/session/s1/interrupt', }) expect(restart).toHaveBeenCalledOnce() - // No prompt_async calls - const promptAsyncCalls = forward.mock.calls.filter( - (call: unknown[]) => (call[0] as { path: string }).path.includes('prompt_async'), + // No resume calls + const resumeCalls = forward.mock.calls.filter( + (call: unknown[]) => (call[0] as { path: string }).path.includes('/message'), ) - expect(promptAsyncCalls).toHaveLength(0) + expect(resumeCalls).toHaveLength(0) expect(result).toEqual({ healthy: false, resumedSessionIDs: [] }) }) @@ -219,12 +220,11 @@ describe('OpenCodeRestartCoordinator', () => { expect(forward).toHaveBeenCalledTimes(2) expect(forward).toHaveBeenNthCalledWith(1, { method: 'POST', - path: '/session/manual1/abort', - directory: '/a', + path: '/api/session/manual1/interrupt', }) expect(forward).toHaveBeenNthCalledWith(2, { method: 'POST', - path: '/session/manual1/prompt_async', + path: '/session/manual1/message', directory: '/a', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts: [{ type: 'text', text: 'continue' }] }), @@ -274,13 +274,11 @@ describe('OpenCodeRestartCoordinator', () => { expect(forward).toHaveBeenCalledTimes(2) expect(forward).toHaveBeenNthCalledWith(1, { method: 'POST', - path: '/session/s1/abort', - directory: '/a', + path: '/api/session/s1/interrupt', }) expect(forward).toHaveBeenNthCalledWith(2, { method: 'POST', - path: '/session/s2/abort', - directory: '/b', + path: '/api/session/s2/interrupt', }) }) }) diff --git a/backend/src/services/opencode-restart-coordinator.ts b/backend/src/services/opencode-restart-coordinator.ts index 65c91bf7..93908658 100644 --- a/backend/src/services/opencode-restart-coordinator.ts +++ b/backend/src/services/opencode-restart-coordinator.ts @@ -45,8 +45,7 @@ export class OpenCodeRestartCoordinator { try { await this.client.forward({ method: 'POST', - path: `/session/${s.sessionID}/abort`, - directory: s.directory, + path: `/api/session/${s.sessionID}/interrupt`, }) } catch (error) { logger.warn(`Failed to abort session ${s.sessionID}: ${error}`) @@ -61,7 +60,7 @@ export class OpenCodeRestartCoordinator { try { const response = await this.client.forward({ method: 'POST', - path: `/session/${s.sessionID}/prompt_async`, + path: `/session/${s.sessionID}/message`, directory: s.directory, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts: [{ type: 'text', text: 'continue' }] }), diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 5f75c5e0..21fbdf5c 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -7,7 +7,6 @@ import { type ScheduleRunTriggerSource, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' import { @@ -40,6 +39,7 @@ import { buildUpdatedSchedulePersistenceInput, computeNextRunAtForJob, } from './schedule-config' +import { buildSchedulePermissionRuleset, type SchedulePermissionRuleset, evaluateSchedulePermission } from '@opencode-manager/shared/schemas' import { resolveOpenCodeModel } from './opencode-models' import type { OpenCodeClient } from './opencode/client' import type { ScheduleWorktreeManager } from './schedule-worktree' @@ -60,45 +60,27 @@ class ScheduleServiceError extends Error { } interface SessionResponse { - id: string -} - -interface PromptResponse { - parts?: Array<{ - type?: string - text?: string - }> + data: { + id: string + } } -interface SessionMessagePart { - type?: string +interface SessionMessageContent { + type: string text?: string } interface SessionMessage { - info?: { - id?: string - sessionID?: string - role?: string - time?: { - created?: number - completed?: number - } - error?: { - name?: string - data?: { - message?: string - } - } - } - parts?: SessionMessagePart[] + type: string + id: string + content?: SessionMessageContent[] + finish?: string + error?: { name?: string; data?: { message?: string } } + time?: { created?: number; completed?: number } } -interface SessionStatus { - type: 'idle' | 'retry' | 'busy' - attempt?: number - message?: string - next?: number +interface SessionActiveEntry { + type: string } const RUN_POLL_INTERVAL_MS = 2_000 @@ -106,18 +88,9 @@ const RUN_POLL_TIMEOUT_MS = 5 * 60_000 interface SessionMonitor { getErrorText(): string | null - isIdle(): boolean dispose(): void } -function extractResponseText(response: PromptResponse): string { - return (response.parts ?? []) - .filter((part) => part.type === 'text' && typeof part.text === 'string') - .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') - .filter(Boolean) - .join('\n\n') -} - function buildSessionTitle(job: ScheduleJob): string { return `Scheduled: ${job.name}` } @@ -253,46 +226,56 @@ function buildRunStartedLog(input: { ].join('\n') } -function parsePromptResponse(responseText: string): PromptResponse | null { - if (!responseText.trim()) { - return null - } - - try { - return JSON.parse(responseText) as PromptResponse - } catch { - return null - } -} - -function extractAssistantMessageText(parts: SessionMessagePart[] | undefined): string { - return (parts ?? []) +function extractAssistantMessageText(content: SessionMessageContent[] | undefined): string { + return (content ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') .filter(Boolean) .join('\n\n') } -function getAssistantMessageState(messages: SessionMessage[]): { - responseText: string | null - errorText: string | null - completed: boolean -} | null { - const assistantMessage = [...messages] - .reverse() - .find((message) => message.info?.role === 'assistant') +function getAssistantMessageState(messages: SessionMessage[]): AssistantState | null { + const assistantMessage = messages.findLast((message) => message.type === 'assistant') if (!assistantMessage) { return null } return { - responseText: extractAssistantMessageText(assistantMessage.parts) || null, - errorText: assistantMessage.info?.error?.data?.message ?? assistantMessage.info?.error?.name ?? null, - completed: Boolean(assistantMessage.info?.time?.completed), + responseText: extractAssistantMessageText(assistantMessage.content) || null, + errorText: assistantMessage.error?.data?.message ?? assistantMessage.error?.name ?? null, + completed: Boolean(assistantMessage.time?.completed), } } +type AssistantState = { + responseText: string | null + errorText: string | null + completed: boolean +} + +type RunTerminalState = { + terminal: true + type: 'completed' | 'failed' + responseText: string | null + errorText: string | null +} | { + terminal: false + type: null + responseText: null + errorText: null +} + +function isRunTerminal(assistantState: AssistantState | null): RunTerminalState { + if (assistantState?.errorText) { + return { terminal: true, type: 'failed', responseText: assistantState.responseText, errorText: assistantState.errorText } + } + if (assistantState?.completed) { + return { terminal: true, type: 'completed', responseText: assistantState.responseText, errorText: null } + } + return { terminal: false, type: null, responseText: null, errorText: null } +} + function getSessionEventId(event: SSEEvent): string | null { const properties = event.properties as { sessionID?: string @@ -315,19 +298,8 @@ function getSessionErrorText(event: SSEEvent): string | null { return properties.error?.data?.message ?? properties.error?.name ?? null } -function getSessionStatusType(event: SSEEvent): string | null { - const properties = event.properties as { - status?: { - type?: string - } - } - - return properties.status?.type ?? null -} - function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { let errorText: string | null = null - let idle = false const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { if (eventDirectory !== directory) { @@ -342,20 +314,10 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni errorText = getSessionErrorText(event) ?? 'The session reported an unknown error.' return } - - if (event.type === 'session.idle') { - idle = true - return - } - - if (event.type === 'session.status' && getSessionStatusType(event) === 'idle') { - idle = true - } }) return { getErrorText: () => errorText, - isIdle: () => idle, dispose: unsubscribe, } } @@ -596,12 +558,11 @@ export class ScheduleService { const sessionTitle = buildSessionTitle(job) const sessionResponse = await this.openCodeClient.forward({ method: 'POST', - path: '/session', - directory: runDirectory, + path: '/api/session', body: JSON.stringify({ - title: sessionTitle, agent: job.agentSlug ?? undefined, - permission: buildSchedulePermissionRuleset(job.permissionConfig), + model: model ? { providerID: model.providerID, id: model.modelID } : undefined, + location: { directory: runDirectory }, }), headers: { 'Content-Type': 'application/json' }, }) @@ -610,7 +571,7 @@ export class ScheduleService { throw new ScheduleServiceError('Failed to create OpenCode session', 502) } - const session = await sessionResponse.json() as SessionResponse + const session = (await sessionResponse.json() as SessionResponse).data const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { sessionId: session.id, sessionTitle, @@ -626,7 +587,21 @@ export class ScheduleService { throw new ScheduleServiceError('Failed to attach session to run', 500) } + try { + const titleResponse = await this.openCodeClient.forward({ + method: 'PATCH', + path: `/session/${session.id}`, + directory: runDirectory, + body: JSON.stringify({ title: sessionTitle }), + headers: { 'Content-Type': 'application/json' }, + }) + if (!titleResponse.ok) logger.warn(`Failed to set session title for ${session.id} (${titleResponse.status})`) + } catch (error) { + logger.warn(`Failed to set session title for ${session.id}:`, error) + } + const sessionMonitor = createSessionMonitor(runDirectory, session.id) + const ruleset = buildSchedulePermissionRuleset(job.permissionConfig) void this.submitPromptAndMonitor({ repoId, @@ -635,8 +610,8 @@ export class ScheduleService { sessionId: session.id, sessionTitle, triggerSource, - model, sessionMonitor, + ruleset, directory: runDirectory, }) @@ -694,17 +669,16 @@ export class ScheduleService { throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) } - const runDirectory = run.worktreePath ?? repo.fullPath - if (run.sessionId) { - const messages = await this.listSessionMessages(runDirectory, run.sessionId) + const messages = await this.listSessionMessages(run.sessionId, run.worktreePath ?? repo.fullPath) const assistantState = getAssistantMessageState(messages) + const terminal = isRunTerminal(assistantState) - if (assistantState?.completed || assistantState?.errorText) { + if (terminal.terminal) { await this.finalizeRecoveredRun(job, run, { - status: assistantState.errorText ? 'failed' : 'completed', - responseText: assistantState.responseText, - errorText: assistantState.errorText, + status: terminal.type, + responseText: terminal.responseText, + errorText: terminal.errorText, }, repo) return this.getRun(repoId, jobId, runId) @@ -712,8 +686,7 @@ export class ScheduleService { const abortResponse = await this.openCodeClient.forward({ method: 'POST', - path: `/session/${run.sessionId}/abort`, - directory: runDirectory, + path: `/api/session/${run.sessionId}/interrupt`, }) if (!abortResponse.ok) { @@ -764,63 +737,41 @@ export class ScheduleService { sessionId: string sessionTitle: string triggerSource: ScheduleRunTriggerSource - model: { providerID: string; modelID: string } sessionMonitor: SessionMonitor + ruleset: SchedulePermissionRuleset directory: string }): Promise { const repo = this.assertRepo(input.repoId) try { - const promptResponse = await this.openCodeClient.forward({ - method: 'POST', - path: `/session/${input.sessionId}/message`, - directory: input.directory, - body: JSON.stringify({ - parts: [{ type: 'text', text: await buildPromptWithSkills(input.job.prompt, input.job.skillMetadata, input.directory, this.openCodeClient) }], - model: input.model, - }), - headers: { 'Content-Type': 'application/json' }, - }) - - if (!promptResponse.ok) { - const errorText = await promptResponse.text() - throw new ScheduleServiceError(errorText || 'Failed to run scheduled prompt', 502) - } - - const promptBody = await promptResponse.text() - const promptResult = parsePromptResponse(promptBody) - - if (promptResult) { - const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) - if (!currentRun || currentRun.status !== 'running') { - return + const promptText = await buildPromptWithSkills(input.job.prompt, input.job.skillMetadata, input.directory, this.openCodeClient) + + // POST /session/{id}/message (the SDK's session.prompt) drives the agent turn and + // resolves when it completes. Fire it without awaiting completion so monitorRunCompletion + // can poll for the result, answer pending permissions, and enforce the stall timeout. + // A failed submit is captured and surfaced to the monitor loop so the run fails fast. + let submitErrorText: string | null = null + const submitPromise = (async () => { + try { + const promptResponse = await this.openCodeClient.forward({ + method: 'POST', + path: `/session/${input.sessionId}/message`, + directory: input.directory, + body: JSON.stringify({ + parts: [{ type: 'text', text: promptText }], + }), + headers: { 'Content-Type': 'application/json' }, + }) + if (!promptResponse.ok) { + const errorText = await promptResponse.text() + submitErrorText = errorText || 'Failed to run scheduled prompt' + } + } catch (error) { + submitErrorText = getErrorMessage(error) } + })() - const finishedAt = Date.now() - const responseText = extractResponseText(promptResult) - updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { - status: 'completed', - finishedAt, - sessionId: input.sessionId, - sessionTitle: input.sessionTitle, - responseText, - logText: buildRunLog({ - job: input.job, - triggerSource: input.triggerSource, - sessionId: input.sessionId, - sessionTitle: input.sessionTitle, - responseText, - finishedAt, - }), - }) - - updateScheduleJobRunState(this.db, input.repoId, input.job.id, { - lastRunAt: finishedAt, - nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), - }) - - return - } + await this.respondToPendingRequests(input.sessionId, input.ruleset, input.directory) await this.monitorRunCompletion({ sessionMonitor: input.sessionMonitor, @@ -830,8 +781,11 @@ export class ScheduleService { sessionId: input.sessionId, sessionTitle: input.sessionTitle, triggerSource: input.triggerSource, + ruleset: input.ruleset, directory: input.directory, + getSubmitError: () => submitErrorText, }) + await submitPromise return } catch (error) { const finishedAt = Date.now() @@ -878,36 +832,16 @@ export class ScheduleService { sessionId: string sessionTitle: string triggerSource: ScheduleRunTriggerSource + ruleset: SchedulePermissionRuleset directory: string - initialSessionStatus?: SessionStatus + getSubmitError?: () => string | null }): Promise { try { - const sessionStatus = input.initialSessionStatus - if (sessionStatus && sessionStatus.type === 'idle') { - const repo = this.assertRepo(input.repoId) - const messages = await this.listSessionMessages(input.directory, input.sessionId) - const assistantState = getAssistantMessageState(messages) - if (assistantState?.completed || assistantState?.errorText) { - await this.finalizeRecoveredRun(input.job, { - id: input.runId, - repoId: input.repoId, - jobId: input.job.id, - sessionId: input.sessionId, - sessionTitle: input.sessionTitle, - triggerSource: input.triggerSource, - } as ScheduleRun, { - status: assistantState.errorText ? 'failed' : 'completed', - responseText: assistantState.responseText, - errorText: assistantState.errorText, - }, repo) - return - } - } - const repo = this.assertRepo(input.repoId) - const currentMessages = await this.listSessionMessages(input.directory, input.sessionId) + const currentMessages = await this.listSessionMessages(input.sessionId, input.directory) const currentAssistantState = getAssistantMessageState(currentMessages) - if (currentAssistantState?.completed || currentAssistantState?.errorText) { + const terminal = isRunTerminal(currentAssistantState) + if (terminal.terminal) { await this.finalizeRecoveredRun(input.job, { id: input.runId, repoId: input.repoId, @@ -916,14 +850,14 @@ export class ScheduleService { sessionTitle: input.sessionTitle, triggerSource: input.triggerSource, } as ScheduleRun, { - status: currentAssistantState.errorText ? 'failed' : 'completed', - responseText: currentAssistantState.responseText, - errorText: currentAssistantState.errorText, + status: terminal.type, + responseText: terminal.responseText, + errorText: terminal.errorText, }, repo) return } - const response = await this.waitForAssistantMessage(input.job, input.sessionId, input.sessionMonitor, input.directory) + const response = await this.waitForAssistantMessage(input.job, input.sessionId, input.directory, input.sessionMonitor, input.ruleset, input.getSubmitError) const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) if (!currentRun || currentRun.status !== 'running') { return @@ -1008,6 +942,74 @@ export class ScheduleService { } } + private async respondToPendingRequests(sessionId: string, ruleset: SchedulePermissionRuleset, directory: string): Promise { + try { + const permResponse = await this.openCodeClient.forward({ + method: 'GET', + path: `/api/session/${sessionId}/permission`, + directory, + }) + + if (permResponse.ok) { + const permBody = await permResponse.json() as { data: Array<{ id: string; action: string; resources: string[] }> } + for (const req of permBody.data) { + try { + const verdict = evaluateSchedulePermission(ruleset, req.action, req.resources ?? []) + const replyBody = verdict === 'deny' + ? { reply: 'reject', message: 'Denied by schedule permission config' } + : { reply: 'once' } + const replyResponse = await this.openCodeClient.forward({ + method: 'POST', + path: `/api/session/${sessionId}/permission/${req.id}/reply`, + directory, + body: JSON.stringify(replyBody), + headers: { 'Content-Type': 'application/json' }, + }) + if (!replyResponse.ok && replyResponse.status !== 404) { + logger.warn(`Unexpected status ${replyResponse.status} replying to permission ${req.id} in session ${sessionId}`) + } + } catch (innerError) { + logger.warn(`Failed to reply to permission ${req.id} in session ${sessionId}:`, innerError) + } + } + } else if (permResponse.status !== 404) { + logger.warn(`Unexpected status ${permResponse.status} fetching permissions for session ${sessionId}`) + } + } catch (error) { + logger.warn(`Failed to fetch permissions for session ${sessionId}:`, error) + } + + try { + const questionResponse = await this.openCodeClient.forward({ + method: 'GET', + path: `/api/session/${sessionId}/question`, + directory, + }) + + if (questionResponse.ok) { + const questionBody = await questionResponse.json() as { data: Array<{ id: string }> } + for (const q of questionBody.data) { + try { + const rejectResponse = await this.openCodeClient.forward({ + method: 'POST', + path: `/api/session/${sessionId}/question/${q.id}/reject`, + directory, + }) + if (!rejectResponse.ok && rejectResponse.status !== 404) { + logger.warn(`Unexpected status ${rejectResponse.status} rejecting question ${q.id} in session ${sessionId}`) + } + } catch (innerError) { + logger.warn(`Failed to reject question ${q.id} in session ${sessionId}:`, innerError) + } + } + } else if (questionResponse.status !== 404) { + logger.warn(`Unexpected status ${questionResponse.status} fetching questions for session ${sessionId}`) + } + } catch (error) { + logger.warn(`Failed to fetch questions for session ${sessionId}:`, error) + } + } + private async recoverRunningRun(job: ScheduleJob, run: ScheduleRun): Promise { try { const repo = this.assertRepo(job.repoId) @@ -1021,23 +1023,25 @@ export class ScheduleService { return } - const messages = await this.listSessionMessages(runDirectory, run.sessionId) + const messages = await this.listSessionMessages(run.sessionId, runDirectory) const assistantState = getAssistantMessageState(messages) - if (assistantState?.completed || assistantState?.errorText) { + const activeSessions = await this.getActiveSessions(runDirectory) + const sessionActive = run.sessionId ? run.sessionId in activeSessions : false + const terminal = isRunTerminal(assistantState) + + if (terminal.terminal) { await this.finalizeRecoveredRun(job, run, { - status: assistantState.errorText ? 'failed' : 'completed', - responseText: assistantState.responseText, - errorText: assistantState.errorText, + status: terminal.type, + responseText: terminal.responseText, + errorText: terminal.errorText, }, repo) return } - const sessionStatuses = await this.getSessionStatuses(runDirectory) - const sessionStatus = run.sessionId ? sessionStatuses[run.sessionId] : undefined - - if (sessionStatus && sessionStatus.type !== 'idle') { + if (sessionActive) { const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId) + const ruleset = buildSchedulePermissionRuleset(job.permissionConfig) void this.monitorRunCompletion({ sessionMonitor, repoId: run.repoId, @@ -1046,7 +1050,7 @@ export class ScheduleService { sessionId: run.sessionId, sessionTitle: run.sessionTitle ?? buildSessionTitle(job), triggerSource: run.triggerSource, - initialSessionStatus: sessionStatus, + ruleset, directory: runDirectory, }) return @@ -1134,16 +1138,25 @@ export class ScheduleService { private async waitForAssistantMessage( job: ScheduleJob, sessionId: string, - sessionMonitor: SessionMonitor, directory: string, + sessionMonitor: SessionMonitor, + ruleset: SchedulePermissionRuleset, + getSubmitError?: () => string | null, ): Promise<{ responseText: string | null; errorText: string | null }> { - const startedAt = Date.now() + let timeoutStartedAt: number | null = null - while (Date.now() - startedAt < RUN_POLL_TIMEOUT_MS) { - const messages = await this.listSessionMessages(directory, sessionId) + while (true) { + await this.respondToPendingRequests(sessionId, ruleset, directory) + + const submitErrorText = getSubmitError?.() + if (submitErrorText) { + return { responseText: null, errorText: submitErrorText } + } + + const messages = await this.listSessionMessages(sessionId, directory) const assistantState = getAssistantMessageState(messages) - if (assistantState && (assistantState.completed || assistantState.errorText)) { + if (assistantState?.errorText) { return { responseText: assistantState.responseText, errorText: assistantState.errorText, @@ -1158,23 +1171,39 @@ export class ScheduleService { } } - if (sessionMonitor.isIdle()) { + const activeSessions = await this.getActiveSessions(directory) + const sessionActive = sessionId in activeSessions + + if (assistantState?.completed) { + return { + responseText: assistantState.responseText, + errorText: null, + } + } + + if (sessionActive) { + timeoutStartedAt = null + await Bun.sleep(RUN_POLL_INTERVAL_MS) + continue + } + + if (timeoutStartedAt === null) { + timeoutStartedAt = Date.now() + } else if (Date.now() - timeoutStartedAt >= RUN_POLL_TIMEOUT_MS) { return { responseText: null, - errorText: 'The session became idle without producing an assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', + errorText: 'Timed out waiting for the assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', } } await Bun.sleep(RUN_POLL_INTERVAL_MS) } - - return { - responseText: null, - errorText: 'Timed out waiting for the assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', - } } - private async listSessionMessages(directory: string, sessionId: string): Promise { + private async listSessionMessages(sessionId: string, directory: string): Promise { + // Uses the SDK's session.messages endpoint (GET /session/{id}/message), which returns + // the directory-scoped message list as a plain array of { info, parts }. The /api/session + // variant does not resolve worktree-scoped sessions reliably, so a directory is required. const messagesResponse = await this.openCodeClient.forward({ method: 'GET', path: `/session/${sessionId}/message`, @@ -1186,22 +1215,39 @@ export class ScheduleService { throw new ScheduleServiceError(errorText || 'Failed to fetch session messages', 502) } - return await messagesResponse.json() as SessionMessage[] + const body = await messagesResponse.json() as Array<{ + info?: { + id?: string + role?: string + time?: { created?: number; completed?: number } + error?: { name?: string; data?: { message?: string } } + } + parts?: SessionMessageContent[] + }> + + return body.map((message) => ({ + id: message.info?.id ?? '', + type: message.info?.role ?? '', + content: message.parts, + error: message.info?.error, + time: message.info?.time, + })) } - private async getSessionStatuses(directory: string): Promise> { + private async getActiveSessions(directory: string): Promise> { const response = await this.openCodeClient.forward({ method: 'GET', - path: '/session/status', + path: '/api/session/active', directory, }) if (!response.ok) { const errorText = await response.text() - throw new ScheduleServiceError(errorText || 'Failed to fetch session statuses', 502) + throw new ScheduleServiceError(errorText || 'Failed to fetch active sessions', 502) } - return await response.json() as Record + const body = await response.json() as { data: Record } + return body.data } private assertRepo(repoId: number) { diff --git a/backend/test/services/schedule-permissions.test.ts b/backend/test/services/schedule-permissions.test.ts index f2ed7bad..b4d21835 100644 --- a/backend/test/services/schedule-permissions.test.ts +++ b/backend/test/services/schedule-permissions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_DESTRUCTIVE_BASH_PATTERNS, buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { + DEFAULT_DESTRUCTIVE_BASH_PATTERNS, + buildSchedulePermissionRuleset, + evaluateSchedulePermission, +} from '@opencode-manager/shared/schemas' describe('buildSchedulePermissionRuleset', () => { it('returns the allow-all baseline with default deny rules when given null', () => { @@ -28,3 +32,34 @@ describe('buildSchedulePermissionRuleset', () => { ]) }) }) + +describe('evaluateSchedulePermission', () => { + it('denies bash sudo commands with the default ruleset', () => { + const ruleset = buildSchedulePermissionRuleset(null) + expect(evaluateSchedulePermission(ruleset, 'bash', ['sudo rm -rf /'])).toBe('deny') + }) + + it('allows benign bash commands with the default ruleset', () => { + const ruleset = buildSchedulePermissionRuleset(null) + expect(evaluateSchedulePermission(ruleset, 'bash', ['git status'])).toBe('allow') + }) + + it('denies external_directory access with the default ruleset', () => { + const ruleset = buildSchedulePermissionRuleset(null) + expect(evaluateSchedulePermission(ruleset, 'external_directory', ['/etc'])).toBe('deny') + }) + + it('allows external_directory when allowExternalDirectory is true', () => { + const ruleset = buildSchedulePermissionRuleset({ allowExternalDirectory: true, bashDenyPatterns: [] }) + expect(evaluateSchedulePermission(ruleset, 'external_directory', ['/some/path'])).toBe('allow') + }) + + it('denies when at least one resource matches a deny pattern', () => { + const ruleset = buildSchedulePermissionRuleset(null) + expect(evaluateSchedulePermission(ruleset, 'bash', ['git status', 'sudo rm -rf /'])).toBe('deny') + }) + + it('asks for an unmatched action with an empty ruleset', () => { + expect(evaluateSchedulePermission([], 'bash', ['git status'])).toBe('ask') + }) +}) diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index c8428f23..b6012b28 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' const mocks = vi.hoisted(() => ({ getRepoById: vi.fn(), @@ -30,7 +29,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenCodeModel: vi.fn(), forward: vi.fn(), onEvent: vi.fn(), - loggerError: vi.fn(), + loggerWarn: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { prepare: vi.fn().mockResolvedValue(null), @@ -84,9 +83,9 @@ vi.mock('../../src/services/sse-aggregator', () => ({ vi.mock('../../src/utils/logger', () => ({ logger: { - error: mocks.loggerError, + error: vi.fn(), info: vi.fn(), - warn: vi.fn(), + warn: mocks.loggerWarn, }, })) @@ -110,6 +109,37 @@ function textResponse(body: string, status: number = 200): Response { return new Response(body, { status }) } +function promptReceipt(): Response { + return jsonResponse({ + data: { + admittedSeq: 1, + id: 'msg-1', + sessionID: 'ses-test', + delivery: 'steer', + timeCreated: Math.floor(Date.now() / 1000), + }, + }) +} + +function v2Messages(messages: Array<{ + type: string + id?: string + content?: Array<{ type: string; text?: string }> + time?: { created?: number; completed?: number } + finish?: string + error?: { name?: string; data?: { message?: string } } +}>): Response { + return jsonResponse(messages.map(m => ({ + info: { + id: m.id ?? 'msg-1', + role: m.type, + time: m.time, + error: m.error, + }, + parts: m.content, + }))) +} + function createOpenCodeClientStub(): OpenCodeClient { return { forward: mocks.forward, @@ -176,7 +206,7 @@ const baseRun: ScheduleRun = { workspaceId: null, } -describe('ScheduleService permission ruleset in session creation', () => { +describe('ScheduleService permission auto-responder', () => { beforeEach(() => { vi.clearAllMocks() Reflect.get(ScheduleService, 'activeRuns').clear() @@ -189,32 +219,46 @@ describe('ScheduleService permission ruleset in session creation', () => { mocks.onEvent.mockReturnValue(vi.fn()) mocks.getScheduleRunById.mockReturnValue({ ...baseRun, - sessionId: 'ses-perm-test', + sessionId: 'ses-perm-auto', sessionTitle: 'Scheduled: Weekly engineering summary', logText: 'Run started.', }) }) - it('sends default permission ruleset when job.permissionConfig is null', async () => { + it('denies dangerous bash command with default permission config', async () => { mocks.getScheduleJobById.mockReturnValue(baseJob) const runWithSession: ScheduleRun = { ...baseRun, - sessionId: 'ses-perm-1', + sessionId: 'ses-perm-auto', sessionTitle: 'Scheduled: Weekly engineering summary', logText: 'Run started.', } mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return jsonResponse({ id: 'ses-perm-1' }) + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [{ id: 'perm-1', action: 'bash', resources: ['sudo rm -rf /'] }] }) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [] }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { - return textResponse('') + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { - return jsonResponse([{ info: { role: 'assistant', time: { completed: Date.now() } }, parts: [] }]) + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) } throw new Error(`Unexpected forward request: ${method} ${path}`) }) @@ -222,39 +266,61 @@ describe('ScheduleService permission ruleset in session creation', () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) await service.runJob(42, 7, 'manual') - const sessionCall = mocks.forward.mock.calls.find( - ([req]) => (req as ForwardRequest).path === '/session' && (req as ForwardRequest).method === 'POST', - ) - expect(sessionCall).toBeDefined() - const body = JSON.parse((sessionCall![0] as ForwardRequest).body!) - expect(body.title).toBe('Scheduled: Weekly engineering summary') - expect(body.agent).toBeUndefined() + await vi.waitFor(() => { + const replyCall = mocks.forward.mock.calls.find( + ([req]) => (req as ForwardRequest).path === `/api/session/ses-perm-auto/permission/perm-1/reply` + && (req as ForwardRequest).method === 'POST', + ) + expect(replyCall).toBeDefined() + const body = JSON.parse((replyCall![0] as ForwardRequest).body!) + expect(body).toEqual({ reply: 'reject', message: 'Denied by schedule permission config' }) + }) - const expectedRules = buildSchedulePermissionRuleset(null) - expect(body.permission).toEqual(expectedRules) + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) }) - it('sends custom permission ruleset when job has custom permissionConfig', async () => { - const customConfig = { allowExternalDirectory: true, bashDenyPatterns: [] } - mocks.getScheduleJobById.mockReturnValue({ ...baseJob, permissionConfig: customConfig }) + it('auto-approves benign bash command with reply: once', async () => { + mocks.getScheduleJobById.mockReturnValue(baseJob) const runWithSession: ScheduleRun = { ...baseRun, - sessionId: 'ses-perm-2', + sessionId: 'ses-perm-auto', sessionTitle: 'Scheduled: Weekly engineering summary', logText: 'Run started.', } mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return jsonResponse({ id: 'ses-perm-2' }) + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [{ id: 'perm-2', action: 'bash', resources: ['git status'] }] }) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [] }) + } + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { - return textResponse('') + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { - return jsonResponse([{ info: { role: 'assistant', time: { completed: Date.now() } }, parts: [] }]) + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) } throw new Error(`Unexpected forward request: ${method} ${path}`) }) @@ -262,38 +328,103 @@ describe('ScheduleService permission ruleset in session creation', () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) await service.runJob(42, 7, 'manual') - const sessionCall = mocks.forward.mock.calls.find( - ([req]) => (req as ForwardRequest).path === '/session' && (req as ForwardRequest).method === 'POST', - ) - expect(sessionCall).toBeDefined() - const body = JSON.parse((sessionCall![0] as ForwardRequest).body!) - expect(body.title).toBe('Scheduled: Weekly engineering summary') - expect(body.agent).toBeUndefined() + await vi.waitFor(() => { + const replyCall = mocks.forward.mock.calls.find( + ([req]) => (req as ForwardRequest).path === `/api/session/ses-perm-auto/permission/perm-2/reply` + && (req as ForwardRequest).method === 'POST', + ) + expect(replyCall).toBeDefined() + const body = JSON.parse((replyCall![0] as ForwardRequest).body!) + expect(body).toEqual({ reply: 'once' }) + }) + }) + + it('rejects external_directory with allowExternalDirectory: false', async () => { + mocks.getScheduleJobById.mockReturnValue({ ...baseJob, permissionConfig: { allowExternalDirectory: false, bashDenyPatterns: [] } }) + + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-perm-auto', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started.', + } + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) - const expectedRules = buildSchedulePermissionRuleset(customConfig) - expect(body.permission).toEqual(expectedRules) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [{ id: 'perm-3', action: 'external_directory', resources: ['/etc'] }] }) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [] }) + } + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) + } + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) + } + throw new Error(`Unexpected forward request: ${method} ${path}`) + }) + + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + const replyCall = mocks.forward.mock.calls.find( + ([req]) => (req as ForwardRequest).path === `/api/session/ses-perm-auto/permission/perm-3/reply` + && (req as ForwardRequest).method === 'POST', + ) + expect(replyCall).toBeDefined() + const body = JSON.parse((replyCall![0] as ForwardRequest).body!) + expect(body).toEqual({ reply: 'reject', message: 'Denied by schedule permission config' }) + }) }) - it('preserves existing fields (title, agent) when permission is added', async () => { - mocks.getScheduleJobById.mockReturnValue({ ...baseJob, agentSlug: 'my-agent' }) + it('approves external_directory with allowExternalDirectory: true', async () => { + mocks.getScheduleJobById.mockReturnValue({ ...baseJob, permissionConfig: { allowExternalDirectory: true, bashDenyPatterns: [] } }) const runWithSession: ScheduleRun = { ...baseRun, - sessionId: 'ses-perm-3', + sessionId: 'ses-perm-auto', sessionTitle: 'Scheduled: Weekly engineering summary', logText: 'Run started.', } mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return jsonResponse({ id: 'ses-perm-3' }) + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [{ id: 'perm-4', action: 'external_directory', resources: ['/some/path'] }] }) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [] }) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'POST') { - return textResponse('') + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) } - if (path.match(/^\/session\/[\w-]+\/message$/) && method === 'GET') { - return jsonResponse([{ info: { role: 'assistant', time: { completed: Date.now() } }, parts: [] }]) + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) } throw new Error(`Unexpected forward request: ${method} ${path}`) }) @@ -301,14 +432,171 @@ describe('ScheduleService permission ruleset in session creation', () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) await service.runJob(42, 7, 'manual') - const sessionCall = mocks.forward.mock.calls.find( - ([req]) => (req as ForwardRequest).path === '/session' && (req as ForwardRequest).method === 'POST', - ) - expect(sessionCall).toBeDefined() - const body = JSON.parse((sessionCall![0] as ForwardRequest).body!) - expect(body.title).toBe('Scheduled: Weekly engineering summary') - expect(body.agent).toBe('my-agent') - expect(body.permission).toBeDefined() - expect(body.permission[0]).toEqual({ permission: '*', pattern: '*', action: 'allow' }) + await vi.waitFor(() => { + const replyCall = mocks.forward.mock.calls.find( + ([req]) => (req as ForwardRequest).path === `/api/session/ses-perm-auto/permission/perm-4/reply` + && (req as ForwardRequest).method === 'POST', + ) + expect(replyCall).toBeDefined() + const body = JSON.parse((replyCall![0] as ForwardRequest).body!) + expect(body).toEqual({ reply: 'once' }) + }) + }) + + it('rejects pending questions', async () => { + mocks.getScheduleJobById.mockReturnValue(baseJob) + + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-perm-auto', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started.', + } + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [] }) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [{ id: 'q-1' }] }) + } + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) + } + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) + } + throw new Error(`Unexpected forward request: ${method} ${path}`) + }) + + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.forward).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: `/api/session/ses-perm-auto/question/q-1/reject`, + }), + ) + }) + }) + + it('completes the run despite permission endpoint returning 500', async () => { + mocks.getScheduleJobById.mockReturnValue(baseJob) + + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-perm-auto', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started.', + } + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return textResponse('Internal Server Error', 500) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return textResponse('Internal Server Error', 500) + } + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) + } + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) + } + throw new Error(`Unexpected forward request: ${method} ${path}`) + }) + + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) + }) + + it('handles individual permission reply failure without affecting other replies', async () => { + mocks.getScheduleJobById.mockReturnValue(baseJob) + + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-perm-auto', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started.', + } + let replyCount = 0 + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-perm-auto' } }) + } + if (path === `/session/ses-perm-auto/message` && method === 'POST') { + return promptReceipt() + } + if (path === `/api/session/ses-perm-auto/permission` && method === 'GET') { + return jsonResponse({ data: [ + { id: 'perm-ok', action: 'bash', resources: ['git status'] }, + { id: 'perm-fail', action: 'bash', resources: ['sudo rm -rf /'] }, + ]}) + } + if (path === `/api/session/ses-perm-auto/question` && method === 'GET') { + return jsonResponse({ data: [] }) + } + if (path.match(/^\/api\/session\/ses-perm-auto\/permission\/[\w-]+\/reply$/) && method === 'POST') { + replyCount++ + return jsonResponse({}) + } + if (path.startsWith('/session/ses-perm-auto/message') && method === 'GET') { + return v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ]) + } + if (path === '/api/session/active' && method === 'GET') { + return jsonResponse({ data: {} }) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) + } + throw new Error(`Unexpected forward request: ${method} ${path}`) + }) + + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(replyCount).toBe(2) + }) }) }) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index aa22a063..63d79855 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -116,6 +116,37 @@ function textResponse(body: string, status: number = 200): Response { return new Response(body, { status }) } +function promptReceipt(): Response { + return jsonResponse({ + data: { + admittedSeq: 1, + id: 'msg-1', + sessionID: 'ses-test', + delivery: 'steer', + timeCreated: Math.floor(Date.now() / 1000), + }, + }) +} + +function v2Messages(messages: Array<{ + type: string + id?: string + content?: Array<{ type: string; text?: string }> + time?: { created?: number; completed?: number } + finish?: string + error?: { name?: string; data?: { message?: string } } +}>): Response { + return jsonResponse(messages.map(m => ({ + info: { + id: m.id ?? 'msg-1', + role: m.type, + time: m.time, + error: m.error, + }, + parts: m.content, + }))) +} + function createOpenCodeClientStub(): OpenCodeClient { return { forward: mocks.forward, @@ -213,23 +244,28 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-run-1' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-run-1' } })) } - if (path === '/session/ses-run-1/message' && method === 'POST') { - return Promise.resolve(textResponse('')) + if (path === `/session/ses-run-1/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) } - if (path === '/session/ses-run-1/message' && method === 'GET') { - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'System health is stable.' }], - }, + if (path.startsWith('/session/ses-run-1/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Stale status.' }], time: { created: 1, completed: 2 }, finish: 'stop' }, + { type: 'assistant', content: [{ type: 'text', text: 'System health is stable.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, ])) } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -259,6 +295,59 @@ describe('ScheduleService', () => { ) }) + it('strips thinking blocks from V2 assistant messages', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-think', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-think' } })) + } + if (path === `/session/ses-think/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-think/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { + type: 'assistant', + content: [{ type: 'text', text: 'Let me analyze the system logs...\nThe database connection is healthy.The database connection is healthy.' }], + time: { created: 1000, completed: 2000 }, + finish: 'stop', + }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'The database connection is healthy.', + }), + ) + }) + }) + it('sends session and message JSON POSTs with Content-Type: application/json', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) const runWithSession: ScheduleRun = { @@ -270,11 +359,22 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return jsonResponse({ id: 'ses-content-type' }) + if (path === '/api/session' && method === 'POST') { + return jsonResponse({ data: { id: 'ses-content-type' } }) + } + if (path === `/session/ses-content-type/message` && method === 'POST') { + return promptReceipt() } - if (path === '/session/ses-content-type/message' && method === 'POST') { - return textResponse(JSON.stringify({ parts: [{ type: 'text', text: 'Done.' }] })) + if (path.startsWith('/session/ses-content-type/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return jsonResponse({}) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected forward request: ${method} ${path}`) }) @@ -285,21 +385,76 @@ describe('ScheduleService', () => { expect(mocks.forward).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', - path: '/session', + path: '/api/session', headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }), ) expect(mocks.forward).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', - path: '/session/ses-content-type/message', + path: `/session/ses-content-type/message`, headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }), ) }) }) - it('completes a run immediately when the prompt endpoint returns JSON', async () => { + it('proceeds despite title PATCH failure', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-patch-fail', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-patch-fail' } })) + } + + if (path === `/session/ses-patch-fail/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + + if (path.startsWith('/session/ses-patch-fail/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Completed despite title issue.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(textResponse('Server Error', 500)) + } + + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + const result = await service.runJob(42, 7, 'manual') + + expect(result).toEqual(runWithSession) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Completed despite title issue.', + sessionId: 'ses-patch-fail', + }), + ) + }) + }) + + it('completes a run after prompting the session', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) const runWithSession: ScheduleRun = { ...baseRun, @@ -311,16 +466,27 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-run-2' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-run-2' } })) } - if (path === '/session/ses-run-2/message' && method === 'POST') { - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Immediate status summary.' }], - }))) + if (path === `/session/ses-run-2/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) } + if (path.startsWith('/session/ses-run-2/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Immediate status summary.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -395,14 +561,25 @@ describe('ScheduleService', () => { mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) mocks.getScheduleRunById.mockReturnValue(runWithSession) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-run-6' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-run-6' } })) } - if (path === '/session/ses-run-6/message' && method === 'POST') { + if (path === `/session/ses-run-6/message` && method === 'POST') { return Promise.resolve(textResponse('Provider unavailable', 500)) } + if (path.startsWith('/session/ses-run-6/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) + } + + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -425,6 +602,229 @@ describe('ScheduleService', () => { }) }) + it('fails the run when the V2 assistant message carries an error', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-err-v2', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-err-v2' } })) + } + if (path === `/session/ses-err-v2/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-err-v2/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Partial output' }], error: { name: 'provider_error', data: { message: 'Model crashed' } } }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + responseText: 'Partial output', + errorText: 'Model crashed', + }), + ) + }) + }) + + it('completes when the session stays active longer than RUN_POLL_TIMEOUT_MS', async () => { + let fakeNow = 1_700_000_000_000 + const RUN_POLL_INTERVAL_MS = 2_000 + + vi.stubGlobal('Bun', { sleep: vi.fn(async () => { fakeNow += RUN_POLL_INTERVAL_MS }) }) + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => fakeNow) + + try { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-active-long', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + let activePolls = 0 + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-active-long' } })) + } + if (path === `/session/ses-active-long/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-active-long/message') && method === 'GET') { + activePolls++ + if (activePolls <= 155) { + return Promise.resolve(v2Messages([])) + } + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Completed after active period' }], time: { created: 1000, completed: 2000 }, finish: 'stop' }, + ])) + } + if (path === '/api/session/active' && method === 'GET') { + if (activePolls > 155) { + return Promise.resolve(jsonResponse({ data: {} })) + } + return Promise.resolve(jsonResponse({ data: { 'ses-active-long': { type: 'running' } } })) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Completed after active period', + }), + ) + }) + } finally { + nowSpy.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('completes when assistant message has time.completed even if session remains in active map', async () => { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-completed-still-active', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-completed-still-active' } })) + } + if (path === `/session/ses-completed-still-active/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-completed-still-active/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Completed while still active.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: { 'ses-completed-still-active': { type: 'running' } } })) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Completed while still active.', + sessionId: 'ses-completed-still-active', + }), + ) + }) + }) + + it('times out when the session is inactive and never settles', async () => { + let fakeNow = 1_700_000_000_000 + const RUN_POLL_INTERVAL_MS = 2_000 + + vi.stubGlobal('Bun', { sleep: vi.fn(async () => { fakeNow += RUN_POLL_INTERVAL_MS }) }) + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => fakeNow) + + try { + const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-timeout', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + routeForward(({ path, method }) => { + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-timeout' } })) + } + if (path === `/session/ses-timeout/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-timeout/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) + } + if (path === '/api/session/active' && method === 'GET') { + return Promise.resolve(jsonResponse({ data: {} })) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + errorText: expect.stringContaining('Timed out'), + }), + ) + }) + } finally { + nowSpy.mockRestore() + vi.unstubAllGlobals() + } + }) + it('cancels an in-progress run by aborting the linked session', async () => { const service = new ScheduleService({} as never, createOpenCodeClientStub(), mocks.stubWorktreeManager as never) const runningRun: ScheduleRun = { @@ -443,14 +843,17 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runningRun) mocks.updateScheduleRun.mockReturnValue(cancelledRun) routeForward(({ path, method }) => { - if (path === '/session/ses-run-3/message' && method === 'GET') { - return Promise.resolve(jsonResponse([])) + if (path.startsWith('/session/ses-run-3/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) } - if (path === '/session/ses-run-3/abort' && method === 'POST') { + if (path === `/api/session/ses-run-3/interrupt` && method === 'POST') { return Promise.resolve(textResponse('')) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -459,9 +862,8 @@ describe('ScheduleService', () => { expect(result).toEqual(cancelledRun) expect(mocks.forward).toHaveBeenCalledWith( expect.objectContaining({ - path: '/session/ses-run-3/abort', + path: `/api/session/ses-run-3/interrupt`, method: 'POST', - directory: repo.fullPath, }), ) expect(mocks.updateScheduleRun).toHaveBeenCalledWith( @@ -522,14 +924,17 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValue(runningRun) routeForward(({ path, method }) => { - if (path === '/session/ses-run-7/message' && method === 'GET') { - return Promise.resolve(jsonResponse([])) + if (path.startsWith('/session/ses-run-7/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) } - if (path === '/session/ses-run-7/abort' && method === 'POST') { + if (path === `/api/session/ses-run-7/interrupt` && method === 'POST') { return Promise.resolve(textResponse('Abort refused', 500)) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -551,19 +956,14 @@ describe('ScheduleService', () => { mocks.listRunningScheduleRuns.mockReturnValue([orphanedRun]) routeForward(({ path, method }) => { - if (path === '/session/ses-run-4/message' && method === 'GET') { - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant' }, - parts: [{ type: 'text', text: 'Partial summary' }], - }, + if (path.startsWith('/session/ses-run-4/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Partial summary' }] }, ])) } - if (path === '/session/status' && method === 'GET') { - return Promise.resolve(jsonResponse({ - 'ses-run-4': { type: 'idle' }, - })) + if (path === '/api/session/active' && method === 'GET') { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) @@ -620,15 +1020,15 @@ describe('ScheduleService', () => { mocks.listRunningScheduleRuns.mockReturnValue([completedRun]) routeForward(({ path, method }) => { - if (path === '/session/ses-run-8/message' && method === 'GET') { - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'Recovered summary' }], - }, + if (path.startsWith('/session/ses-run-8/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Recovered summary' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, ])) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -655,29 +1055,26 @@ describe('ScheduleService', () => { sessionTitle: 'Scheduled: Weekly engineering summary', } let messageRequests = 0 + let assistantSeen = false mocks.listRunningScheduleRuns.mockReturnValue([resumedRun]) mocks.getScheduleRunById.mockReturnValue(resumedRun) routeForward(({ path, method }) => { - if (path === '/session/ses-run-9/message' && method === 'GET') { + if (path.startsWith('/session/ses-run-9/message') && method === 'GET') { messageRequests += 1 if (messageRequests === 1) { - return Promise.resolve(jsonResponse([])) + return Promise.resolve(v2Messages([])) } - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'Recovered after reconnect' }], - }, + assistantSeen = true + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Recovered after reconnect' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, ])) } - if (path === '/session/status' && method === 'GET') { - return Promise.resolve(jsonResponse({ - 'ses-run-9': { type: 'busy' }, - })) + if (path === '/api/session/active' && method === 'GET') { + return Promise.resolve(jsonResponse({ data: assistantSeen ? {} : { 'ses-run-9': { type: 'running' } } })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) @@ -826,15 +1223,15 @@ describe('ScheduleService', () => { mocks.getScheduleRunById.mockReturnValueOnce(runningRun).mockReturnValueOnce(runningRun).mockReturnValueOnce(completedRun) routeForward(({ path, method }) => { - if (path === '/session/ses-run-5/message' && method === 'GET') { - return Promise.resolve(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'Completed summary' }], - }, + if (path.startsWith('/session/ses-run-5/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Completed summary' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, ])) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -876,14 +1273,20 @@ describe('ScheduleService', () => { { name: 'code-review', description: 'Code review workflow', location: '/path/SKILL.md', content: 'Review instructions here' }, ])) } - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-skills-1' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-skills-1' } })) } - if (path === '/session/ses-skills-1/message' && method === 'POST') { + if (path === `/session/ses-skills-1/message` && method === 'POST') { capturedPromptBody = body - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Done.' }], - }))) + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-skills-1/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -925,14 +1328,23 @@ describe('ScheduleService', () => { { name: 'git-release', description: 'Git release workflow', location: '/path/SKILL.md', content: 'Release instructions here' }, ])) } - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-skills-2' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-skills-2' } })) } - if (path === '/session/ses-skills-2/message' && method === 'POST') { + if (path === `/session/ses-skills-2/message` && method === 'POST') { capturedPromptBody = body - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Done.' }], - }))) + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-skills-2/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -968,14 +1380,23 @@ describe('ScheduleService', () => { let capturedPromptBody: string | undefined routeForward(({ path, method, body }) => { - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-skills-3' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-skills-3' } })) } - if (path === '/session/ses-skills-3/message' && method === 'POST') { + if (path === `/session/ses-skills-3/message` && method === 'POST') { capturedPromptBody = body - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Done.' }], - }))) + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-skills-3/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1012,14 +1433,23 @@ describe('ScheduleService', () => { if (path === '/skill' && method === 'GET') { return Promise.resolve(new Response('error', { status: 500 })) } - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-skills-4' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-skills-4' } })) } - if (path === '/session/ses-skills-4/message' && method === 'POST') { + if (path === `/session/ses-skills-4/message` && method === 'POST') { capturedPromptBody = body - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Done.' }], - }))) + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-skills-4/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1056,14 +1486,23 @@ describe('ScheduleService', () => { if (path === '/skill' && method === 'GET') { return Promise.resolve(jsonResponse([])) } - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-skills-5' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-skills-5' } })) } - if (path === '/session/ses-skills-5/message' && method === 'POST') { + if (path === `/session/ses-skills-5/message` && method === 'POST') { capturedPromptBody = body - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Done.' }], - }))) + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-skills-5/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1118,17 +1557,23 @@ describe('ScheduleService worktree isolation', () => { setupWorktreePrepare() mocks.updateScheduleRunMetadata.mockReturnValue(worktreeRun) - mocks.getScheduleRunById.mockReturnValue(worktreeRun) - routeForward(({ path, method, directory }) => { - if (path === '/session' && method === 'POST') { - expect(directory).toBe(worktreePath) - return Promise.resolve(jsonResponse({ id: 'ses-wt-1' })) - } - if (path === '/session/ses-wt-1/message' && method === 'POST') { - expect(directory).toBe(worktreePath) - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Worktree run done.' }], - }))) + mocks.getScheduleRunById.mockReturnValue(worktreeRun) + routeForward(({ path, method, body }) => { + if (path === '/api/session' && method === 'POST') { + const parsed = JSON.parse(body!) + expect(parsed.location.directory).toBe(worktreePath) + return Promise.resolve(jsonResponse({ data: { id: 'ses-wt-1' } })) + } + if (path === `/session/ses-wt-1/message` && method === 'POST') { + return Promise.resolve(textResponse('')) + } + if (path.startsWith('/session/ses-wt-1/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Worktree run done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1160,13 +1605,22 @@ describe('ScheduleService worktree isolation', () => { mocks.getScheduleRunById.mockReturnValue(runWithWorktree) routeForward(({ path, method }) => { - if (path === '/session' && method === 'POST') { - return Promise.resolve(jsonResponse({ id: 'ses-wt-1' })) + if (path === '/api/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ data: { id: 'ses-wt-1' } })) + } + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) } - if (path === '/session/ses-wt-1/message' && method === 'POST') { - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Worktree run done.' }], - }))) + if (path === `/session/ses-wt-1/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-wt-1/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Worktree run done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1198,15 +1652,25 @@ describe('ScheduleService worktree isolation', () => { mocks.getScheduleRunById.mockReturnValue(runWithSession) let capturedDirectory: string | undefined - routeForward(({ path, method, directory }) => { - if (path === '/session' && method === 'POST') { - capturedDirectory = directory - return Promise.resolve(jsonResponse({ id: 'ses-inline-1' })) + routeForward(({ path, method, body }) => { + if (path === '/api/session' && method === 'POST') { + const parsed = JSON.parse(body!) + capturedDirectory = parsed.location.directory + return Promise.resolve(jsonResponse({ data: { id: 'ses-inline-1' } })) + } + if (path === `/session/ses-inline-1/message` && method === 'POST') { + return Promise.resolve(promptReceipt()) + } + if (path.startsWith('/session/ses-inline-1/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Inline run done.' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, + ])) } - if (path === '/session/ses-inline-1/message' && method === 'POST') { - return Promise.resolve(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Inline run done.' }], - }))) + if (path.match(/^\/session\/[\w-]+$/) && method === 'PATCH') { + return Promise.resolve(jsonResponse({})) + } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1243,12 +1707,15 @@ describe('ScheduleService worktree isolation', () => { setupWorktreeFinalize('ghi789') routeForward(({ path, method }) => { - if (path === '/session/ses-cancel-wt/message' && method === 'GET') { - return Promise.resolve(jsonResponse([])) + if (path.startsWith('/session/ses-cancel-wt/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) } - if (path === '/session/ses-cancel-wt/abort' && method === 'POST') { + if (path === `/api/session/ses-cancel-wt/interrupt` && method === 'POST') { return Promise.resolve(textResponse('')) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1280,13 +1747,11 @@ describe('ScheduleService worktree isolation', () => { // Session exists but has no completed message — triggers finalizeRecoveredRun routeForward(({ path, method }) => { - if (path === '/session/ses-recover-wt/message' && method === 'GET') { - return Promise.resolve(jsonResponse([])) + if (path.startsWith('/session/ses-recover-wt/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) } - if (path === '/session/status' && method === 'GET') { - return Promise.resolve(jsonResponse({ - 'ses-recover-wt': { type: 'idle' }, - })) + if (path === '/api/session/active' && method === 'GET') { + return Promise.resolve(jsonResponse({ data: {} })) } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1321,11 +1786,14 @@ describe('ScheduleService worktree isolation', () => { activeTeardowns.add('42:7:5') routeForward(({ path, method }) => { - if (path === '/session/ses-race-double/message' && method === 'GET') { - return Promise.resolve(jsonResponse([ - { info: { role: 'assistant', time: { completed: Date.now() } }, parts: [{ type: 'text', text: 'Already done' }] }, + if (path.startsWith('/session/ses-race-double/message') && method === 'GET') { + return Promise.resolve(v2Messages([ + { type: 'assistant', content: [{ type: 'text', text: 'Already done' }], time: { created: Math.floor(Date.now() / 1000), completed: Math.floor(Date.now() / 1000) }, finish: 'stop' }, ])) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) @@ -1362,12 +1830,15 @@ describe('ScheduleService worktree isolation', () => { mocks.getScheduleRunById.mockReturnValue(runningRun) routeForward(({ path, method }) => { - if (path === '/session/ses-guard-cycle/message' && method === 'GET') { - return Promise.resolve(jsonResponse([])) + if (path.startsWith('/session/ses-guard-cycle/message') && method === 'GET') { + return Promise.resolve(v2Messages([])) } - if (path === '/session/ses-guard-cycle/abort' && method === 'POST') { + if (path === `/api/session/ses-guard-cycle/interrupt` && method === 'POST') { return Promise.resolve(textResponse('')) } + if (path === "/api/session/active" && method === "GET") { + return Promise.resolve(jsonResponse({ data: {} })) + } throw new Error(`Unexpected proxy request: ${method} ${path}`) }) diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index d29761b6..1024fdf5 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -52,14 +52,11 @@ export interface SchedulePermissionRule { export type SchedulePermissionRuleset = SchedulePermissionRule[] /** - * Builds the OpenCode session permission ruleset for an unattended scheduled run. + * Builds the permission ruleset enforced by unattended scheduled runs. * - * OpenCode's `POST /session` `permission` field expects an ordered array of - * `{ permission, pattern, action }` rules (`PermissionV1.Ruleset`), evaluated - * with last-match-wins semantics (see https://opencode.ai/docs/permissions). - * A leading `*`/`*` allow rule sets the allow-all baseline; the trailing - * `external_directory` and `bash` deny rules then override it for external - * directory access and matching destructive command patterns. + * Rules use OpenCode's last-match-wins semantics. The leading allow rule sets + * the baseline and trailing rules deny external directory access and matching + * destructive commands. */ export function buildSchedulePermissionRuleset( config: SchedulePermissionConfig | null | undefined, @@ -75,6 +72,33 @@ export function buildSchedulePermissionRuleset( return ruleset } +function matchesPermissionPattern(pattern: string, value: string): boolean { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + return new RegExp(`^${escaped}$`).test(value) +} + +export function evaluateSchedulePermission( + ruleset: SchedulePermissionRuleset, + action: string, + resources: string[], +): SchedulePermissionAction { + const targets = resources.length > 0 ? resources : ['*'] + + const verdicts = targets.map((resource) => { + let verdict: SchedulePermissionAction = 'ask' + for (const rule of ruleset) { + if (matchesPermissionPattern(rule.permission, action) && matchesPermissionPattern(rule.pattern, resource)) { + verdict = rule.action + } + } + return verdict + }) + + if (verdicts.some((v) => v === 'deny')) return 'deny' + if (verdicts.some((v) => v === 'ask')) return 'ask' + return 'allow' +} + export const ScheduleJobSchema = z.object({ id: z.number(), repoId: z.number(),