From d9173f6b56bd5ed9c292e21b536a22a0cd8f9f7c Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 28 Aug 2026 17:47:47 -0400 Subject: [PATCH 1/3] fix: support canonical cron routes with legacy fallback - Route canonical cron endpoints through Hermes - Fall back to legacy routes and session history for older connectors --- apps/connect/internal/hermes/client.go | 23 ++++ apps/connect/internal/hermes/client_test.go | 2 + apps/mobile/src/lib/brio.test.mjs | 36 ++++++- apps/mobile/src/lib/brio.ts | 110 +++++++++++++++----- 4 files changed, 143 insertions(+), 28 deletions(-) diff --git a/apps/connect/internal/hermes/client.go b/apps/connect/internal/hermes/client.go index 6c730f4..b82e81b 100644 --- a/apps/connect/internal/hermes/client.go +++ b/apps/connect/internal/hermes/client.go @@ -167,6 +167,8 @@ func RoutePath(path string) Route { return Route{Kind: RouteControlForward, Path: "/api/gateway/restart"} case "/logs": return Route{Kind: RouteControlForward, Path: "/api/logs"} + case "/api/cron/jobs": + return Route{Kind: RouteControlForward, Path: path} case "/jobs", "/jobs/": return Route{Kind: RouteControlForward, Path: "/api/cron/jobs"} } @@ -182,6 +184,10 @@ func RoutePath(path string) Route { if name != "" && !strings.Contains(name, "/") { return Route{Kind: RouteControlForward, Path: "/api/tools/toolsets/" + name} } + case strings.HasPrefix(path, "/api/cron/jobs/"): + if canonicalCronJobPath(path) { + return Route{Kind: RouteControlForward, Path: path} + } case strings.HasPrefix(path, "/jobs/"): if mapped, ok := legacyCronJobPath(path); ok { return Route{Kind: RouteControlForward, Path: mapped} @@ -193,6 +199,23 @@ func RoutePath(path string) Route { return Route{Kind: RouteUnknown} } +func canonicalCronJobPath(path string) bool { + rest := strings.TrimPrefix(path, "/api/cron/jobs/") + id, action, hasAction := strings.Cut(rest, "/") + if id == "" || strings.Contains(action, "/") { + return false + } + if !hasAction { + return true + } + switch action { + case "pause", "resume", "trigger", "runs": + return true + default: + return false + } +} + func legacyCronJobPath(path string) (string, bool) { rest := strings.TrimPrefix(path, "/jobs/") id, action, hasAction := strings.Cut(rest, "/") diff --git a/apps/connect/internal/hermes/client_test.go b/apps/connect/internal/hermes/client_test.go index d41f459..371ee86 100644 --- a/apps/connect/internal/hermes/client_test.go +++ b/apps/connect/internal/hermes/client_test.go @@ -70,6 +70,8 @@ func TestRoutePath(t *testing.T) { {path: "/gateway/status", kind: RouteControlForward, forwardTo: "/api/status"}, {path: "/gateway/restart", kind: RouteControlForward, forwardTo: "/api/gateway/restart"}, {path: "/logs", kind: RouteControlForward, forwardTo: "/api/logs"}, + {path: "/api/cron/jobs", kind: RouteControlForward, forwardTo: "/api/cron/jobs"}, + {path: "/api/cron/jobs/job_1/runs", kind: RouteControlForward, forwardTo: "/api/cron/jobs/job_1/runs"}, {path: "/jobs/", kind: RouteControlForward, forwardTo: "/api/cron/jobs"}, {path: "/jobs/job_1/pause", kind: RouteControlForward, forwardTo: "/api/cron/jobs/job_1/pause"}, {path: "/jobs/job_1/runs", kind: RouteControlForward, forwardTo: "/api/cron/jobs/job_1/runs"}, diff --git a/apps/mobile/src/lib/brio.test.mjs b/apps/mobile/src/lib/brio.test.mjs index 9b9f844..b7f203c 100644 --- a/apps/mobile/src/lib/brio.test.mjs +++ b/apps/mobile/src/lib/brio.test.mjs @@ -146,8 +146,40 @@ test('scopes default-profile cron lists and run history instead of using Hermes try { await listJobs(connection, 'default'); await listJobRuns(connection, 'job-1', 20, 'default'); - assert.equal(requests[0], 'http://127.0.0.1:8787/jobs/?profile=default'); - assert.equal(requests[1], 'http://127.0.0.1:8787/jobs/job-1/runs?limit=20&profile=default'); + assert.equal(requests[0], 'http://127.0.0.1:8787/api/cron/jobs?profile=default'); + assert.equal(requests[1], 'http://127.0.0.1:8787/api/cron/jobs/job-1/runs?limit=20&profile=default'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('falls back to legacy cron sessions when an older connector lacks the run endpoint', async () => { + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (input) => { + const url = String(input); + requests.push(url); + if (url.includes('/api/cron/jobs/') || url.includes('/jobs/job-1/runs')) { + return new Response(JSON.stringify({ error: 'no route for GET ' + new URL(url).pathname }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/api/sessions')) { + return new Response(JSON.stringify({ data: [ + { id: 'cron_job-1_20260828_120000', source: 'cron', started_at: 1, message_count: 2 }, + { id: 'cron-other_20260828_120000', source: 'cron', started_at: 2, message_count: 2 }, + ] }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected request: ${url}`); + }; + try { + const runs = await listJobRuns({ + id: 'direct-1', name: 'Hermes', mode: 'self_hosted', transport: 'direct', status: 'online', + capabilities: {}, url: 'http://127.0.0.1:8787', token: 'secret', + }, 'job-1', 20, 'default'); + assert.deepEqual(runs.runs.map((run) => run.id), ['cron_job-1_20260828_120000']); + assert.match(requests.at(-1), /\/api\/sessions\?limit=100&source=cron&order=recent/); } finally { globalThis.fetch = originalFetch; } diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index dadfdf3..8f54364 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -1100,45 +1100,103 @@ export function getLogs( ); } -export function listJobs(connection: AgentConnection, profile?: string) { - return brioFetch( - connection, - `${scopedPath('/jobs/', profile)}${defaultAutomationProfileQuery(profile)}`, - ); +export async function listJobs(connection: AgentConnection, profile?: string) { + try { + return await brioFetch( + connection, + `${scopedPath('/api/cron/jobs', profile)}${automationProfileQuery(profile)}`, + ); + } catch (error) { + if (!isUnsupportedAutomationRoute(error)) throw error; + return brioFetch( + connection, + `${scopedPath('/jobs/', profile)}${automationProfileQuery(profile)}`, + ); + } } -export function listJobRuns(connection: AgentConnection, jobId: string, limit = 20, profile?: string) { - const query = new URLSearchParams({ limit: String(Math.max(1, Math.min(limit, 100))) }); - if (!profile || profile === 'default') query.set('profile', 'default'); - return brioFetch<{ runs: HermesSession[]; limit?: number }>( - connection, - `${scopedPath(`/jobs/${encodeURIComponent(jobId)}/runs`, profile)}?${query.toString()}`, - ); +export async function listJobRuns(connection: AgentConnection, jobId: string, limit = 20, profile?: string) { + const boundedLimit = Math.max(1, Math.min(limit, 100)); + const query = new URLSearchParams({ limit: String(boundedLimit) }); + query.set('profile', profileNameForAutomation(profile)); + try { + return await brioFetch<{ runs: HermesSession[]; limit?: number }>( + connection, + `${scopedPath(`/api/cron/jobs/${encodeURIComponent(jobId)}/runs`, profile)}?${query.toString()}`, + ); + } catch (error) { + if (!isUnsupportedAutomationRoute(error)) throw error; + try { + return await brioFetch<{ runs: HermesSession[]; limit?: number }>( + connection, + `${scopedPath(`/jobs/${encodeURIComponent(jobId)}/runs`, profile)}?${query.toString()}`, + ); + } catch (legacyError) { + if (!isUnsupportedAutomationRoute(legacyError)) throw legacyError; + const sessions = await listSessions(connection, 100, profile, { source: 'cron', order: 'recent' }); + const prefix = `cron_${jobId}_`; + return { + runs: sessions.sessions.filter((session) => session.id.startsWith(prefix)).slice(0, boundedLimit), + limit: boundedLimit, + }; + } + } } -export function runJobAction( +export async function runJobAction( connection: AgentConnection, jobId: string, action: 'pause' | 'resume' | 'trigger', profile?: string, ) { - return brioFetch>( - connection, - `${scopedPath(`/jobs/${encodeURIComponent(jobId)}/${action}`, profile)}${defaultAutomationProfileQuery(profile)}`, - { method: 'POST', body: '{}' }, - ); + try { + return await brioFetch>( + connection, + `${scopedPath(`/api/cron/jobs/${encodeURIComponent(jobId)}/${action}`, profile)}${automationProfileQuery(profile)}`, + { method: 'POST', body: '{}' }, + ); + } catch (error) { + if (!isUnsupportedAutomationRoute(error)) throw error; + return brioFetch>( + connection, + `${scopedPath(`/jobs/${encodeURIComponent(jobId)}/${action}`, profile)}${automationProfileQuery(profile)}`, + { method: 'POST', body: '{}' }, + ); + } } -export function deleteJob(connection: AgentConnection, jobId: string, profile?: string) { - return brioFetch>( - connection, - `${scopedPath(`/jobs/${encodeURIComponent(jobId)}`, profile)}${defaultAutomationProfileQuery(profile)}`, - { method: 'DELETE' }, - ); +export async function deleteJob(connection: AgentConnection, jobId: string, profile?: string) { + try { + return await brioFetch>( + connection, + `${scopedPath(`/api/cron/jobs/${encodeURIComponent(jobId)}`, profile)}${automationProfileQuery(profile)}`, + { method: 'DELETE' }, + ); + } catch (error) { + if (!isUnsupportedAutomationRoute(error)) throw error; + return brioFetch>( + connection, + `${scopedPath(`/jobs/${encodeURIComponent(jobId)}`, profile)}${automationProfileQuery(profile)}`, + { method: 'DELETE' }, + ); + } } -function defaultAutomationProfileQuery(profile?: string) { - return !profile || profile === 'default' ? '?profile=default' : ''; +function profileNameForAutomation(profile?: string) { + return profile?.trim() || 'default'; +} + +function automationProfileQuery(profile?: string) { + return `?profile=${encodeURIComponent(profileNameForAutomation(profile))}`; +} + +function isUnsupportedAutomationRoute(error: unknown) { + if (error instanceof BrioRequestError) { + return error.status === 404 || /no route|not found/i.test(error.message); + } + // Relay gateways can surface the upstream route error as a plain Error, + // without preserving the HTTP status frame. + return error instanceof Error && /no route|not found/i.test(error.message); } export function controlRPC( From 65662051a4957a9bca2578d9250651f7a560eef8 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 28 Aug 2026 18:03:11 -0400 Subject: [PATCH 2/3] feat(mobile): load older automation responses - Batch response fetching and add bounded load-more controls - Improve legacy job-run fallback scanning --- .../automation/hermes-automation-screen.tsx | 95 ++++++++++++++----- apps/mobile/src/lib/brio.ts | 3 + 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/automation/hermes-automation-screen.tsx b/apps/mobile/src/features/automation/hermes-automation-screen.tsx index e103dba..d24a599 100644 --- a/apps/mobile/src/features/automation/hermes-automation-screen.tsx +++ b/apps/mobile/src/features/automation/hermes-automation-screen.tsx @@ -1,4 +1,4 @@ -import { useQueries, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useState } from 'react'; import { Pressable, RefreshControl, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -30,10 +30,20 @@ type AutomationResponse = { timestamp: number; }; +const INITIAL_AUTOMATION_RESPONSE_LIMIT = 5; +const MAX_AUTOMATION_RESPONSE_LIMIT = 20; + +type AutomationMessageBatch = { + sessions: HermesSession[]; + messageSets: { role: string; content: string; timestamp: number }[][]; + error?: Error; +}; + export function HermesAutomationScreen({ connection }: { connection: AgentConnection }) { const colors = useT3Theme(); const [view, setView] = useState('jobs'); const [selectedJob, setSelectedJob] = useState(null); + const [heartbeatLimit, setHeartbeatLimit] = useState(INITIAL_AUTOMATION_RESPONSE_LIMIT); const agentId = environmentId(connection); const storedProfiles = useProfileStore((state) => state.activeProfiles); const profilesQuery = useQuery({ @@ -58,8 +68,8 @@ export function HermesAutomationScreen({ connection }: { connection: AgentConnec refetchInterval: 15_000, }); const heartbeatSessions = useQuery({ - queryKey: ['automation-heartbeats', connection.id, connection.url, activeProfile], - queryFn: () => listSessions(connection, 20, activeProfile, { source: 'heartbeat', order: 'recent' }), + queryKey: ['automation-heartbeats', connection.id, connection.url, activeProfile, heartbeatLimit], + queryFn: () => listSessions(connection, heartbeatLimit, activeProfile, { source: 'heartbeat', order: 'recent' }), refetchInterval: 15_000, }); @@ -112,6 +122,8 @@ export function HermesAutomationScreen({ connection }: { connection: AgentConnec title="Heartbeat responses" onRefresh={() => void heartbeatSessions.refetch()} refreshing={heartbeatSessions.isRefetching} + canLoadMore={heartbeatSessions.data?.sessions.length === heartbeatLimit && heartbeatLimit < MAX_AUTOMATION_RESPONSE_LIMIT} + onLoadMore={() => setHeartbeatLimit((limit) => Math.min(limit * 2, MAX_AUTOMATION_RESPONSE_LIMIT))} oneResponsePerSession requestPrefix="[Heartbeat — recurring instruction" /> @@ -133,9 +145,10 @@ function JobResponses({ }) { const colors = useT3Theme(); const id = jobID(job); + const [runLimit, setRunLimit] = useState(INITIAL_AUTOMATION_RESPONSE_LIMIT); const runs = useQuery({ - queryKey: ['automation-job-runs', connection.id, connection.url, profile, id], - queryFn: () => listJobRuns(connection, id, 20, profile), + queryKey: ['automation-job-runs', connection.id, connection.url, profile, id, runLimit], + queryFn: () => listJobRuns(connection, id, runLimit, profile), enabled: Boolean(id), refetchInterval: 15_000, }); @@ -160,6 +173,8 @@ function JobResponses({ title="Run responses" onRefresh={() => void runs.refetch()} refreshing={runs.isRefetching} + canLoadMore={runs.data?.runs.length === runLimit && runLimit < MAX_AUTOMATION_RESPONSE_LIMIT} + onLoadMore={() => setRunLimit((limit) => Math.min(limit * 2, MAX_AUTOMATION_RESPONSE_LIMIT))} oneResponsePerSession /> @@ -175,6 +190,8 @@ function ResponseFeed({ oneResponsePerSession = false, profile, refreshing, + canLoadMore = false, + onLoadMore, requestPrefix, sessions, sessionsError, @@ -186,6 +203,8 @@ function ResponseFeed({ emptyDetail: string; emptyTitle: string; onRefresh: () => void; + canLoadMore?: boolean; + onLoadMore?: () => void; oneResponsePerSession?: boolean; profile: string; refreshing: boolean; @@ -196,28 +215,50 @@ function ResponseFeed({ title: string; }) { const colors = useT3Theme(); - const responseQueries = useQueries({ - queries: sessions.map((session) => ({ - queryKey: ['automation-response', connection.id, connection.url, profile, session.id, session.message_count], - queryFn: () => getSessionMessages(connection, session.id, profile), - retry: 2, - refetchInterval: (query: { state: { status: string } }) => query.state.status === 'error' ? 30_000 : false, - })), - }); - const successfulSessions: HermesSession[] = []; - const successfulMessages: { role: string; content: string; timestamp: number }[][] = []; - responseQueries.forEach((query, index) => { - if (!query.data) return; - successfulSessions.push(sessions[index]); - successfulMessages.push(query.data.messages); + const responseQuery = useQuery({ + queryKey: [ + 'automation-responses', + connection.id, + connection.url, + profile, + sessions.map((session) => `${session.id}:${session.message_count}`).join('|'), + ], + enabled: !sessionsLoading && sessions.length > 0, + queryFn: async () => { + const results = await Promise.allSettled( + sessions.map(async (session) => ({ + session, + messages: (await getSessionMessages(connection, session.id, profile)).messages, + })), + ); + const successfulSessions: HermesSession[] = []; + const successfulMessages: { role: string; content: string; timestamp: number }[][] = []; + let error: Error | undefined; + results.forEach((result) => { + if (result.status === 'fulfilled') { + successfulSessions.push(result.value.session); + successfulMessages.push(result.value.messages); + } else if (!error) { + error = result.reason instanceof Error ? result.reason : new Error(String(result.reason)); + } + }); + return { sessions: successfulSessions, messageSets: successfulMessages, error }; + }, + retry: false, + staleTime: 30_000, }); - const responses = collectResponses(successfulSessions, successfulMessages, oneResponsePerSession, requestPrefix); - const responsesLoading = responseQueries.some((query) => query.isLoading); - const responsesRefetching = responseQueries.some((query) => query.isRefetching); - const responseError = responseQueries.find((query) => query.error)?.error; + const responses = collectResponses( + responseQuery.data?.sessions ?? [], + responseQuery.data?.messageSets ?? [], + oneResponsePerSession, + requestPrefix, + ); + const responsesLoading = responseQuery.isLoading; + const responsesRefetching = responseQuery.isRefetching; + const responseError = responseQuery.error ?? responseQuery.data?.error; const refreshAll = () => { onRefresh(); - responseQueries.forEach((query) => void query.refetch()); + void responseQuery.refetch(); }; return ( @@ -233,6 +274,11 @@ function ResponseFeed({ ) : null} {sessionsError || responseError ? : null} {responses.map((response) => )} + {canLoadMore && onLoadMore ? ( + + + + ) : null} {!sessionsLoading && !sessionsError && !responsesLoading && !responseError && responses.length === 0 ? ( ) : null} @@ -371,4 +417,5 @@ const styles = StyleSheet.create({ responseHeader: { alignItems: 'baseline', flexDirection: 'row', justifyContent: 'space-between' }, responseLabel: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 17 }, responseText: { fontSize: 15, lineHeight: 22 }, + loadMore: { alignItems: 'center', paddingVertical: T3Spacing.sm }, }); diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index 8f54364..924746c 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -1133,6 +1133,9 @@ export async function listJobRuns(connection: AgentConnection, jobId: string, li ); } catch (legacyError) { if (!isUnsupportedAutomationRoute(legacyError)) throw legacyError; + // The legacy gateway may ignore source filters. Scan a full recent page + // so an older job is not mistaken for a job with no runs; the caller's + // boundedLimit still caps the message-history fan-out. const sessions = await listSessions(connection, 100, profile, { source: 'cron', order: 'recent' }); const prefix = `cron_${jobId}_`; return { From 210dd230696f87f0d1729d914f3888719d76b896 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 28 Aug 2026 19:40:28 -0400 Subject: [PATCH 3/3] feat(mobile): improve automation response browsing - Add paginated automation history with legacy relay compatibility - Add markdown-rendered run detail views and response metadata - Add react-native-markdown-display dependency --- apps/mobile/package-lock.json | 115 +++++- apps/mobile/package.json | 1 + .../automation/hermes-automation-screen.tsx | 369 ++++++++++++------ apps/mobile/src/lib/brio.ts | 265 ++++++++++++- 4 files changed, 620 insertions(+), 130 deletions(-) diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 7b3612a..f855bf6 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -36,6 +36,7 @@ "react-dom": "19.2.3", "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", + "react-native-markdown-display": "^7.0.2", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -6755,6 +6756,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -7091,6 +7101,15 @@ "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", "license": "MIT" }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-in-js-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", @@ -7100,6 +7119,17 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -7381,6 +7411,12 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz", + "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==", + "license": "BSD-2-Clause" + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -10575,6 +10611,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10729,6 +10774,31 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz", + "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "entities": "~2.0.0", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -10745,6 +10815,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -12051,7 +12127,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -12063,7 +12138,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -12389,6 +12463,15 @@ "react-native-reanimated": ">= 2.0.0" } }, + "node_modules/react-native-fit-image": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", + "integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==", + "license": "Beerware", + "dependencies": { + "prop-types": "^15.5.10" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", @@ -12416,6 +12499,22 @@ "react-native": "*" } }, + "node_modules/react-native-markdown-display": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz", + "integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==", + "license": "MIT", + "dependencies": { + "css-to-react-native": "^3.0.0", + "markdown-it": "^10.0.0", + "prop-types": "^15.7.2", + "react-native-fit-image": "^1.5.5" + }, + "peerDependencies": { + "react": ">=16.2.0", + "react-native": ">=0.50.4" + } + }, "node_modules/react-native-reanimated": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", @@ -13414,6 +13513,12 @@ "node": ">=6" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -13997,6 +14102,12 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 71c7220..c761b97 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -31,6 +31,7 @@ "react-dom": "19.2.3", "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", + "react-native-markdown-display": "^7.0.2", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", diff --git a/apps/mobile/src/features/automation/hermes-automation-screen.tsx b/apps/mobile/src/features/automation/hermes-automation-screen.tsx index d24a599..b3f9d6f 100644 --- a/apps/mobile/src/features/automation/hermes-automation-screen.tsx +++ b/apps/mobile/src/features/automation/hermes-automation-screen.tsx @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query'; +import Markdown from 'react-native-markdown-display'; import { useState } from 'react'; import { Pressable, RefreshControl, ScrollView, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -8,11 +9,12 @@ import { T3Radius, T3Spacing, T3Typography } from '@/constants/t3-theme'; import { useT3Theme } from '@/hooks/use-t3-theme'; import { getSessionMessages, - listJobRuns, + listAutomationSessionsPage, + listJobRunsPage, listJobs, - listSessions, type AgentConnection, type HermesJob, + type HermesSessionPage, type HermesSession, } from '@/lib/brio'; import { @@ -26,24 +28,24 @@ import { useProfileStore } from '@/state/profile-store'; type AutomationView = 'jobs' | 'heartbeats'; type AutomationResponse = { id: string; + sessionId: string; content: string; timestamp: number; + startedAt: number; + endedAt?: number | null; + messageCount: number; + model?: string; }; const INITIAL_AUTOMATION_RESPONSE_LIMIT = 5; -const MAX_AUTOMATION_RESPONSE_LIMIT = 20; +const MAX_AUTOMATION_PAGES = 20; -type AutomationMessageBatch = { - sessions: HermesSession[]; - messageSets: { role: string; content: string; timestamp: number }[][]; - error?: Error; -}; +type AutomationPageLoader = (cursor?: string) => Promise; export function HermesAutomationScreen({ connection }: { connection: AgentConnection }) { const colors = useT3Theme(); const [view, setView] = useState('jobs'); const [selectedJob, setSelectedJob] = useState(null); - const [heartbeatLimit, setHeartbeatLimit] = useState(INITIAL_AUTOMATION_RESPONSE_LIMIT); const agentId = environmentId(connection); const storedProfiles = useProfileStore((state) => state.activeProfiles); const profilesQuery = useQuery({ @@ -67,12 +69,6 @@ export function HermesAutomationScreen({ connection }: { connection: AgentConnec }, refetchInterval: 15_000, }); - const heartbeatSessions = useQuery({ - queryKey: ['automation-heartbeats', connection.id, connection.url, activeProfile, heartbeatLimit], - queryFn: () => listSessions(connection, heartbeatLimit, activeProfile, { source: 'heartbeat', order: 'recent' }), - refetchInterval: 15_000, - }); - if (selectedJob) { return ( ) : ( void heartbeatSessions.refetch()} - refreshing={heartbeatSessions.isRefetching} - canLoadMore={heartbeatSessions.data?.sessions.length === heartbeatLimit && heartbeatLimit < MAX_AUTOMATION_RESPONSE_LIMIT} - onLoadMore={() => setHeartbeatLimit((limit) => Math.min(limit * 2, MAX_AUTOMATION_RESPONSE_LIMIT))} - oneResponsePerSession - requestPrefix="[Heartbeat — recurring instruction" - /> + detail="Recurring heartbeat prompts run in isolated automation sessions seeded from the source chat. Only Hermes’ responses appear here." + emptyDetail="Heartbeat responses will appear here after a configured heartbeat fires." + emptyTitle="No heartbeat responses" + profile={activeProfile} + connection={connection} + title="Heartbeat responses" + queryKey={['automation-heartbeats', connection.id, connection.url, activeProfile]} + loadPage={(cursor) => listAutomationSessionsPage( + connection, + INITIAL_AUTOMATION_RESPONSE_LIMIT, + activeProfile, + { source: 'heartbeat', order: 'recent' }, + cursor, + )} + oneResponsePerSession + requestPrefix="[Heartbeat — recurring instruction" + /> )} ); @@ -145,13 +142,6 @@ function JobResponses({ }) { const colors = useT3Theme(); const id = jobID(job); - const [runLimit, setRunLimit] = useState(INITIAL_AUTOMATION_RESPONSE_LIMIT); - const runs = useQuery({ - queryKey: ['automation-job-runs', connection.id, connection.url, profile, id, runLimit], - queryFn: () => listJobRuns(connection, id, runLimit, profile), - enabled: Boolean(id), - refetchInterval: 15_000, - }); return ( @@ -167,14 +157,16 @@ function JobResponses({ emptyDetail="Run this job once and its response will appear here." emptyTitle="No responses yet" profile={profile} - sessions={runs.data?.runs ?? []} - sessionsError={runs.error} - sessionsLoading={runs.isLoading} title="Run responses" - onRefresh={() => void runs.refetch()} - refreshing={runs.isRefetching} - canLoadMore={runs.data?.runs.length === runLimit && runLimit < MAX_AUTOMATION_RESPONSE_LIMIT} - onLoadMore={() => setRunLimit((limit) => Math.min(limit * 2, MAX_AUTOMATION_RESPONSE_LIMIT))} + queryKey={['automation-job-runs', connection.id, connection.url, profile, id]} + enabled={Boolean(id)} + loadPage={(cursor) => listJobRunsPage( + connection, + id, + INITIAL_AUTOMATION_RESPONSE_LIMIT, + profile, + cursor, + )} oneResponsePerSession /> @@ -186,80 +178,90 @@ function ResponseFeed({ detail, emptyDetail, emptyTitle, - onRefresh, + loadPage, + enabled = true, oneResponsePerSession = false, profile, - refreshing, - canLoadMore = false, - onLoadMore, + queryKey, requestPrefix, - sessions, - sessionsError, - sessionsLoading, title, }: { connection: AgentConnection; detail: string; emptyDetail: string; emptyTitle: string; - onRefresh: () => void; - canLoadMore?: boolean; - onLoadMore?: () => void; + loadPage: AutomationPageLoader; + enabled?: boolean; oneResponsePerSession?: boolean; profile: string; - refreshing: boolean; + queryKey: readonly unknown[]; requestPrefix?: string; - sessions: HermesSession[]; - sessionsError: unknown; - sessionsLoading: boolean; title: string; }) { const colors = useT3Theme(); - const responseQuery = useQuery({ - queryKey: [ - 'automation-responses', - connection.id, - connection.url, - profile, - sessions.map((session) => `${session.id}:${session.message_count}`).join('|'), - ], - enabled: !sessionsLoading && sessions.length > 0, - queryFn: async () => { - const results = await Promise.allSettled( - sessions.map(async (session) => ({ - session, - messages: (await getSessionMessages(connection, session.id, profile)).messages, - })), - ); - const successfulSessions: HermesSession[] = []; - const successfulMessages: { role: string; content: string; timestamp: number }[][] = []; - let error: Error | undefined; - results.forEach((result) => { - if (result.status === 'fulfilled') { - successfulSessions.push(result.value.session); - successfulMessages.push(result.value.messages); - } else if (!error) { - error = result.reason instanceof Error ? result.reason : new Error(String(result.reason)); - } - }); - return { sessions: successfulSessions, messageSets: successfulMessages, error }; + const [selectedResponse, setSelectedResponse] = useState(null); + const sessionQuery = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam }) => loadPage(typeof pageParam === 'string' ? pageParam : undefined), + initialPageParam: null as string | null, + enabled, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.hasMore || !lastPage.nextCursor) return undefined; + if (allPages.length >= MAX_AUTOMATION_PAGES) return undefined; + const previousCursors = new Set(allPages.slice(0, -1).map((page) => page.nextCursor)); + if (previousCursors.has(lastPage.nextCursor)) return undefined; + const previousIds = new Set(allPages.slice(0, -1).flatMap((page) => page.sessions.map((session) => session.id))); + const hasNewSession = lastPage.sessions.some((session) => !previousIds.has(session.id)); + if (!hasNewSession && !lastPage.continueWhenEmpty) return undefined; + return lastPage.nextCursor; }, + refetchInterval: 15_000, retry: false, - staleTime: 30_000, }); - const responses = collectResponses( - responseQuery.data?.sessions ?? [], - responseQuery.data?.messageSets ?? [], - oneResponsePerSession, - requestPrefix, - ); - const responsesLoading = responseQuery.isLoading; - const responsesRefetching = responseQuery.isRefetching; - const responseError = responseQuery.error ?? responseQuery.data?.error; + const sessions = dedupeSessions(sessionQuery.data?.pages.flatMap((page) => page.sessions) ?? []); + const responseQueries = useQueries({ + queries: sessions.map((session) => ({ + queryKey: ['automation-response', connection.id, connection.url, profile, session.id, session.message_count], + enabled: enabled && sessions.length > 0, + queryFn: () => getSessionMessages(connection, session.id, profile), + retry: false, + staleTime: 30_000, + })), + }); + const successfulSessions: HermesSession[] = []; + const successfulMessages: { role: string; content: string; timestamp: number }[][] = []; + responseQueries.forEach((query, index) => { + if (!query.data) return; + successfulSessions.push(sessions[index]); + successfulMessages.push(query.data.messages); + }); + const responses = collectResponses(successfulSessions, successfulMessages, oneResponsePerSession, requestPrefix); + const sessionsLoading = sessionQuery.isLoading; + const responsesLoading = sessions.length > 0 && responseQueries.some((query) => query.isLoading); + const responsesRefetching = responseQueries.some((query) => query.isRefetching); + const loadingMoreResponses = sessionQuery.data?.pages.length + ? sessionQuery.data.pages.length > 1 && responseQueries.some((query) => query.isLoading) + : false; + const loadMoreLoading = sessionQuery.isFetchingNextPage || loadingMoreResponses; + const responseError = responseQueries.find((query) => query.error)?.error; const refreshAll = () => { - onRefresh(); - void responseQuery.refetch(); + void sessionQuery.refetch(); + responseQueries.forEach((query) => void query.refetch()); }; + const refreshing = sessionQuery.isRefetching && !sessionQuery.isFetchingNextPage; + const historyCapped = (sessionQuery.data?.pages.length ?? 0) >= MAX_AUTOMATION_PAGES; + const historyExhausted = (historyCapped || !sessionQuery.hasNextPage) && !sessionQuery.isFetching && !sessionQuery.error; + const historyWarning = sessionQuery.data?.pages.find((page) => page.warning)?.warning; + const initialResponsesLoading = (sessionQuery.data?.pages.length ?? 0) <= 1 && responsesLoading; + + if (selectedResponse) { + return ( + setSelectedResponse(null)} + /> + ); + } return ( {title} {detail} - {sessionsLoading || (sessions.length > 0 && responsesLoading) ? ( + {sessionsLoading || initialResponsesLoading ? ( ) : null} - {sessionsError || responseError ? : null} - {responses.map((response) => )} - {canLoadMore && onLoadMore ? ( + {sessionQuery.error || responseError ? : null} + {responses.map((response) => ( + setSelectedResponse(response)} + response={response} + /> + ))} + {historyWarning ? ( + {historyWarning} + ) : null} + {(sessionQuery.hasNextPage || loadMoreLoading) ? ( - + ) : null} - {!sessionsLoading && !sessionsError && !responsesLoading && !responseError && responses.length === 0 ? ( + {historyExhausted && sessions.length > 0 ? ( + + {historyCapped ? 'Response history limit reached.' : 'No older responses available.'} + + ) : null} + {!sessionsLoading && !sessionQuery.error && !responsesLoading && !responseError && responses.length === 0 ? ( ) : null} ); } +function RunDetail({ response, onBack }: { response: AutomationResponse; onBack: () => void }) { + const colors = useT3Theme(); + const status = response.endedAt === null ? 'Running' : 'Completed'; + return ( + + + + + Run details + {formatTime(response.timestamp)} + + + + + + + + {status} run + + Hermes + + + + + + + + Run ID: {response.sessionId} + + + + {response.content} + + + + + ); +} + +function RunMeta({ label, value }: { label: string; value: string }) { + const colors = useT3Theme(); + return ( + + {label} + {value} + + ); +} + function JobCard({ job, onPress }: { job: HermesJob; onPress: () => void }) { const colors = useT3Theme(); const paused = job.paused === true || job.enabled === false || job.state === 'paused'; @@ -306,16 +392,28 @@ function JobCard({ job, onPress }: { job: HermesJob; onPress: () => void }) { ); } -function ResponseCard({ response }: { response: AutomationResponse }) { +function ResponseCard({ response, onPress }: { response: AutomationResponse; onPress: () => void }) { const colors = useT3Theme(); + const status = response.endedAt === null ? 'Running' : 'Completed'; return ( - - - Hermes - {formatTime(response.timestamp)} - - {response.content} - + ({ opacity: pressed ? 0.65 : 1 })}> + + + + + {status} run + + {formatTime(response.timestamp)} + + {previewText(response.content)} + + + {response.model || 'Default model'} · {response.messageCount} messages + + View details › + + + ); } @@ -342,6 +440,15 @@ function Failure({ error, onRetry }: { error: unknown; onRetry: () => void }) { ); } +function dedupeSessions(sessions: HermesSession[]) { + const seen = new Set(); + return sessions.filter((session) => { + if (seen.has(session.id)) return false; + seen.add(session.id); + return true; + }); +} + function collectResponses( sessions: HermesSession[], messageSets: { role: string; content: string; timestamp: number }[][], @@ -366,14 +473,31 @@ function collectResponses( visible.forEach((message, messageIndex) => { responses.push({ id: `${sessions[sessionIndex]?.id ?? sessionIndex}:${message.timestamp}:${messageIndex}`, + sessionId: sessions[sessionIndex]?.id ?? String(sessionIndex), content: message.content.trim(), timestamp: message.timestamp || sessions[sessionIndex]?.started_at || 0, + startedAt: sessions[sessionIndex]?.started_at || 0, + endedAt: sessions[sessionIndex]?.ended_at, + messageCount: sessions[sessionIndex]?.message_count || messages.length, + model: sessions[sessionIndex]?.model, }); }); }); return responses.sort((left, right) => right.timestamp - left.timestamp); } +function previewText(content: string) { + return content + .replace(/```[\s\S]*?```/g, 'Code block') + .replace(/^#{1,6}\s+/gm, '') + .replace(/^\s*[-*+]\s+/gm, '• ') + .replace(/^\s*\d+\.\s+/gm, '') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[*_`~]/g, '') + .replace(/\n{2,}/g, '\n') + .trim(); +} + function jobID(job: HermesJob) { const id = job.id ?? job.job_id; return typeof id === 'string' ? id : ''; @@ -414,8 +538,21 @@ const styles = StyleSheet.create({ detailHeaderCopy: { flex: 1, paddingRight: 70 }, detailTitle: { fontFamily: T3Typography.bold, fontSize: 17, lineHeight: 22, textAlign: 'center' }, responseCard: { gap: T3Spacing.md, padding: T3Spacing.lg }, - responseHeader: { alignItems: 'baseline', flexDirection: 'row', justifyContent: 'space-between' }, + responseFooter: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.sm, justifyContent: 'space-between' }, + responseHeader: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between' }, responseLabel: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 17 }, - responseText: { fontSize: 15, lineHeight: 22 }, + responsePreview: { fontSize: 15, lineHeight: 21 }, + responseTitle: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.sm }, + runDetail: { flex: 1 }, + runDetailContent: { alignSelf: 'center', gap: T3Spacing.md, maxWidth: 720, padding: T3Spacing.xl, paddingBottom: T3Spacing.huge, width: '100%' }, + runID: { fontFamily: T3Typography.mono, fontSize: 11, lineHeight: 16 }, + runMetaGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: T3Spacing.lg, marginTop: T3Spacing.sm }, + runMetaItem: { flexGrow: 1, gap: T3Spacing.xs, minWidth: '42%' }, + runMetaValue: { fontSize: 14, lineHeight: 19 }, + runSummary: { gap: T3Spacing.md, padding: T3Spacing.lg }, + runSummaryHeader: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between' }, + runSummaryTitle: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.sm }, + markdownCard: { padding: T3Spacing.lg }, loadMore: { alignItems: 'center', paddingVertical: T3Spacing.sm }, + historyStatus: { fontSize: 12, lineHeight: 16, textAlign: 'center' }, }); diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index 924746c..4bc82bd 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -162,6 +162,14 @@ type HermesSessionListEnvelope = { error?: string; }; +export type HermesSessionPage = { + sessions: HermesSession[]; + hasMore: boolean; + nextCursor?: string; + continueWhenEmpty?: boolean; + warning?: string; +}; + type HermesMessageListEnvelope = { messages?: HermesMessage[]; data?: HermesMessage[]; @@ -707,6 +715,140 @@ export function interruptComposerSession(connection: AgentConnection, sessionId: }); } +function parseSessionCursor(cursor?: string) { + if (!cursor) return { offset: 0, lastId: undefined as string | undefined }; + try { + const parsed = JSON.parse(decodeURIComponent(cursor)) as { + offset?: number; + strategy?: 'raw' | 'window'; + firstId?: string; + lastId?: string; + }; + if (Number.isSafeInteger(parsed.offset) && parsed.offset >= 0) { + return { offset: parsed.offset, strategy: parsed.strategy ?? 'raw', firstId: parsed.firstId, lastId: parsed.lastId }; + } + } catch { + // Accept the numeric cursor used by early development builds. + } + const offset = Number(cursor); + return { offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, strategy: 'raw' as const, firstId: undefined, lastId: undefined }; +} + +function makeSessionCursor(offset: number, lastId?: string, firstId?: string, strategy: 'raw' | 'window' = 'raw') { + return encodeURIComponent(JSON.stringify({ offset, strategy, firstId, lastId })); +} + +/** + * Loads one stable page for automation feeds. The cursor is an opaque raw + * session offset so it also works when an older Hermes relay ignores source + * filters and Brio has to filter the compatibility page locally. + */ +export async function listAutomationSessionsPage( + connection: AgentConnection, + limit = 5, + profile?: string, + options: SessionListOptions = {}, + cursor?: string, +): Promise { + const included = options.source + ? new Set([options.source]) + : options.sources?.length + ? new Set(options.sources) + : null; + const excluded = new Set(options.excludeSources ?? []); + const hasSourceFilter = Boolean(included || excluded.size); + const requestedLimit = Math.max(1, Math.min(limit, 100)); + const pageSize = hasSourceFilter ? 100 : requestedLimit; + const maxCompatibilityPages = 10; + const sessions: HermesSession[] = []; + const seen = new Set(); + const cursorState = parseSessionCursor(cursor); + const windowMode = cursorState.strategy === 'window'; + let rawOffset = cursorState.offset; + + for (let pageIndex = 0; pageIndex < (hasSourceFilter && !windowMode ? maxCompatibilityPages : 1); pageIndex += 1) { + const pageStart = rawOffset; + const query = new URLSearchParams({ + limit: String(windowMode ? Math.min(100, pageStart + requestedLimit) : pageSize), + }); + if (pageStart && !windowMode) query.set('offset', String(pageStart)); + if (options.source) query.set('source', options.source); + if (options.sources?.length) query.set('sources', options.sources.join(',')); + if (options.excludeSources?.length) query.set('exclude_sources', options.excludeSources.join(',')); + if (options.order) query.set('order', options.order); + + const envelope = await brioFetch( + connection, + `${scopedPath('/api/sessions', profile)}?${query.toString()}`, + ); + const rawPage = normalizeSessionList(envelope).sessions; + if (!windowMode && ( + (cursorState.firstId && rawPage[0]?.id === cursorState.firstId) + || (cursorState.lastId && rawPage[0]?.id === cursorState.lastId) + )) { + return { sessions: [], hasMore: false }; + } + if (windowMode) { + const page = rawPage.filter((session) => + (!included || included.has(session.source)) && !excluded.has(session.source)); + const window = page.slice(pageStart, pageStart + requestedLimit); + const reachedReportedTotal = typeof envelope.total === 'number' && pageStart + window.length >= envelope.total; + const hasMore = window.length > 0 + && !reachedReportedTotal + && rawPage.length >= Math.min(100, pageStart + requestedLimit); + return { + sessions: window, + hasMore, + nextCursor: hasMore + ? makeSessionCursor(pageStart + Math.max(window.length, 1), window.at(-1)?.id, window[0]?.id, 'window') + : undefined, + }; + } + const page = rawPage; + let consumed = 0; + let sawUnseen = false; + for (const session of page) { + consumed += 1; + if (seen.has(session.id)) continue; + seen.add(session.id); + sawUnseen = true; + if ((!included || included.has(session.source)) && !excluded.has(session.source)) { + sessions.push(session); + } + if (sessions.length >= requestedLimit) break; + } + + const reachedReportedTotal = typeof envelope.total === 'number' && pageStart + page.length >= envelope.total; + const pageHasMore = page.length >= pageSize && !reachedReportedTotal; + if (sessions.length >= requestedLimit || !pageHasMore || !sawUnseen) { + return { + sessions, + hasMore: pageHasMore && sawUnseen, + continueWhenEmpty: hasSourceFilter && !windowMode, + nextCursor: + pageHasMore && sawUnseen + ? makeSessionCursor( + pageStart + Math.max(consumed, 1), + page[Math.max(consumed, 1) - 1]?.id, + page[0]?.id, + hasSourceFilter && page.every((session) => + (!included || included.has(session.source)) && !excluded.has(session.source)) + ? 'window' + : 'raw', + ) + : undefined, + }; + } + rawOffset = pageStart + page.length; + } + + return { + sessions, + hasMore: false, + warning: 'Older Hermes cannot safely paginate this filtered history beyond the scanned limit.', + }; +} + export async function listSessions( connection: AgentConnection, limit = 100, @@ -1115,37 +1257,136 @@ export async function listJobs(connection: AgentConnection, profile?: string) { } } -export async function listJobRuns(connection: AgentConnection, jobId: string, limit = 20, profile?: string) { +type HermesJobRunsEnvelope = { + runs?: HermesSession[]; + sessions?: HermesSession[]; + data?: HermesSession[]; + limit?: number; + total?: number; + has_more?: boolean; + next_cursor?: string | null; +}; + +type HermesJobRunsResponse = HermesJobRunsEnvelope | HermesSession[]; + +function normalizeJobRuns(response: HermesJobRunsResponse): HermesJobRunsEnvelope & { runs: HermesSession[] } { + if (Array.isArray(response)) return { runs: response }; + return { + ...response, + runs: response.runs ?? response.sessions ?? response.data ?? [], + }; +} + +const JOB_RUN_FALLBACK_SCAN_LIMIT = 100; + +export async function listJobRunsPage( + connection: AgentConnection, + jobId: string, + limit = 5, + profile?: string, + cursor?: string, +): Promise { const boundedLimit = Math.max(1, Math.min(limit, 100)); - const query = new URLSearchParams({ limit: String(boundedLimit) }); + const cursorState = parseSessionCursor(cursor); + const windowMode = Boolean(cursor && cursorState.strategy === 'window'); + const requestLimit = windowMode ? Math.min(100, cursorState.offset + boundedLimit) : boundedLimit; + const query = new URLSearchParams({ limit: String(requestLimit) }); query.set('profile', profileNameForAutomation(profile)); + if (cursorState.offset && !windowMode) query.set('offset', String(cursorState.offset)); try { - return await brioFetch<{ runs: HermesSession[]; limit?: number }>( + const result = normalizeJobRuns(await brioFetch( connection, `${scopedPath(`/api/cron/jobs/${encodeURIComponent(jobId)}/runs`, profile)}?${query.toString()}`, - ); + )); + const allRuns = result.runs ?? result.data ?? []; + if (!windowMode && ( + (cursorState.firstId && allRuns[0]?.id === cursorState.firstId) + || (cursorState.lastId && allRuns[0]?.id === cursorState.lastId) + )) { + return { sessions: [], hasMore: false }; + } + const runs = windowMode ? allRuns.slice(cursorState.offset, cursorState.offset + boundedLimit) : allRuns; + const hasMore = runs.length > 0 && (typeof result.has_more === 'boolean' + ? result.has_more + : typeof result.total === 'number' + ? cursorState.offset + runs.length < result.total + : allRuns.length >= requestLimit); + return { + sessions: runs, + hasMore, + nextCursor: hasMore + ? makeSessionCursor( + cursorState.offset + runs.length, + runs.at(-1)?.id, + runs[0]?.id, + 'window', + ) + : undefined, + }; } catch (error) { if (!isUnsupportedAutomationRoute(error)) throw error; try { - return await brioFetch<{ runs: HermesSession[]; limit?: number }>( + const result = normalizeJobRuns(await brioFetch( connection, `${scopedPath(`/jobs/${encodeURIComponent(jobId)}/runs`, profile)}?${query.toString()}`, - ); + )); + const allRuns = result.runs ?? result.data ?? []; + if (!windowMode && ( + (cursorState.firstId && allRuns[0]?.id === cursorState.firstId) + || (cursorState.lastId && allRuns[0]?.id === cursorState.lastId) + )) { + return { sessions: [], hasMore: false }; + } + const runs = windowMode ? allRuns.slice(cursorState.offset, cursorState.offset + boundedLimit) : allRuns; + const hasMore = runs.length > 0 && (typeof result.has_more === 'boolean' + ? result.has_more + : typeof result.total === 'number' + ? cursorState.offset + runs.length < result.total + : allRuns.length >= requestLimit); + return { + sessions: runs, + hasMore, + nextCursor: hasMore + ? makeSessionCursor( + cursorState.offset + Math.max(runs.length, 1), + runs.at(-1)?.id, + runs[0]?.id, + 'window', + ) + : undefined, + }; } catch (legacyError) { if (!isUnsupportedAutomationRoute(legacyError)) throw legacyError; - // The legacy gateway may ignore source filters. Scan a full recent page - // so an older job is not mistaken for a job with no runs; the caller's - // boundedLimit still caps the message-history fan-out. - const sessions = await listSessions(connection, 100, profile, { source: 'cron', order: 'recent' }); + // Older gateways expose cron runs only through the shared session list. + // Keep the raw-session cursor so a busy history cannot hide this job's + // recent runs. The source page is deliberately larger than the UI page: + // filtering after five rows could return an empty page even when the job + // has a run immediately behind other jobs. Return every matching run in + // this scan chunk so the cursor never skips matches that were fetched. + const page = await listAutomationSessionsPage( + connection, + Math.max(boundedLimit, JOB_RUN_FALLBACK_SCAN_LIMIT), + profile, + { source: 'cron', order: 'recent' }, + cursor, + ); const prefix = `cron_${jobId}_`; return { - runs: sessions.sessions.filter((session) => session.id.startsWith(prefix)).slice(0, boundedLimit), - limit: boundedLimit, + sessions: page.sessions.filter((session) => session.id.startsWith(prefix)), + hasMore: page.hasMore, + nextCursor: page.nextCursor, + continueWhenEmpty: true, + warning: page.warning, }; } } } +export async function listJobRuns(connection: AgentConnection, jobId: string, limit = 20, profile?: string) { + const page = await listJobRunsPage(connection, jobId, limit, profile); + return { runs: page.sessions, limit: Math.max(1, Math.min(limit, 100)) }; +} + export async function runJobAction( connection: AgentConnection, jobId: string,