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({