From 3d8c2f5b5eca4ed639213eacd451d392990ab5d7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:33:31 -0400 Subject: [PATCH 1/6] fix(schedules): open report links in place and route schedule notifications to the run report --- backend/src/db/schedules.ts | 6 + backend/src/routes/internal/notifications.ts | 2 +- backend/src/services/assistant-mode.ts | 1 + backend/src/services/notification.ts | 20 ++- .../services/opencode-manager-tool-plugin.ts | 15 ++- .../routes/internal-notifications.test.ts | 37 +++++ .../services/notification-service.test.ts | 25 +++- .../opencode-manager-tool-plugin.test.ts | 7 +- .../file-browser/FileBrowserSheet.tsx | 6 +- .../components/schedules/RunDetailPanel.tsx | 126 +++++++++++------- .../components/schedules/RunHistoryCards.tsx | 26 ++-- .../schedules/ScheduleRunMarkdown.test.tsx | 25 ++++ .../schedules/ScheduleRunMarkdown.tsx | 15 ++- frontend/src/lib/markdownLinks.test.ts | 18 ++- frontend/src/lib/markdownLinks.ts | 17 +++ frontend/src/pages/GlobalSchedules.tsx | 1 + frontend/src/pages/SessionDetail.tsx | 14 +- shared/src/schemas/internal-assistant.ts | 1 + 18 files changed, 278 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/schedules/ScheduleRunMarkdown.test.tsx diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index feb175888..971796358 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -460,6 +460,12 @@ export function getScheduleRunById(db: Database, repoId: number, jobId: number, return row ? rowToScheduleRun(row) : null } +export function getScheduleRunBySessionId(db: Database, sessionId: string): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 1') + const row = stmt.get(sessionId) as ScheduleRunRow | undefined + return row ? rowToScheduleRun(row) : null +} + export function getRunningScheduleRunByJob(db: Database, repoId: number, jobId: number): ScheduleRun | null { const stmt = db.prepare(` SELECT * FROM schedule_runs diff --git a/backend/src/routes/internal/notifications.ts b/backend/src/routes/internal/notifications.ts index 229df2210..641c65edd 100644 --- a/backend/src/routes/internal/notifications.ts +++ b/backend/src/routes/internal/notifications.ts @@ -40,7 +40,7 @@ export function createInternalNotificationRoutes(notificationService: Notificati timestamp: Date.now(), data: { eventType: 'assistant.message', - url: parsed.data.url ?? '/', + url: notificationService.getScheduleRunReportUrl(parsed.data.sessionId) ?? parsed.data.url ?? '/', priority: parsed.data.priority, }, } diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 32ae6e766..dc9dfab9a 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -569,6 +569,7 @@ Sending is rate limited to **10 notifications per minute**. Beyond that the tool - Notifications are only sent if the user has registered devices (browser push subscriptions) - If VAPID is not configured on the server, the tool fails with a \`503\` status - Use \`priority: 'high'\` for urgent notifications that should interrupt the user +- In a scheduled run, the notification always opens that run's report, so \`url\` is not needed - Do not call the internal HTTP API with \`curl\` for notifications; the tool is the supported path ` } diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 6843c9ce9..d64e2ab2c 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -20,6 +20,7 @@ import { getRepoName, listRepos, } from "../db/queries"; +import { getScheduleRunBySessionId } from "../db/schedules"; import type { Repo } from "../types/repo"; import { getReposPath } from "@opencode-manager/shared/config/env"; import { ASSISTANT_REPO_ID } from "@opencode-manager/shared/utils"; @@ -71,6 +72,11 @@ const EVENT_CONFIG: Record< const MAX_BODY_LENGTH = 140; +const RUN_OUTCOME_EVENTS = new Set([ + NotificationEventType.SESSION_IDLE, + NotificationEventType.SESSION_FAILED, +]); + function resolveEventSessionId(event: SSEEvent): string | undefined { if (event.type === NotificationEventType.FORM_CREATED) { return event.data.form.sessionID; @@ -78,6 +84,10 @@ function resolveEventSessionId(event: SSEEvent): string | undefined { return sessionIDFromEvent(event); } +export function buildScheduleRunReportUrl(runId: number): string { + return `/schedules?scheduleTab=runs&runId=${runId}`; +} + export function buildNotificationUrl( repo: Pick | null, sessionId: string | undefined @@ -272,6 +282,13 @@ export class NotificationService { return rows.map((r) => r.user_id); } + /** Returns the run report URL when the session was started by a scheduled run. */ + getScheduleRunReportUrl(sessionId: string | undefined): string | null { + if (!sessionId) return null; + const run = getScheduleRunBySessionId(this.db, sessionId); + return run ? buildScheduleRunReportUrl(run.id) : null; + } + private async resolveRepoForDirectory(directory: string): Promise { const repo = getRepoBySourcePath(this.db, path.resolve(directory)) ?? @@ -312,7 +329,8 @@ export class NotificationService { const repo = directory ? await this.resolveRepoForDirectory(directory) : null; const repoId = repo?.id; const repoName = repo ? getRepoName(repo) : undefined; - const url = buildNotificationUrl(repo, sessionId); + const reportUrl = RUN_OUTCOME_EVENTS.has(event.type) ? this.getScheduleRunReportUrl(sessionId) : null; + const url = reportUrl ?? buildNotificationUrl(repo, sessionId); const payload = buildEventNotificationPayload(event, { repoName, diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index c639d7d9e..c07acdca5 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -198,8 +198,9 @@ async function postInternalApi(routePath, body, signal) { var ACTIONS = { send_notification: { - run: async function (params, signal) { - var result = await postInternalApi('/notifications/send', params, signal) + run: async function (params, context) { + var body = Object.assign({}, params, { sessionId: context.sessionID }) + var result = await postInternalApi('/notifications/send', body, context.signal) if (result.noSubscriptions === true) { return 'No devices are registered for push notifications, so nothing was delivered.' } @@ -207,9 +208,9 @@ var ACTIONS = { }, }, request: { - run: async function (params, signal) { + run: async function (params, context) { assertAllowedRoute(params.method, params.path) - var text = await requestInternalApi(params.method, params.path, params.body, signal) + var text = await requestInternalApi(params.method, params.path, params.body, context.signal) return text || 'The request succeeded with an empty response body.' }, }, @@ -229,14 +230,14 @@ function assertParams(actionName, params) { } } -async function runAction(input, signal) { +async function runAction(input, context) { var actionName = input !== null && typeof input === 'object' ? input.action : undefined if (!Object.prototype.hasOwnProperty.call(ACTIONS, actionName)) { throw new Error('Unknown OpenCode Manager action: ' + String(actionName) + '. Supported actions: ' + ACTION_NAMES.join(', ') + '.') } var params = input.params assertParams(actionName, params) - return await ACTIONS[actionName].run(params, signal) + return await ACTIONS[actionName].run(params, context) } export default { @@ -249,7 +250,7 @@ export default { input: INPUT_SCHEMA, options: { codemode: false }, execute: async function (input, context) { - return { content: await runAction(input, context.signal) } + return { content: await runAction(input, context) } }, }) }) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 817d60342..0acf4d0a8 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -9,6 +9,7 @@ import { createOpenCodeClient } from '../../src/services/opencode/client' import { allMigrations } from '../../src/db/migrations' import { getOrCreateInternalToken } from '../../src/services/internal-token' import { migrate } from '../../src/db/migration-runner' +import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' describe('internal/notifications routes', () => { @@ -142,6 +143,42 @@ describe('internal/notifications routes', () => { expect(res.status).toBe(400) }) + describe('notification url', () => { + const send = (payload: Record) => + app.request('/api/internal/notifications/send', { + method: 'POST', + body: JSON.stringify({ title: 'Test', body: 'Body', ...payload }), + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + }) + + const sentUrl = (sendToUser: ReturnType) => + (sendToUser.mock.calls[0]?.[1] as { data: { url: string } }).data.url + + beforeEach(() => { + vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) + }) + + it('links a scheduled run session to its run report, even when the agent passes a url', async () => { + db.exec('PRAGMA foreign_keys = OFF') + const run = createScheduleRun(db, { jobId: 7, repoId: 0, triggerSource: 'schedule', status: 'running', startedAt: 1, createdAt: 1 }) + updateScheduleRunMetadata(db, 0, 7, run.id, { sessionId: 'ses_scheduled' }) + const sendToUser = vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 1, expired: 0, failed: 0, total: 1 }) + + const res = await send({ sessionId: 'ses_scheduled', url: '/repos/my-repo' }) + + expect(res.status).toBe(200) + expect(sentUrl(sendToUser)).toBe(`/schedules?scheduleTab=runs&runId=${run.id}`) + }) + + it('keeps the agent url for sessions that are not scheduled runs', async () => { + const sendToUser = vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 1, expired: 0, failed: 0, total: 1 }) + + await send({ sessionId: 'ses_manual', url: '/repos/3' }) + + expect(sentUrl(sendToUser)).toBe('/repos/3') + }) + }) + it('POST /api/internal/notifications/send returns 429 after 10 calls within rate window', async () => { vi.spyOn(notificationService, 'isConfigured').mockReturnValue(true) vi.spyOn(notificationService, 'sendToUser').mockResolvedValue({ delivered: 0, expired: 0, failed: 0, total: 0 }) diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index fa489ce4e..eb0cace1f 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -3,6 +3,7 @@ import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' +import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' import { NotificationService } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' @@ -45,8 +46,7 @@ function permissionAskedEvent(sessionID: string): SSEEvent { } } -function createService(): NotificationService { - const db = new Database(':memory:') +function createService(db = new Database(':memory:')): NotificationService { migrate(db, allMigrations) createRepo(db, { localPath: 'repo-one', @@ -128,6 +128,27 @@ describe('NotificationService.handleSSEEvent session routing', () => { expect(payload.data?.url).toBe('/repos/1/sessions/ses_perm') }) + it('opens the run report for a scheduled session that finishes, but the session for its permission prompts', async () => { + const db = new Database(':memory:') + const service = createService(db) + db.exec('PRAGMA foreign_keys = OFF') + const run = createScheduleRun(db, { jobId: 5, repoId: 1, triggerSource: 'schedule', status: 'running', startedAt: 1, createdAt: 1 }) + updateScheduleRunMetadata(db, 1, 5, run.id, { sessionId: 'ses_sched' }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, { + id: 'evt_idle_1', + created: 1700000000000, + type: 'session.idle', + location: { directory: DIRECTORY }, + data: { sessionID: 'ses_sched' }, + } as SSEEvent) + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_sched')) + + const urls = send.mock.calls.map((call) => (call[1] as PushNotificationPayload).data?.url) + expect(urls).toEqual([`/schedules?scheduleTab=runs&runId=${run.id}`, '/repos/1/sessions/ses_sched']) + }) + it('suppresses a permission for a session the user is viewing', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 13860bbe9..79fb88140 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -506,7 +506,12 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s expect(apiRequests).toHaveLength(1) const request = JSON.parse(apiRequests[0] as string) as { auth: string; body: string } expect(request.auth).toBe('Bearer test-token') - expect(JSON.parse(request.body)).toEqual({ title: 'Storm watch', body: 'Formation odds crossed 40%', priority: 'high' }) + expect(JSON.parse(request.body)).toEqual({ + title: 'Storm watch', + body: 'Formation odds crossed 40%', + priority: 'high', + sessionId: expect.stringMatching(/^ses_/), + }) expect(toolResults.some((output) => output.includes('Notification sent: 1 delivered, 0 failed.'))).toBe(true) } finally { api.server.close() diff --git a/frontend/src/components/file-browser/FileBrowserSheet.tsx b/frontend/src/components/file-browser/FileBrowserSheet.tsx index 50e55b27c..52b905d5e 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, memo, useCallback, useRef } from 'react' +import { createPortal } from 'react-dom' import { FileBrowser, type FileBrowserHandle } from './FileBrowser' import { getRepoRelativeDisplayPath } from './display-path' import { Button } from '@/components/ui/button' @@ -116,7 +117,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose if (!isOpen && !shouldRender) return null - return ( + return createPortal(
-
+ , + document.body, ) }) diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 42c620b8a..0ad3c7821 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -1,7 +1,14 @@ +import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ScheduleRunMarkdown } from '@/components/schedules/ScheduleRunMarkdown' +import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' +import { getRepo } from '@/api/repos' +import { getWorkspaceFilePath } from '@/lib/markdownLinks' +import { getSessionPath } from '@/lib/navigation' +import { getRepoDisplayName } from '@/lib/utils' import { Loader2 } from 'lucide-react' import type { ScheduleRun } from '@opencode-manager/shared/types' @@ -15,6 +22,21 @@ interface RunDetailPanelProps { export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { const navigate = useNavigate() + const [selectedFilePath, setSelectedFilePath] = useState(null) + const { data: repo } = useQuery({ + queryKey: ['repo', repoId], + queryFn: () => getRepo(repoId), + }) + + const handleOpenLocalPath = repo + ? (linkPath: string) => { + setSelectedFilePath(getWorkspaceFilePath(linkPath, { + directory: activeRun?.worktreePath ?? repo.fullPath, + repoFullPath: repo.fullPath, + repoLocalPath: repo.localPath, + })) + } + : undefined if (selectedRunLoading && !activeRun) { return ( @@ -28,55 +50,67 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel return
Select a run to inspect logs and output.
} + const { sessionId } = activeRun + return ( - -
- - Log - Assistant Output - {activeRun.status === 'cancelled' ? 'Details' : 'Error'} - -
- {(activeRun.status === 'running' || activeRun.sessionId || activeRun.responseText) && ( -
-
- {activeRun.sessionId && ( - - )} -
- + <> + +
+ + Log + Assistant Output + {activeRun.status === 'cancelled' ? 'Details' : 'Error'} +
- )} - - {selectedRunLoading && !activeRun ? ( -
- ) : ( -
{activeRun.logText ?? 'No log text captured.'}
- )} -
- - {selectedRunLoading && !activeRun ? ( -
- ) : activeRun.responseText ? ( -
- + {(activeRun.status === 'running' || activeRun.sessionId || activeRun.responseText) && ( +
+
+ {sessionId && ( + + )} +
+
- ) : ( -
No assistant output captured.
- )} - - - {selectedRunLoading && !activeRun ? ( -
- ) : ( -
{activeRun.errorText ?? 'No error recorded.'}
)} -
- + + {selectedRunLoading && !activeRun ? ( +
+ ) : ( +
{activeRun.logText ?? 'No log text captured.'}
+ )} +
+ + {selectedRunLoading && !activeRun ? ( +
+ ) : activeRun.responseText ? ( +
+ +
+ ) : ( +
No assistant output captured.
+ )} +
+ + {selectedRunLoading && !activeRun ? ( +
+ ) : ( +
{activeRun.errorText ?? 'No error recorded.'}
+ )} +
+ + setSelectedFilePath(null)} + basePath={repo?.localPath} + repoName={repo ? getRepoDisplayName(repo) : undefined} + repoId={repoId} + initialSelectedFile={selectedFilePath ?? undefined} + /> + ) } diff --git a/frontend/src/components/schedules/RunHistoryCards.tsx b/frontend/src/components/schedules/RunHistoryCards.tsx index d983bcbcc..1b44a3145 100644 --- a/frontend/src/components/schedules/RunHistoryCards.tsx +++ b/frontend/src/components/schedules/RunHistoryCards.tsx @@ -9,6 +9,7 @@ import { useRepoScheduleRun } from '@/hooks/useSchedules' interface RunHistoryCardsProps { runs: ScheduleRun[] | undefined runsLoading: boolean + selectedRunId?: number | null onSelectRun: (id: number) => void onCancelRun: () => void cancelRunPending: boolean @@ -19,20 +20,25 @@ interface RunHistoryCardsProps { export function RunHistoryCards({ runs, runsLoading, + selectedRunId = null, onSelectRun, onCancelRun, cancelRunPending, onDeleteRun, deleteRunPending, }: RunHistoryCardsProps) { - const [expandedRunId, setExpandedRunId] = useState(null) - const [expandedRunRepoId, setExpandedRunRepoId] = useState(null) - const [expandedRunJobId, setExpandedRunJobId] = useState(null) + const [expandedRunId, setExpandedRunId] = useState(selectedRunId) + const [syncedSelectedRunId, setSyncedSelectedRunId] = useState(selectedRunId) + if (selectedRunId !== syncedSelectedRunId) { + setSyncedSelectedRunId(selectedRunId) + setExpandedRunId(selectedRunId) + } + const expandedRun = runs?.find((run) => run.id === expandedRunId) const { data: runDetail, isLoading } = useRepoScheduleRun( - expandedRunRepoId ?? undefined, - expandedRunJobId, - expandedRunId + expandedRun?.repoId, + expandedRun?.jobId ?? null, + expandedRun?.id ?? null ) function getRunStatusIcon(status: ScheduleRun['status']) { @@ -42,15 +48,11 @@ export function RunHistoryCards({ return } - function handleCardClick(runId: number, repoId: number, jobId: number) { + function handleCardClick(runId: number) { if (expandedRunId === runId) { setExpandedRunId(null) - setExpandedRunRepoId(null) - setExpandedRunJobId(null) } else { setExpandedRunId(runId) - setExpandedRunRepoId(repoId) - setExpandedRunJobId(jobId) onSelectRun(runId) } } @@ -92,7 +94,7 @@ export function RunHistoryCards({
)}
- + {activeRun.status === 'running' && ( + + )}
)} - + {selectedRunLoading && !activeRun ? (
) : ( @@ -63,14 +65,14 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel {selectedRunLoading && !activeRun ? (
) : activeRun.responseText ? ( -
+
) : (
No assistant output captured.
)} - + {selectedRunLoading && !activeRun ? (
) : ( diff --git a/frontend/src/components/schedules/RunHistoryCards.tsx b/frontend/src/components/schedules/RunHistoryCards.tsx index d983bcbcc..5f553b015 100644 --- a/frontend/src/components/schedules/RunHistoryCards.tsx +++ b/frontend/src/components/schedules/RunHistoryCards.tsx @@ -76,20 +76,17 @@ export function RunHistoryCards({ } return ( -
-
- {runs.map((run, index) => { - const isExpanded = expandedRunId === run.id - const displayRun = isExpanded && runDetail ? runDetail : run +
+ {runs.map((run) => { + const isExpanded = expandedRunId === run.id + const displayRun = isExpanded && runDetail ? runDetail : run - return ( -
-
+ return ( +
+
)} -
- {isExpanded && ( -
-
- -
-
- )}
- ) - })} -
+ {isExpanded && ( +
+ +
+ )} +
+ ) + })}
) } diff --git a/frontend/src/components/schedules/RunHistoryTab.tsx b/frontend/src/components/schedules/RunHistoryTab.tsx index 066b9864d..dc32b3b9d 100644 --- a/frontend/src/components/schedules/RunHistoryTab.tsx +++ b/frontend/src/components/schedules/RunHistoryTab.tsx @@ -71,17 +71,19 @@ export function RunHistoryTab({
- +
+ +
-
+
-
+
Filter by repo:
-
+
{!hasJobs ? (
@@ -667,7 +667,7 @@ export function GlobalSchedules() { -
+
Filter by repo:
-
+
{runsLoading && allRuns.length === 0 ? (
From 3fff29751f7c20172d3d731778e463ac76e0acb4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:55:20 -0400 Subject: [PATCH 3/6] fix(schedules): let run history cards fill the available width --- frontend/src/pages/GlobalSchedules.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index ce811b538..861edd5ff 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -816,13 +816,13 @@ export function GlobalSchedules() {
-
+
{runsLoading && allRuns.length === 0 ? (
) : allRuns.length === 0 ? ( -
+
From e497dbfe3441ca300368c39da9380e790b4f78f5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:32:20 -0400 Subject: [PATCH 4/6] fix(schedules): resolve older run links and index session lookups --- .../022-schedule-runs-session-index.ts | 19 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/schedules.ts | 7 +- backend/src/routes/schedules.ts | 20 +- backend/test/db/schedule-migrations.test.ts | 31 +++ backend/test/db/schedules.test.ts | 19 ++ backend/test/routes/schedules.test.ts | 29 ++ frontend/src/api/schedules.ts | 2 + frontend/src/pages/GlobalSchedules.tsx | 88 +++++- .../pages/__tests__/GlobalSchedules.test.tsx | 259 ++++++++++++++++++ 10 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/migrations/022-schedule-runs-session-index.ts create mode 100644 frontend/src/pages/__tests__/GlobalSchedules.test.tsx diff --git a/backend/src/db/migrations/022-schedule-runs-session-index.ts b/backend/src/db/migrations/022-schedule-runs-session-index.ts new file mode 100644 index 000000000..9e56f04b7 --- /dev/null +++ b/backend/src/db/migrations/022-schedule-runs-session-index.ts @@ -0,0 +1,19 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + version: 22, + name: 'schedule-runs-session-index', + + up(db) { + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_session + ON schedule_runs(session_id, started_at DESC) + `) + }, + + down(db) { + db.run('DROP INDEX IF EXISTS idx_schedule_runs_session') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index c08f3e706..d3b026883 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -20,6 +20,7 @@ import migration018 from './018-session-pins' import migration019 from './019-drop-opencode-configs' import migration020 from './020-drop-opencode-model-state' import migration021 from './021-drop-schedule-run-workspace-id' +import migration022 from './022-schedule-runs-session-index' export const allMigrations: Migration[] = [ migration001, @@ -43,4 +44,5 @@ export const allMigrations: Migration[] = [ migration019, migration020, migration021, + migration022, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index 971796358..57490e197 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -598,10 +598,11 @@ export interface ListAllRunsOptions { repoId?: number jobId?: number triggerSource?: string + runId?: number } export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = {}): ScheduleRunWithContext[] { - const { limit = 50, offset = 0, status, repoId, jobId, triggerSource } = options + const { limit = 50, offset = 0, status, repoId, jobId, triggerSource, runId } = options const conditions: string[] = [] const params: (string | number)[] = [] @@ -621,6 +622,10 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = conditions.push('sr.trigger_source = ?') params.push(triggerSource) } + if (runId !== undefined) { + conditions.push('sr.id = ?') + params.push(runId) + } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index c1dad9018..4c10f8f38 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -19,6 +19,23 @@ function parseRunListLimit(value: string | undefined): number { return Math.min(parsed, 100) } +function parseRunIdFilter(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined + } + + if (!/^\d+$/.test(value)) { + throw new ScheduleServiceError('Invalid run id', 400) + } + + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new ScheduleServiceError('Run id must be a positive integer', 400) + } + + return parsed +} + export function createScheduleRoutes(scheduleService: ScheduleService) { const app = new Hono() @@ -48,7 +65,8 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { return Number.isNaN(parsed) ? undefined : parsed })() : undefined const triggerSource = c.req.query('triggerSource') || undefined - const runs = scheduleService.listAllRuns({ limit, offset, status, repoId, jobId, triggerSource }) + const runId = parseRunIdFilter(c.req.query('runId')) + const runs = scheduleService.listAllRuns({ limit, offset, status, repoId, jobId, triggerSource, runId }) return c.json({ runs }) } catch (error) { return handleServiceError(c, error, 'Failed to list all schedule runs', ScheduleServiceError) diff --git a/backend/test/db/schedule-migrations.test.ts b/backend/test/db/schedule-migrations.test.ts index fefb561b5..c136df3f6 100644 --- a/backend/test/db/schedule-migrations.test.ts +++ b/backend/test/db/schedule-migrations.test.ts @@ -4,6 +4,7 @@ import migration007 from '../../src/db/migrations/007-schedules' import migration008 from '../../src/db/migrations/008-schedule-cron-support' import migration015 from '../../src/db/migrations/015-schedule-worktree-isolation' import migration021 from '../../src/db/migrations/021-drop-schedule-run-workspace-id' +import migration022 from '../../src/db/migrations/022-schedule-runs-session-index' describe('schedule migrations', () => { it('creates schedule jobs with nullable interval minutes in v7', () => { @@ -206,3 +207,33 @@ describe('migration 021 - drop schedule run workspace id', () => { db.close() }) }) + +describe('migration 022 - schedule run session index', () => { + it('creates the composite session index on schedule_runs', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_runs (id INTEGER PRIMARY KEY, session_id TEXT, started_at INTEGER NOT NULL)') + + migration022.up(db) + + const indexes = (db.prepare('PRAGMA index_list(schedule_runs)').all() as { name: string }[]).map((index) => index.name) + expect(indexes).toContain('idx_schedule_runs_session') + + const columns = (db.prepare('PRAGMA index_info(idx_schedule_runs_session)').all() as { name: string }[]).map((column) => column.name) + expect(columns).toEqual(['session_id', 'started_at']) + + db.close() + }) + + it('drops the index on rollback', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_runs (id INTEGER PRIMARY KEY, session_id TEXT, started_at INTEGER NOT NULL)') + + migration022.up(db) + migration022.down(db) + + const indexes = (db.prepare('PRAGMA index_list(schedule_runs)').all() as { name: string }[]).map((index) => index.name) + expect(indexes).not.toContain('idx_schedule_runs_session') + + db.close() + }) +}) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 905b43d6b..1d31f8b95 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -580,6 +580,25 @@ describe('schedule database queries', () => { expect(stmt.all).toHaveBeenCalledWith('manual', 50, 0) }) + it('applies runId filter', () => { + const stmt = { all: vi.fn().mockReturnValue([]) } + mockDb.prepare.mockReturnValue(stmt) + + schedulesDb.listAllScheduleRuns(mockDb, { runId: 99 }) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('sr.id = ?')) + expect(stmt.all).toHaveBeenCalledWith(99, 50, 0) + }) + + it('applies runId together with other filters', () => { + const stmt = { all: vi.fn().mockReturnValue([]) } + mockDb.prepare.mockReturnValue(stmt) + + schedulesDb.listAllScheduleRuns(mockDb, { runId: 99, repoId: 42, limit: 1 }) + + expect(stmt.all).toHaveBeenCalledWith(42, 99, 1, 0) + }) + it('applies limit and offset', () => { const stmt = { all: vi.fn().mockReturnValue([]) } mockDb.prepare.mockReturnValue(stmt) diff --git a/backend/test/routes/schedules.test.ts b/backend/test/routes/schedules.test.ts index 183115031..6cfc779e4 100644 --- a/backend/test/routes/schedules.test.ts +++ b/backend/test/routes/schedules.test.ts @@ -273,4 +273,33 @@ describe('Schedule Routes', () => { triggerSource: 'manual', }) }) + + it('passes a runId bound to the service for all runs', async () => { + scheduleService.listAllRuns.mockReturnValue([]) + + const response = await app.request('/repos/42/schedules/all/runs?runId=99') + const body = await response.json() as { runs: Array } + + expect(response.status).toBe(200) + expect(body.runs).toHaveLength(0) + expect(scheduleService.listAllRuns).toHaveBeenCalledWith(expect.objectContaining({ runId: 99 })) + }) + + it.each(['abc', '1abc', '1.5', '-1', ''])('rejects an invalid runId format %s for all runs', async (runId) => { + const response = await app.request(`/repos/42/schedules/all/runs?runId=${encodeURIComponent(runId)}`) + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Invalid run id') + expect(scheduleService.listAllRuns).not.toHaveBeenCalled() + }) + + it.each(['0', '99999999999999999999'])('rejects a non-positive or unsafe runId %s for all runs', async (runId) => { + const response = await app.request(`/repos/42/schedules/all/runs?runId=${runId}`) + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Run id must be a positive integer') + expect(scheduleService.listAllRuns).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 24040d9d3..6af986d7b 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -26,6 +26,7 @@ export interface ListAllRunsParams { repoId?: number jobId?: number triggerSource?: string + runId?: number } export interface ScheduleCount { @@ -45,6 +46,7 @@ export async function listAllScheduleRuns(params: ListAllRunsParams = {}): Promi if (params.repoId !== undefined) searchParams.set('repoId', String(params.repoId)) if (params.jobId !== undefined) searchParams.set('jobId', String(params.jobId)) if (params.triggerSource) searchParams.set('triggerSource', params.triggerSource) + if (params.runId !== undefined) searchParams.set('runId', String(params.runId)) const qs = searchParams.toString() return fetchWrapper(`${API_BASE_URL}/api/schedules/all/runs${qs ? `?${qs}` : ''}`) } diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 89ebbc974..5b70cccd5 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -12,7 +12,7 @@ import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { DeleteDialog } from '@/components/ui/delete-dialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { CalendarClock, Loader2, Plus, ArrowLeft, Play, Pencil, Trash2, Pause, PlayCircle, Clock3, History, SlidersHorizontal } from 'lucide-react' +import { CalendarClock, Loader2, Plus, ArrowLeft, Play, Pencil, Trash2, Pause, PlayCircle, Clock3, History, SlidersHorizontal, XCircle, SearchX } from 'lucide-react' import { useScheduleUrlState } from '@/hooks/useScheduleUrlState' import type { ScheduleTab } from '@/hooks/useScheduleUrlState' @@ -70,6 +70,27 @@ export function GlobalSchedules() { const { data: runsPage = [], isLoading: runsLoading } = useAllScheduleRuns(runsParams, scheduleTab === 'runs') + const selectedRunInHistory = useMemo( + () => (runId !== null ? allRuns.find((run) => run.id === runId) ?? null : null), + [allRuns, runId], + ) + + const selectedRunParams = useMemo(() => ({ limit: 1, runId: runId ?? undefined }), [runId]) + + const { + data: selectedRunPage, + isLoading: selectedRunLoading, + isError: selectedRunError, + refetch: refetchSelectedRun, + } = useAllScheduleRuns( + selectedRunParams, + scheduleTab === 'runs' && runId !== null && selectedRunInHistory === null, + ) + + const selectedRun = runId === null + ? null + : selectedRunInHistory ?? selectedRunPage?.[0] ?? null + const createMutation = useCreateRepoSchedule() const deleteMutation = useDeleteRepoSchedule() const runMutation = useRunRepoSchedule() @@ -96,8 +117,15 @@ export function GlobalSchedules() { } }, [runsPage]) + const runsForDisplay = useMemo(() => { + if (!selectedRun || allRuns.some((run) => run.id === selectedRun.id)) { + return allRuns + } + return [...allRuns, selectedRun] + }, [allRuns, selectedRun]) + const sortedRuns = useMemo(() => { - const sorted = [...allRuns] + const sorted = [...runsForDisplay] sorted.sort((a, b) => { switch (runSortOption) { case 'jobName': @@ -113,7 +141,7 @@ export function GlobalSchedules() { } }) return sorted - }, [allRuns, runSortOption]) + }, [runsForDisplay, runSortOption]) const uniqueRepos = useMemo(() => { const repoMap = new Map() @@ -312,6 +340,11 @@ export function GlobalSchedules() { const hasJobs = jobs.length > 0 + const selectedRunMissing = runId !== null && selectedRunInHistory === null + const selectedRunLookupLoading = selectedRunMissing && selectedRunLoading + const selectedRunLookupError = selectedRunMissing && selectedRunError + const selectedRunLookupNotFound = selectedRunMissing && !selectedRunLoading && !selectedRunError && selectedRun === null + return (
@@ -821,7 +854,54 @@ export function GlobalSchedules() {
- ) : allRuns.length === 0 ? ( + ) : selectedRunLookupLoading ? ( +
+ +
+ ) : selectedRunLookupError ? ( +
+ + +
+ +
+
+

Failed to load run

+

+ The selected run could not be loaded. +

+
+
+ + +
+
+
+
+ ) : selectedRunLookupNotFound ? ( +
+ + +
+ +
+
+

Run not found

+

+ The requested run no longer exists. +

+
+ +
+
+
+ ) : sortedRuns.length === 0 ? (
diff --git a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx new file mode 100644 index 000000000..df9fd8157 --- /dev/null +++ b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { GlobalSchedules } from '../GlobalSchedules' + +const mocks = vi.hoisted(() => ({ + useAllSchedules: vi.fn(), + useAllScheduleRuns: vi.fn(), + useCreateRepoSchedule: vi.fn(), + useUpdateRepoSchedule: vi.fn(), + useDeleteRepoSchedule: vi.fn(), + useRunRepoSchedule: vi.fn(), + useCancelRepoScheduleRun: vi.fn(), + useScheduleUrlState: vi.fn(), + RunHistoryCards: vi.fn(() => null), +})) + +vi.mock('@/hooks/useSchedules', () => ({ + useAllSchedules: mocks.useAllSchedules, + useAllScheduleRuns: mocks.useAllScheduleRuns, + useCreateRepoSchedule: mocks.useCreateRepoSchedule, + useUpdateRepoSchedule: mocks.useUpdateRepoSchedule, + useDeleteRepoSchedule: mocks.useDeleteRepoSchedule, + useRunRepoSchedule: mocks.useRunRepoSchedule, + useCancelRepoScheduleRun: mocks.useCancelRepoScheduleRun, +})) + +vi.mock('@/hooks/useScheduleUrlState', () => ({ + useScheduleUrlState: mocks.useScheduleUrlState, +})) + +vi.mock('@/components/schedules', () => ({ + ScheduleJobDialog: vi.fn(() => null), + RunHistoryCards: mocks.RunHistoryCards, + PromptsTab: vi.fn(() => null), +})) + +function makeRun(overrides: Record = {}) { + return { + id: 1, + jobId: 7, + repoId: 42, + triggerSource: 'manual', + status: 'completed', + startedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + finishedAt: Date.UTC(2026, 2, 9, 12, 5, 0), + createdAt: Date.UTC(2026, 2, 9, 12, 0, 0), + sessionId: 'ses-1', + sessionTitle: 'Run title', + logText: null, + responseText: null, + errorText: null, + runBranch: null, + commitHash: null, + worktreePath: null, + jobName: 'Weekly summary', + repoName: 'my-repo', + repoPath: '/home/user/my-repo', + ...overrides, + } +} + +function createMockScheduleUrlState(overrides: Record = {}) { + return { + scheduleTab: 'runs', + setScheduleTab: vi.fn(), + dialog: null, + promptDialog: null, + jobId: null, + runId: null, + templateId: null, + openNewJob: vi.fn(), + openEditJob: vi.fn(), + openDeleteJob: vi.fn(), + openNewTemplate: vi.fn(), + openEditTemplate: vi.fn(), + openDeleteTemplate: vi.fn(), + openImportTemplate: vi.fn(), + closeDialog: vi.fn(), + closePromptDialog: vi.fn(), + selectRun: vi.fn(), + selectJobAndView: vi.fn(), + selectJobAndCloseDialog: vi.fn(), + replaceUrlParams: vi.fn(), + ...overrides, + } +} + +let mainRunsResult: { data: unknown; isLoading: boolean; isError?: boolean } +let selectedRunResult: { data: unknown; isLoading: boolean; isError?: boolean; refetch?: () => void } + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => + {children} +} + +const renderGlobalSchedules = () => { + return render( + + + , + { wrapper: createWrapper() } + ) +} + +const runHistoryProps = () => mocks.RunHistoryCards.mock.calls.at(-1)?.[0] as { + runs: Array<{ id: number }> + selectedRunId: number | null +} + +describe('GlobalSchedules run history', () => { + beforeEach(() => { + vi.clearAllMocks() + mainRunsResult = { data: [], isLoading: false } + selectedRunResult = { data: undefined, isLoading: false, isError: false } + + mocks.useAllScheduleRuns.mockImplementation((params: { limit?: number }) => + params.limit === 1 ? selectedRunResult : mainRunsResult + ) + mocks.useAllSchedules.mockReturnValue({ data: [], isLoading: false, error: null }) + mocks.useCreateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useUpdateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useDeleteRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useRunRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useCancelRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState()) + }) + + it('loads and pins a selected run outside the loaded history', () => { + const historyRuns = Array.from({ length: 50 }, (_, index) => makeRun({ id: index + 1, sessionTitle: `Run ${index + 1}` })) + mainRunsResult = { data: historyRuns, isLoading: false } + selectedRunResult = { data: [makeRun({ id: 999, sessionTitle: 'Deep linked run' })], isLoading: false, isError: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999 })) + + renderGlobalSchedules() + + expect(mocks.useAllScheduleRuns).toHaveBeenCalledWith({ limit: 1, runId: 999 }, true) + const props = runHistoryProps() + expect(props.selectedRunId).toBe(999) + expect(props.runs.some((run) => run.id === 999)).toBe(true) + expect(screen.queryByText('No runs found')).not.toBeInTheDocument() + }) + + it('does not duplicate a selected run already present in history', () => { + mainRunsResult = { data: [makeRun({ id: 5 }), makeRun({ id: 6 })], isLoading: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 5 })) + + renderGlobalSchedules() + + expect(mocks.useAllScheduleRuns).toHaveBeenCalledWith({ limit: 1, runId: 5 }, false) + const props = runHistoryProps() + expect(props.runs.filter((run) => run.id === 5)).toHaveLength(1) + }) + + it('shows loading instead of no runs while the selected run lookup is pending', () => { + selectedRunResult = { data: undefined, isLoading: true, isError: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999 })) + + const { container } = renderGlobalSchedules() + + expect(screen.queryByText('No runs found')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).not.toBeNull() + expect(mocks.RunHistoryCards).not.toHaveBeenCalled() + }) + + it('shows run not found when the selected run lookup returns empty', () => { + selectedRunResult = { data: [], isLoading: false, isError: false } + const selectRun = vi.fn() + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999, selectRun })) + + renderGlobalSchedules() + + expect(screen.getByText('Run not found')).toBeInTheDocument() + expect(screen.queryByText('No runs found')).not.toBeInTheDocument() + expect(mocks.RunHistoryCards).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Back to run history' })) + + expect(selectRun).toHaveBeenCalledWith(null) + }) + + it('shows an error with retry and back actions when the selected run lookup fails', () => { + const refetch = vi.fn() + selectedRunResult = { data: undefined, isLoading: false, isError: true, refetch } + const selectRun = vi.fn() + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999, selectRun })) + + renderGlobalSchedules() + + expect(screen.getByText('Failed to load run')).toBeInTheDocument() + expect(screen.queryByText('No runs found')).not.toBeInTheDocument() + expect(mocks.RunHistoryCards).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(refetch).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Back to run history' })) + expect(selectRun).toHaveBeenCalledWith(null) + }) + + it('re-queries when the selected run id changes', () => { + mainRunsResult = { data: [], isLoading: false } + selectedRunResult = { data: [makeRun({ id: 999 })], isLoading: false, isError: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999 })) + + const { rerender } = renderGlobalSchedules() + + expect(mocks.useAllScheduleRuns).toHaveBeenCalledWith({ limit: 1, runId: 999 }, true) + + selectedRunResult = { data: [makeRun({ id: 1000 })], isLoading: false, isError: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 1000 })) + + rerender( + + + + ) + + expect(mocks.useAllScheduleRuns).toHaveBeenCalledWith({ limit: 1, runId: 1000 }, true) + expect(runHistoryProps().runs.some((run) => run.id === 1000)).toBe(true) + }) + + it('ignores cached selected run data when the selection is cleared', () => { + mainRunsResult = { data: [], isLoading: false } + selectedRunResult = { data: [makeRun({ id: 999, sessionTitle: 'Deep linked run' })], isLoading: false, isError: false } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999 })) + + const { rerender } = renderGlobalSchedules() + + expect(runHistoryProps().runs.some((run) => run.id === 999)).toBe(true) + + mocks.RunHistoryCards.mockClear() + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: null })) + + rerender( + + + + ) + + expect(screen.getByText('No runs found')).toBeInTheDocument() + expect(mocks.RunHistoryCards).not.toHaveBeenCalled() + }) + + it('does not query the selected run when the runs tab is inactive', () => { + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'jobs', runId: 999 })) + + renderGlobalSchedules() + + expect(mocks.useAllScheduleRuns).toHaveBeenCalledWith({ limit: 1, runId: 999 }, false) + }) +}) From eb40a04353b8c971dab6768d36e51b50725d749a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:43:49 -0400 Subject: [PATCH 5/6] fix(schedules): intercept local report links while repo data loads --- .../schedules/RunDetailPanel.test.tsx | 130 ++++++++++++++++++ .../components/schedules/RunDetailPanel.tsx | 36 +++-- 2 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/schedules/RunDetailPanel.test.tsx diff --git a/frontend/src/components/schedules/RunDetailPanel.test.tsx b/frontend/src/components/schedules/RunDetailPanel.test.tsx new file mode 100644 index 000000000..aeeabd47f --- /dev/null +++ b/frontend/src/components/schedules/RunDetailPanel.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { RunDetailPanel } from './RunDetailPanel' +import type { Repo } from '@/api/types' +import type { ScheduleRun } from '@opencode-manager/shared/types' + +const mocks = vi.hoisted(() => ({ + getRepo: vi.fn(), +})) + +vi.mock('@/api/repos', () => ({ + getRepo: mocks.getRepo, +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: ({ isOpen, initialSelectedFile }: { isOpen: boolean; initialSelectedFile?: string }) => + isOpen ?
: null, +})) + +const repo: Repo = { + id: 5, + name: 'my-repo', + localPath: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, +} + +const run: ScheduleRun = { + id: 1, + jobId: 1, + repoId: 5, + triggerSource: 'manual', + status: 'completed', + startedAt: 0, + finishedAt: null, + createdAt: 0, + sessionId: null, + sessionTitle: null, + logText: null, + responseText: '[Report](recaps/daily.html)\n\n[Docs](https://example.com)', + errorText: null, + runBranch: null, + commitHash: null, + worktreePath: null, +} + +function renderPanel() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return render( + + + + + , + ) +} + +function clickLink(name: string) { + const link = screen.getByRole('link', { name }) + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + fireEvent(link, event) + return event +} + +describe('RunDetailPanel local link handling', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prevents navigation and opens no sheet while the repo query is pending', async () => { + mocks.getRepo.mockReturnValue(new Promise(() => {})) + renderPanel() + + await screen.findByRole('status') + + const event = clickLink('Report') + + expect(event.defaultPrevented).toBe(true) + expect(screen.queryByTestId('file-browser-sheet')).not.toBeInTheDocument() + }) + + it('prevents navigation, shows a retryable error, and refetches when the repo query fails', async () => { + mocks.getRepo.mockRejectedValue(new Error('failed')) + renderPanel() + + await screen.findByRole('alert') + expect(mocks.getRepo).toHaveBeenCalledTimes(1) + + const event = clickLink('Report') + + expect(event.defaultPrevented).toBe(true) + expect(screen.queryByTestId('file-browser-sheet')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + await waitFor(() => expect(mocks.getRepo).toHaveBeenCalledTimes(2)) + }) + + it('opens the sheet with the resolved path once the repo is loaded and keeps external links external', async () => { + mocks.getRepo.mockResolvedValue(repo) + renderPanel() + + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + + const event = clickLink('Report') + + expect(event.defaultPrevented).toBe(true) + const sheet = await screen.findByTestId('file-browser-sheet') + expect(sheet.getAttribute('data-selected-file')).toBe('repos/my-repo/recaps/daily.html') + + const docs = screen.getByRole('link', { name: 'Docs' }) + expect(docs.getAttribute('target')).toBe('_blank') + expect(docs.getAttribute('rel')).toBe('noopener noreferrer') + }) +}) diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 1dfe2dea9..dd2ad2388 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -23,20 +23,19 @@ interface RunDetailPanelProps { export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { const navigate = useNavigate() const [selectedFilePath, setSelectedFilePath] = useState(null) - const { data: repo } = useQuery({ + const { data: repo, isPending: repoPending, isError: repoError, refetch: refetchRepo } = useQuery({ queryKey: ['repo', repoId], queryFn: () => getRepo(repoId), }) - const handleOpenLocalPath = repo - ? (linkPath: string) => { - setSelectedFilePath(getWorkspaceFilePath(linkPath, { - directory: activeRun?.worktreePath ?? repo.fullPath, - repoFullPath: repo.fullPath, - repoLocalPath: repo.localPath, - })) - } - : undefined + const handleOpenLocalPath = (linkPath: string) => { + if (!repo) return + setSelectedFilePath(getWorkspaceFilePath(linkPath, { + directory: activeRun?.worktreePath ?? repo.fullPath, + repoFullPath: repo.fullPath, + repoLocalPath: repo.localPath, + })) + } if (selectedRunLoading && !activeRun) { return ( @@ -90,9 +89,20 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel {selectedRunLoading && !activeRun ? (
) : activeRun.responseText ? ( -
- -
+ <> + {repoPending && ( +
Loading repository details for local links.
+ )} + {repoError && ( +
+ Could not load repository details for local links. + +
+ )} +
+ +
+ ) : (
No assistant output captured.
)} From b53f1928309d78257e542a0fab071b4dc846f03e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:10:07 -0400 Subject: [PATCH 6/6] fix(schedules): keep cached reports visible after polling errors --- frontend/src/pages/GlobalSchedules.tsx | 2 +- .../src/pages/__tests__/GlobalSchedules.test.tsx | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 5b70cccd5..baae3d98a 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -342,7 +342,7 @@ export function GlobalSchedules() { const selectedRunMissing = runId !== null && selectedRunInHistory === null const selectedRunLookupLoading = selectedRunMissing && selectedRunLoading - const selectedRunLookupError = selectedRunMissing && selectedRunError + const selectedRunLookupError = selectedRunMissing && selectedRunError && selectedRun === null const selectedRunLookupNotFound = selectedRunMissing && !selectedRunLoading && !selectedRunError && selectedRun === null return ( diff --git a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx index df9fd8157..6287ddb56 100644 --- a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx +++ b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx @@ -205,6 +205,20 @@ describe('GlobalSchedules run history', () => { expect(selectRun).toHaveBeenCalledWith(null) }) + it('renders the cached selected run when a background refetch fails', () => { + selectedRunResult = { data: [makeRun({ id: 999, sessionTitle: 'Deep linked run' })], isLoading: false, isError: true } + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'runs', runId: 999 })) + + renderGlobalSchedules() + + expect(screen.queryByText('Failed to load run')).not.toBeInTheDocument() + expect(screen.queryByText('Run not found')).not.toBeInTheDocument() + expect(mocks.RunHistoryCards).toHaveBeenCalled() + const props = runHistoryProps() + expect(props.selectedRunId).toBe(999) + expect(props.runs.filter((run) => run.id === 999)).toHaveLength(1) + }) + it('re-queries when the selected run id changes', () => { mainRunsResult = { data: [], isLoading: false } selectedRunResult = { data: [makeRun({ id: 999 })], isLoading: false, isError: false }