From 01f65352d86e4dde02e1a33603bffe8a6e92adea Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:49:45 -0500 Subject: [PATCH] fix(automations): run lists send byte-bounded previews; Run Details reads one full row (#38218) * fix(automations): run lists send byte-bounded previews; Run Details reads one full row * fix(automations): retry a failed full run read on the next history read * fix(automations): show a full-output read failure with Retry in Run Details --- .../AutomationManager.run-history.spec.tsx | 63 ++++++++- .../automations/AutomationManager.test.tsx | 5 +- .../src/automations/AutomationRunDetails.tsx | 21 ++- .../src/automations/automation-operations.ts | 10 ++ mobile/src/automations/use-automation-runs.ts | 5 +- .../automations/use-full-automation-run.ts | 73 ++++++++++ mobile/src/i18n/locales/de.json | 2 + mobile/src/i18n/locales/en.json | 2 + mobile/src/i18n/locales/es.json | 2 + mobile/src/i18n/locales/fr.json | 2 + mobile/src/i18n/locales/ja.json | 2 + mobile/src/i18n/locales/ko.json | 2 + mobile/src/i18n/locales/pt.json | 2 + mobile/src/i18n/locales/ru.json | 2 + mobile/src/i18n/locales/tr.json | 2 + mobile/src/i18n/locales/zh-Hant.json | 2 + mobile/src/i18n/locales/zh.json | 2 + .../automation-runs-preview-page.ts | 45 ++++++ .../automation-runs-preview.test.ts | 130 ++++++++++++++++++ .../rpc/methods/automations/automations.ts | 23 +++- .../automation-api-operations.ts | 14 ++ .../automations/AutomationRunDetailsPage.tsx | 20 ++- ...AutomationsPage.refresh-selection.spec.tsx | 9 +- .../automations/AutomationsPageSurface.tsx | 1 + .../automation-scoped-list-client.ts | 61 +++++--- .../automations/use-full-automation-run.ts | 81 +++++++++++ src/renderer/src/i18n/locales/de.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/pt.json | 2 + src/renderer/src/i18n/locales/ru.json | 2 + src/renderer/src/i18n/locales/tr.json | 2 + src/renderer/src/i18n/locales/zh-Hant.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/automation-run-cursor.ts | 7 +- src/shared/rpc-contract/automation-params.ts | 6 +- 39 files changed, 587 insertions(+), 31 deletions(-) create mode 100644 mobile/src/automations/use-full-automation-run.ts create mode 100644 src/main/runtime/rpc/methods/automations/automation-runs-preview-page.ts create mode 100644 src/main/runtime/rpc/methods/automations/automation-runs-preview.test.ts create mode 100644 src/renderer/src/components/automations/use-full-automation-run.ts diff --git a/mobile/src/automations/AutomationManager.run-history.spec.tsx b/mobile/src/automations/AutomationManager.run-history.spec.tsx index f45fa085400..eb5726c26e5 100644 --- a/mobile/src/automations/AutomationManager.run-history.spec.tsx +++ b/mobile/src/automations/AutomationManager.run-history.spec.tsx @@ -1,18 +1,22 @@ -import { act, type ReactTestInstance } from 'react-test-renderer' -import { expect, it } from 'vitest' +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' import { AutomationEditor } from './AutomationEditor' import { AutomationRunList } from './AutomationRunList' +import { AutomationRunDetails } from './AutomationRunDetails' import { MobileSegmentedControl } from '../components/MobileSegmentedControl' -import type { automationClient } from './automation-test-fixtures' +import { automationClient, savedRun } from './automation-test-fixtures' export function registerAutomationRunHistoryTests({ mount, root, - press + press, + setTree }: { mount: () => Promise> root: () => ReactTestInstance press: (label: string) => Promise + setTree: (tree: ReactTestRenderer) => void }): void { it('observes history only while a history consumer is visible', async () => { const port = await mount() @@ -55,4 +59,55 @@ export function registerAutomationRunHistoryTests({ expect(historyReads()).toHaveLength(2) expect(root().findByType(AutomationRunList).props.history.runs[0]).toEqual(original) }) + + it('retries a failed full-row read when history re-reads the row', async () => { + const preview = { + ...savedRun(), + outputSnapshot: { content: 'START', capturedAt: 1700000000003, truncated: true } + } + let reachable = false + const port = automationClient(() => { + if (!reachable) { + throw new Error('offline') + } + return { + run: { + ...preview, + outputSnapshot: { content: 'START END', capturedAt: 1700000000003, truncated: false } + } + } + }) + const details = (run: typeof preview) => + createElement(AutomationRunDetails, { + run, + client: port.client, + hostId: 'paired', + connected: true, + history: { + runs: [run], + selected: run, + unavailable: false, + cursor: null, + loading: false, + error: null, + loadMore: vi.fn(), + reload: vi.fn(), + scope: null + }, + onBack: vi.fn() + }) + const shows = (content: string) => + root() + .findAll((node) => String(node.type) === 'Text') + .some((node) => JSON.stringify(node.props.children) === JSON.stringify(content)) + const tree = create(details(preview)) + setTree(tree) + await act(async () => {}) + expect(shows('START')).toBe(true) + reachable = true + await act(async () => { + tree.update(details({ ...preview })) + }) + expect(shows('START END')).toBe(true) + }) } diff --git a/mobile/src/automations/AutomationManager.test.tsx b/mobile/src/automations/AutomationManager.test.tsx index ca10af85598..94762327037 100644 --- a/mobile/src/automations/AutomationManager.test.tsx +++ b/mobile/src/automations/AutomationManager.test.tsx @@ -200,7 +200,7 @@ async function mount( } describe('mobile automation navigation and setup', () => { - registerAutomationRunHistoryTests({ mount, root, press }) + registerAutomationRunHistoryTests({ mount, root, press, setTree }) registerAutomationPresentationTests({ inventory, root, press, setTree, mount }) registerAutomationHostReselectionTests({ inventory, @@ -507,7 +507,8 @@ describe('mobile automation navigation and setup', () => { loading: false, error: null, loadMore: vi.fn(), - reload: vi.fn() + reload: vi.fn(), + scope: null } await act(async () => { tree = create( diff --git a/mobile/src/automations/AutomationRunDetails.tsx b/mobile/src/automations/AutomationRunDetails.tsx index 571f23b86ba..4402660fe0f 100644 --- a/mobile/src/automations/AutomationRunDetails.tsx +++ b/mobile/src/automations/AutomationRunDetails.tsx @@ -17,6 +17,7 @@ import { import { canOpenAutomationRunAgent } from './automation-run-agent' import type { MobileAutomationRun } from './automation-reply-schema' import type { AutomationRunObservation } from './use-automation-runs' +import { useFullAutomationRun } from './use-full-automation-run' import { automationStyles as styles } from './automation-styles' export function AutomationRunDetails(props: { @@ -31,7 +32,13 @@ export function AutomationRunDetails(props: { const t = useMobileTranslation() useMobileColorScheme() const history = props.history - const run = history.selected ?? props.run + const full = useFullAutomationRun( + props.client, + props.connected, + history.selected ?? props.run, + history.scope + ) + const run = full.run const runError = automationRunErrorText(run) const [opening, setOpening] = useState(false) const [error, setError] = useState(null) @@ -177,6 +184,18 @@ export function AutomationRunDetails(props: { {runError ? {runError} : null} {t('automations.mobile.output', 'Output')} + {full.failed ? ( + <> + + {t('automations.mobile.fullOutputUnavailable', "Couldn't load the full output.")} + + + + ) : null} {getAutomationRunContent( { ...run, error: runError }, diff --git a/mobile/src/automations/automation-operations.ts b/mobile/src/automations/automation-operations.ts index 34f15ea7192..227e9cdf3a8 100644 --- a/mobile/src/automations/automation-operations.ts +++ b/mobile/src/automations/automation-operations.ts @@ -77,6 +77,16 @@ export const automationRunsRead = bindDeferredRpcOperation( read: rpcResultVariant('runs-page', runsPageSchema) }) ) +// An old host ignores `runId` and lists instead, which reads as no `run`. +export const automationRunRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'automation.mobile-run', + method: 'automation.runs', + acceptance: 'require-result-or-throw', + barrier: 'after-caller-barrier', + read: rpcResultVariant('run', z.object({ run: runSchema.optional() })) + }) +) export const automationProjectsRead = bindDeferredRpcOperation( defineRpcOperation({ name: 'project.mobile-automation-inventory', diff --git a/mobile/src/automations/use-automation-runs.ts b/mobile/src/automations/use-automation-runs.ts index a4919da86de..d7e60e5e43c 100644 --- a/mobile/src/automations/use-automation-runs.ts +++ b/mobile/src/automations/use-automation-runs.ts @@ -88,6 +88,7 @@ export function useAutomationRuns( } const reply = await automationRunsRead.request(client, { limit: 30, + preview: true, ...requestScope, ...(cursor ? { cursor } : {}) }) @@ -184,7 +185,9 @@ export function useAutomationRuns( void load(history.cursor) } }, - reload: () => void load() + reload: () => void load(), + /** The scope these runs were listed under; its owner fences full-row reads. */ + scope: query } } export type AutomationRunObservation = ReturnType diff --git a/mobile/src/automations/use-full-automation-run.ts b/mobile/src/automations/use-full-automation-run.ts new file mode 100644 index 00000000000..8fad4b83d5d --- /dev/null +++ b/mobile/src/automations/use-full-automation-run.ts @@ -0,0 +1,73 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { automationRunRead } from './automation-operations' +import type { MobileAutomationRun } from './automation-reply-schema' +import type { AutomationRunScope } from './use-automation-runs' + +export type FullAutomationRun = { + run: MobileAutomationRun + /** The full read failed; `run` is still the preview. */ + failed: boolean + retry: () => void +} + +/** History lists cut output to a preview marked truncated, so Details reads that run's full row. */ +export function useFullAutomationRun( + client: RpcClient | null, + connected: boolean, + run: MobileAutomationRun, + scope: AutomationRunScope +): FullAutomationRun { + const expectedOwner = scope?.automationId === run.automationId ? scope.expectedOwner : undefined + // A new status or capture re-reads; untruncated rows were never cut. + const key = run.outputSnapshot?.truncated + ? JSON.stringify([run.id, run.status, run.outputSnapshot.capturedAt, expectedOwner ?? null]) + : null + const latest = useRef(expectedOwner) + useLayoutEffect(() => { + latest.current = expectedOwner + }) + const [full, setFull] = useState<{ key: string; run: MobileAutomationRun } | null>(null) + const [failedKey, setFailedKey] = useState(null) + const [attempt, setAttempt] = useState(0) + const loaded = full?.key === key + // A failed read keeps the preview, marked truncated; Retry, a reconnect or the next history read of the row retries. + useEffect(() => { + if (!key || loaded || !client || !connected) { + return + } + const expectedOwner = latest.current + const generation = client.getGeneration?.() + let current = true + automationRunRead + .request(client, { + automationId: run.automationId, + runId: run.id, + ...(expectedOwner ? { expectedOwner } : {}), + // An old host ignores `runId` and lists; one row keeps that answer small. + limit: 1 + }) + .then((reply) => { + const read = automationRunRead.interpret(reply).run + if (current && generation === client.getGeneration?.() && read?.id === run.id) { + setFull({ key, run: read }) + } + }) + .catch(() => { + if (current && generation === client.getGeneration?.()) { + setFailedKey(key) + } + }) + return () => { + current = false + } + }, [client, connected, key, loaded, run, attempt]) + return { + run: loaded ? full.run : run, + failed: !loaded && key !== null && failedKey === key, + retry: () => { + setFailedKey(null) + setAttempt((value) => value + 1) + } + } +} diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index bbf7fb820a2..30141c9c12d 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -3977,6 +3977,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Das ursprüngliche Agentenfenster ist nicht verfügbar. Gespeicherte Ausgaben bleiben hier.", "openAgent": "Agenten öffnen", "retryHistory": "Verlauf erneut laden", + "fullOutputUnavailable": "Die vollständige Ausgabe konnte nicht geladen werden.", + "retryFullOutput": "Erneut versuchen", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "Keine passenden Ausführungen im geladenen Verlauf. Lade weitere, um fortzufahren.", "noMatchingRunsYet": "Noch keine passenden Ausführungen.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 0a3e5230ac2..809ce59ea25 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -4572,6 +4572,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "The original agent pane is unavailable. Saved output remains here.", "openAgent": "Open agent", "retryHistory": "Retry history", + "fullOutputUnavailable": "Couldn't load the full output.", + "retryFullOutput": "Retry", "loadingRuns": "Loading runs…", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "No matching runs in the loaded history. Load more to continue.", "noMatchingRunsYet": "No matching runs yet.", diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index fad62145526..14174fe1dd5 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -4084,6 +4084,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "El panel original del agente no está disponible. La salida guardada permanece aquí.", "openAgent": "Abrir agente", "retryHistory": "Reintentar historial", + "fullOutputUnavailable": "No se pudo cargar la salida completa.", + "retryFullOutput": "Reintentar", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "No hay ejecuciones coincidentes en el historial cargado. Carga más para continuar.", "noMatchingRunsYet": "Aún no hay ejecuciones coincidentes.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index a5a4116c898..6744ef63587 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -4084,6 +4084,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Le panneau d’origine de l’agent est indisponible. La sortie enregistrée reste ici.", "openAgent": "Ouvrir l’agent", "retryHistory": "Réessayer l’historique", + "fullOutputUnavailable": "Impossible de charger la sortie complète.", + "retryFullOutput": "Réessayer", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "Aucune exécution correspondante dans l’historique chargé. Chargez-en davantage pour continuer.", "noMatchingRunsYet": "Aucune exécution correspondante pour le moment.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index cc6fbf9a55b..9ff1798eeaf 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -3982,6 +3982,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "元のAgentペインは利用できません。保存済みの出力はここに残ります。", "openAgent": "Agentを開く", "retryHistory": "履歴を再読み込み", + "fullOutputUnavailable": "完全な出力を読み込めませんでした。", + "retryFullOutput": "再試行", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "読み込んだ履歴に一致する実行はありません。さらに読み込んでください。", "noMatchingRunsYet": "一致する実行はまだありません。", "runSummary": "{{title}}、{{status}}、{{date}}", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 3f71803143b..91c168c70d3 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -3982,6 +3982,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "원래 에이전트 창을 사용할 수 없습니다. 저장된 출력은 여기에 유지됩니다.", "openAgent": "에이전트 열기", "retryHistory": "기록 다시 불러오기", + "fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.", + "retryFullOutput": "다시 시도", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "불러온 기록에 일치하는 실행이 없습니다. 계속하려면 더 불러오세요.", "noMatchingRunsYet": "아직 일치하는 실행이 없습니다.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index 2550d2a52d3..74363b19511 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -4075,6 +4075,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "O painel original do agente está indisponível. A saída salva permanece aqui.", "openAgent": "Abrir agente", "retryHistory": "Tentar histórico novamente", + "fullOutputUnavailable": "Não foi possível carregar a saída completa.", + "retryFullOutput": "Tentar novamente", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "Nenhuma execução correspondente no histórico carregado. Carregue mais para continuar.", "noMatchingRunsYet": "Ainda não há execuções correspondentes.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index 71794e16172..faba883f246 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -4174,6 +4174,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Исходная панель агента недоступна. Сохранённый вывод остаётся здесь.", "openAgent": "Открыть агента", "retryHistory": "Повторить загрузку истории", + "fullOutputUnavailable": "Не удалось загрузить полный вывод.", + "retryFullOutput": "Повторить", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "В загруженной истории нет подходящих запусков. Загрузите ещё, чтобы продолжить.", "noMatchingRunsYet": "Подходящих запусков пока нет.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 4383a4366f3..d6c3f2665e2 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -3978,6 +3978,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Özgün aracı bölmesi kullanılamıyor. Kaydedilen çıktı burada kalır.", "openAgent": "Aracıyı aç", "retryHistory": "Geçmişi yeniden yükle", + "fullOutputUnavailable": "Çıktının tamamı yüklenemedi.", + "retryFullOutput": "Yeniden dene", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "Yüklenen geçmişte eşleşen çalıştırma yok. Devam etmek için daha fazlasını yükleyin.", "noMatchingRunsYet": "Henüz eşleşen çalıştırma yok.", "runSummary": "{{title}}, {{status}}, {{date}}", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 1ecc7416281..c86a26131ba 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -3974,6 +3974,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "原始代理程式窗格無法使用。已儲存的輸出仍保留在此處。", "openAgent": "開啟代理程式", "retryHistory": "重試歷程記錄", + "fullOutputUnavailable": "無法載入完整輸出。", + "retryFullOutput": "重試", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "已載入的歷程記錄中沒有符合的執行。請載入更多以繼續。", "noMatchingRunsYet": "尚無符合的執行。", "runSummary": "{{title}},{{status}},{{date}}", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index a56db21f453..598adaecdfa 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -3982,6 +3982,8 @@ "theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "原始代理窗格不可用。已保存的输出仍保留在此处。", "openAgent": "打开代理", "retryHistory": "重试历史记录", + "fullOutputUnavailable": "无法加载完整输出。", + "retryFullOutput": "重试", "noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "已加载的历史记录中没有匹配的运行。请加载更多以继续。", "noMatchingRunsYet": "暂无匹配的运行。", "runSummary": "{{title}},{{status}},{{date}}", diff --git a/src/main/runtime/rpc/methods/automations/automation-runs-preview-page.ts b/src/main/runtime/rpc/methods/automations/automation-runs-preview-page.ts new file mode 100644 index 00000000000..3c94d901892 --- /dev/null +++ b/src/main/runtime/rpc/methods/automations/automation-runs-preview-page.ts @@ -0,0 +1,45 @@ +import type { AutomationRun, AutomationRunsPage } from '../../../../../shared/automations-types' +import { automationRunCursor } from '../../../../../shared/automation-run-cursor' +import { sliceAtCodeUnitLimit } from '../../../../../shared/surrogate-safe-text-slice' + +export const AUTOMATION_RUN_OUTPUT_PREVIEW_CHARS = 8_000 + +function previewAutomationRun(run: AutomationRun): AutomationRun { + const snapshot = run.outputSnapshot + if (!snapshot || snapshot.content.length <= AUTOMATION_RUN_OUTPUT_PREVIEW_CHARS) { + return run + } + return { + ...run, + outputSnapshot: { + ...snapshot, + content: sliceAtCodeUnitLimit(snapshot.content, AUTOMATION_RUN_OUTPUT_PREVIEW_CHARS), + truncated: true + } + } +} + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8') +} + +/** Preview rows, keeping those whose exact `{ runs, nextCursor }` reply fits `maxBytes`; the cursor resumes at the cut. */ +export function previewAutomationRunsPage( + page: AutomationRunsPage, + maxBytes: number +): AutomationRunsPage { + const runs = page.runs.map(previewAutomationRun) + let rowBytes = 0 + for (let count = 0; count < runs.length; count += 1) { + rowBytes += jsonBytes(runs[count]) + (count > 0 ? 1 : 0) + const nextCursor = count + 1 < runs.length ? automationRunCursor(runs[count]) : page.nextCursor + if (rowBytes + jsonBytes({ runs: [], nextCursor }) <= maxBytes) { + continue + } + if (count === 0) { + throw new Error('This automation run is too large to list over this connection.') + } + return { runs: runs.slice(0, count), nextCursor: automationRunCursor(runs[count - 1]) } + } + return { runs, nextCursor: page.nextCursor } +} diff --git a/src/main/runtime/rpc/methods/automations/automation-runs-preview.test.ts b/src/main/runtime/rpc/methods/automations/automation-runs-preview.test.ts new file mode 100644 index 00000000000..bd1f11dd187 --- /dev/null +++ b/src/main/runtime/rpc/methods/automations/automation-runs-preview.test.ts @@ -0,0 +1,130 @@ +import '../../unused-default-rpc-methods.test-fixture' +import { describe, expect, it, vi } from 'vitest' +import { eraseRpcMethods, type RpcContext } from '../../core' +import { OrcaRuntimeService } from '../../../orca-runtime' +import { AUTOMATION_METHODS } from './automations' +import { remoteRpcContentBudget } from '../../../../../shared/remote-rpc-content-budget' +import type { AutomationRun } from '../../../../../shared/automations-types' + +const REQUEST_ID = 'request-1' +// Each escaped control character costs six reply bytes; the emoji is a surrogate pair. +const OUTPUT = `${'\u0001'.repeat(7_999)}😀${'é'.repeat(12_000)}` + +function run(index: number, title = 'Nightly run'): AutomationRun { + return { + id: `run-${String(index).padStart(3, '0')}`, + automationId: 'a1', + title, + scheduledFor: 1_000 - index, + status: 'completed', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: { format: 'plain_text', content: OUTPUT, capturedAt: 5, truncated: false }, + precheckResult: { + command: 'check', + exitCode: 1, + timedOut: false, + durationMs: 1, + stdout: '\u0002'.repeat(4_000), + stderr: '\u0003'.repeat(4_000), + stdoutTruncated: true, + stderrTruncated: true, + error: null, + startedAt: 1, + completedAt: 2 + }, + usage: null, + error: '\u0004'.repeat(4_000), + startedAt: null, + dispatchedAt: null, + createdAt: 1_000 - index + } +} + +function runtimeWith(runs: AutomationRun[]) { + const store = { + listAutomationRuns: vi.fn(() => runs), + assertAutomationOwnerFence: vi.fn( + ({ expectedOwner }: { expectedOwner?: { selector: { kind: string } } }) => { + if (expectedOwner && expectedOwner.selector.kind !== 'self') { + throw new Error('automation_owner_changed') + } + } + ) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: run reads touch only these two store members. + return new OrcaRuntimeService(store as never) +} + +async function runs(runtime: OrcaRuntimeService, params: Record) { + const method = eraseRpcMethods(AUTOMATION_METHODS).find( + (entry) => entry.name === 'automation.runs' + ) + const parsed = method!.params!.parse(params) + const context: RpcContext = { runtime, requestId: REQUEST_ID } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: erased handlers return unknown; these are automation.runs' reply shapes. + return (await method!.handler(parsed, context)) as { + runs?: AutomationRun[] + nextCursor?: string | null + run?: AutomationRun + } +} + +describe('automation.runs previews', () => { + it('keeps every page inside the reply budget and resumes at each cut', async () => { + const all = Array.from({ length: 100 }, (_, index) => run(index)) + const runtime = runtimeWith(all) + const seen: string[] = [] + let cursor: string | undefined + let pages = 0 + do { + const page = await runs(runtime, { automationId: 'a1', preview: true, cursor }) + pages += 1 + expect(Buffer.byteLength(JSON.stringify(page), 'utf8')).toBeLessThanOrEqual( + remoteRpcContentBudget(REQUEST_ID) + ) + const last = page.runs!.at(-1)! + expect(page.nextCursor ?? null).toBe( + last.id === 'run-099' ? null : `${last.createdAt}:${last.id}` + ) + expect(page.runs![0].outputSnapshot).toMatchObject({ + content: '\u0001'.repeat(7_999), + truncated: true + }) + seen.push(...page.runs!.map((entry) => entry.id)) + cursor = page.nextCursor ?? undefined + } while (cursor) + expect(pages).toBeGreaterThan(1) + expect(seen).toEqual(all.map((entry) => entry.id)) + }) + + it('refuses a row that cannot fit one reply instead of sending it', async () => { + const runtime = runtimeWith([run(0, 'x'.repeat(5 * 1024 * 1024))]) + await expect(runs(runtime, { automationId: 'a1', preview: true })).rejects.toThrow( + 'too large to list' + ) + }) + + it('reads one full row through the owner fence', async () => { + const runtime = runtimeWith([run(0), run(1)]) + const { run: full } = await runs(runtime, { + automationId: 'a1', + runId: 'run-001', + expectedOwner: { selector: { kind: 'self' } } + }) + expect(full?.id).toBe('run-001') + expect(full?.outputSnapshot?.content).toBe(OUTPUT) + await expect( + runs(runtime, { + automationId: 'a1', + runId: 'run-001', + expectedOwner: { selector: { kind: 'orphan' } } + }) + ).rejects.toThrow('automation_owner_changed') + }) +}) diff --git a/src/main/runtime/rpc/methods/automations/automations.ts b/src/main/runtime/rpc/methods/automations/automations.ts index 222f9c09b93..501080cb641 100644 --- a/src/main/runtime/rpc/methods/automations/automations.ts +++ b/src/main/runtime/rpc/methods/automations/automations.ts @@ -1,7 +1,9 @@ import '../../../../../shared/protocol-version' import { AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY } from '../../../../../shared/automation-runtime-capabilities' import type { AutomationOwnerPrecondition } from '../../../../../shared/automation-owner-precondition' +import { remoteRpcContentBudget } from '../../../../../shared/remote-rpc-content-budget' import { defineMethod, type RpcContext } from '../../core' +import { previewAutomationRunsPage } from './automation-runs-preview-page' import { AutomationCreate, AutomationId, @@ -90,7 +92,26 @@ export const AUTOMATION_METHODS = [ name: 'automation.runs', permission: 'workspace', params: AutomationRuns, - handler: (params, { runtime }) => { + handler: (params, { runtime, requestId }) => { + if (params.runId !== undefined) { + if (!params.automationId) { + throw new Error('A run id requires an automation id.') + } + return { + run: runtime.showAutomationRun(params.automationId, params.runId, params.expectedOwner) + } + } + if (params.preview) { + return previewAutomationRunsPage( + runtime.listAutomationRunsPage( + params.automationId, + params.expectedOwner, + params.limit, + params.cursor + ), + remoteRpcContentBudget(requestId ?? '') + ) + } if (params.limit !== undefined || params.cursor !== undefined) { return runtime.listAutomationRunsPage( params.automationId, diff --git a/src/main/runtime/workspaces/persisted-surfaces/automation-api-operations.ts b/src/main/runtime/workspaces/persisted-surfaces/automation-api-operations.ts index e07622edc41..2d314e1f6f7 100644 --- a/src/main/runtime/workspaces/persisted-surfaces/automation-api-operations.ts +++ b/src/main/runtime/workspaces/persisted-surfaces/automation-api-operations.ts @@ -56,6 +56,20 @@ export abstract class OrcaRuntimeWithAutomationApiOperations extends OrcaRuntime ) } + showAutomationRun( + automationId: string, + runId: string, + expectedOwner?: AutomationOwnerPrecondition + ): AutomationRun { + const run = this.readAutomationRuns(automationId, expectedOwner, () => + this.automation.listRuns(automationId).find((entry) => entry.id === runId) + ) + if (!run) { + throw new Error('Automation run not found.') + } + return run + } + showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation { const automation = this.automation.show(id) this.automation.assertOwner(id, expectedOwner, 'read') diff --git a/src/renderer/src/components/automations/AutomationRunDetailsPage.tsx b/src/renderer/src/components/automations/AutomationRunDetailsPage.tsx index 11118bdb193..bac34d3ade0 100644 --- a/src/renderer/src/components/automations/AutomationRunDetailsPage.tsx +++ b/src/renderer/src/components/automations/AutomationRunDetailsPage.tsx @@ -10,6 +10,8 @@ import { getAutomationRunContent } from './automation-run-content' import { automationRunErrorText } from './automation-run-error-copy' import type { AutomationRunViewState } from './automation-run-view-state' import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' +import { useFullAutomationRun } from './use-full-automation-run' +import type { SelectedAutomationRunHistoryOutcome } from './use-selected-automation-run-history' import { AutomationRunErrorNotice, formatAutomationDateTimeWithRelative, @@ -19,7 +21,8 @@ import { export function AutomationRunDetailsPage({ automation, - run, + run: listedRun, + history, relativeNow, workspaceDisplay, viewState, @@ -31,6 +34,8 @@ export function AutomationRunDetailsPage({ }: { automation: Automation | null run: AutomationRun + /** The read that listed `run`; Details re-reads its full row through the same owner. */ + history: SelectedAutomationRunHistoryOutcome relativeNow: number workspaceDisplay: AutomationRunWorkspaceDisplay | null viewState: AutomationRunViewState | null @@ -40,6 +45,8 @@ export function AutomationRunDetailsPage({ onOpenWorkspace: () => void onBack: () => void }): React.JSX.Element { + const full = useFullAutomationRun(listedRun, history) + const run = full.run const content = getAutomationRunContent(run) const errorText = automationRunErrorText(run) return ( @@ -95,6 +102,17 @@ export function AutomationRunDetailsPage({ error={errorText} /> ) : null} + {full.failed ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.fullOutputUnavailable', + "Couldn't load the full output." + )} + +
+ ) : null} { ? { kind: 'environment', environmentId: 'gpu' } : { kind: 'local' } ) - expect(params).toEqual({ automationId: automation.id, expectedOwner: SELF_PRECONDITION }) + expect(params).toEqual({ + automationId: automation.id, + expectedOwner: SELF_PRECONDITION, + preview: true + }) if (authority === 'runtime') { expect(options).toMatchObject({ expectedEnvironmentPairingRevision: 4 }) } @@ -317,7 +321,8 @@ describe('AutomationsPage selection', () => { // Fence history reads too so moved rows refuse rather than answer from a former host. expect(api.automations.listRuns).toHaveBeenCalledWith({ automationId: 'a-2', - expectedOwner: SELF_PRECONDITION + expectedOwner: SELF_PRECONDITION, + preview: true }) expect(mocks.detailPane?.selected?.id).toBe('a-2') }) diff --git a/src/renderer/src/components/automations/AutomationsPageSurface.tsx b/src/renderer/src/components/automations/AutomationsPageSurface.tsx index 84e1c9a9ae0..3fc4cb615e0 100644 --- a/src/renderer/src/components/automations/AutomationsPageSurface.tsx +++ b/src/renderer/src/components/automations/AutomationsPageSurface.tsx @@ -234,6 +234,7 @@ export function AutomationsPageSurface({ { + const result = await callAuthority( + authority, + 'automation.runs', + { automationId, expectedOwner, preview: true, ...options } + ) + return { runs: result.runs, nextCursor: 'nextCursor' in result ? result.nextCursor : null } +} + +/** Every run as previews; old hosts ignore `preview` and answer once with full rows and no cursor. */ +async function listRunsFenced( + authority: AutomationAuthorityRef, + automationId: string, + expectedOwner: AutomationOwnerPrecondition ): Promise { - const result = await callAuthority<{ runs: AutomationRun[] }>(authority, 'automation.runs', { - automationId, + const runs: AutomationRun[] = [] + let cursor: string | undefined + do { + const page = await listRunsPageFenced( + authority, + automationId, + expectedOwner, + cursor ? { cursor } : {} + ) + runs.push(...page.runs) + cursor = page.nextCursor ?? undefined + } while (cursor) + return runs +} + +/** One full run for Details; null when an old host ignored `runId`, whose list rows were never cut. */ +export async function readFullAutomationRun( + authority: AutomationAuthorityRef, + expectedOwner: AutomationOwnerPrecondition, + run: Pick +): Promise { + const result = await callAuthority<{ run?: AutomationRun }>(authority, 'automation.runs', { + automationId: run.automationId, expectedOwner, - ...options + runId: run.id, + // An old host ignores `runId` and lists; one row keeps that answer small. + limit: 1 }) - return result.runs + return result.run?.id === run.id ? result.run : null } export async function listAutomationRunsForOwner( @@ -181,12 +218,7 @@ export async function listAutomationRunsPageForOwner( automationId: string, options: { limit?: number; cursor?: string } = {} ): Promise { - const result = await callAuthority( - owner.authority, - 'automation.runs', - { automationId, expectedOwner: ownerPrecondition(owner), ...options } - ) - return { runs: result.runs, nextCursor: 'nextCursor' in result ? result.nextCursor : null } + return await listRunsPageFenced(owner.authority, automationId, ownerPrecondition(owner), options) } /** Share owned/orphan mutation transport and capability probing; only the fence precondition differs. */ @@ -263,12 +295,7 @@ export async function listOrphanAutomationRunsPage( automationId: string, options: { limit?: number; cursor?: string } = {} ): Promise { - const result = await callAuthority( - authority, - 'automation.runs', - { automationId, expectedOwner: ORPHAN_OWNER_PRECONDITION, ...options } - ) - return { runs: result.runs, nextCursor: 'nextCursor' in result ? result.nextCursor : null } + return await listRunsPageFenced(authority, automationId, ORPHAN_OWNER_PRECONDITION, options) } export async function runAutomationNowForOwner( diff --git a/src/renderer/src/components/automations/use-full-automation-run.ts b/src/renderer/src/components/automations/use-full-automation-run.ts new file mode 100644 index 00000000000..226e5ffa1df --- /dev/null +++ b/src/renderer/src/components/automations/use-full-automation-run.ts @@ -0,0 +1,81 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AutomationRun } from '../../../../shared/automations-types' +import { automationActionAvailability } from './automation-captured-owner' +import { readFullAutomationRun } from './automation-scoped-list-client' +import type { SelectedAutomationRunHistoryOutcome } from './use-selected-automation-run-history' + +export type FullAutomationRun = { + run: AutomationRun + /** The full read failed; `run` is still the preview. */ + failed: boolean + retry: () => void +} + +/** Fenced history lists previews, so Details reads the full row through the same owner; legacy rows were never cut. */ +export function useFullAutomationRun( + run: AutomationRun, + history: SelectedAutomationRunHistoryOutcome +): FullAutomationRun { + const captured = history.capturedOwner + const availability = captured ? automationActionAvailability(captured, 'history') : null + const authority = + availability?.kind === 'owned' + ? availability.owner.authority + : availability?.kind === 'orphan-fenced' + ? (captured?.authority ?? history.authority) + : null + const expectedOwner = + availability?.kind === 'owned' || availability?.kind === 'orphan-fenced' + ? availability.precondition + : null + // Only cut previews re-read; a new status or capture re-reads, and another owner never answers. + const key = + authority && expectedOwner && run.outputSnapshot?.truncated + ? JSON.stringify([ + authority, + expectedOwner, + run.id, + run.status, + run.outputSnapshot?.capturedAt + ]) + : null + const latest = useRef({ authority, expectedOwner }) + useLayoutEffect(() => { + latest.current = { authority, expectedOwner } + }) + const [full, setFull] = useState<{ key: string; run: AutomationRun } | null>(null) + const [failedKey, setFailedKey] = useState(null) + const [attempt, setAttempt] = useState(0) + const loaded = full?.key === key + // A failed read keeps the preview, marked truncated; Retry or the next history read of the row retries. + useEffect(() => { + const { authority, expectedOwner } = latest.current + if (!key || loaded || !authority || !expectedOwner) { + return + } + let current = true + readFullAutomationRun(authority, expectedOwner, run).then( + (row) => { + if (current && row) { + setFull({ key, run: row }) + } + }, + () => { + if (current) { + setFailedKey(key) + } + } + ) + return () => { + current = false + } + }, [key, loaded, run, attempt]) + return { + run: loaded ? full.run : run, + failed: !loaded && key !== null && failedKey === key, + retry: () => { + setFailedKey(null) + setAttempt((value) => value + 1) + } + } +} diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c187f3101ac..92723c6a721 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -17629,6 +17629,8 @@ "noRunUsageYet": "Noch keine Nutzungsdaten zu Ausführungen", "noWorkspace": "Kein Arbeitsbereich", "latestSavedOutput": "Zuletzt gespeicherte Ausgabe", + "fullOutputUnavailable": "Die vollständige Ausgabe konnte nicht geladen werden.", + "retryFullOutput": "Erneut versuchen", "backToList": "Alle Automatisierungen", "tableName": "Name", "tableProject": "Projekt", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 159fe8719c0..8b2bd3af38c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18042,6 +18042,8 @@ "noRunUsageYet": "No run usage yet", "noWorkspace": "No workspace", "latestSavedOutput": "Latest saved output", + "fullOutputUnavailable": "Couldn't load the full output.", + "retryFullOutput": "Retry", "backToList": "All automations", "tableName": "Name", "tableProject": "Project", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1be35ca2907..7191cc5ec92 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17391,6 +17391,8 @@ "noRunUsageYet": "Aún no hay uso de ejecución", "noWorkspace": "Sin espacio de trabajo", "latestSavedOutput": "Última salida guardada", + "fullOutputUnavailable": "No se pudo cargar la salida completa.", + "retryFullOutput": "Reintentar", "backToList": "Todas las automatizaciones", "tableName": "Nombre", "tableProject": "Proyecto", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 513132d3c4e..3311da44785 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17585,6 +17585,8 @@ "noRunUsageYet": "Aucune utilisation d'exécution pour l'instant", "noWorkspace": "Aucun espace de travail", "latestSavedOutput": "Dernière sortie enregistrée", + "fullOutputUnavailable": "Impossible de charger la sortie complète.", + "retryFullOutput": "Réessayer", "backToList": "Toutes les automatisations", "tableName": "Nom", "tableProject": "Projet", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 89344d3b353..651234b1f85 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -16975,6 +16975,8 @@ "noRunUsageYet": "まだ実行の使用量がありません", "noWorkspace": "ワークスペースなし", "latestSavedOutput": "最新の保存済み出力", + "fullOutputUnavailable": "完全な出力を読み込めませんでした。", + "retryFullOutput": "再試行", "backToList": "すべての自動化", "tableName": "名前", "tableProject": "プロジェクト", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d0f42ddb3f6..6f605e39dda 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -16975,6 +16975,8 @@ "noRunUsageYet": "아직 실행 사용량 없음", "noWorkspace": "워크스페이스 없음", "latestSavedOutput": "최근 저장된 출력", + "fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.", + "retryFullOutput": "다시 시도", "backToList": "모든 자동화", "tableName": "이름", "tableProject": "프로젝트", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 1af871e0c80..03db577113b 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -14182,6 +14182,8 @@ "noRunUsageYet": "Ainda sem uso de execução", "noWorkspace": "Nenhum espaço de trabalho", "latestSavedOutput": "Última saída salva", + "fullOutputUnavailable": "Não foi possível carregar a saída completa.", + "retryFullOutput": "Tentar novamente", "backToList": "Todas as automações", "tableName": "Nome", "tableProject": "Projeto", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index f0915531412..9db125575df 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -18139,6 +18139,8 @@ "noRunUsageYet": "Данных об использовании запусков пока нет", "noWorkspace": "Нет рабочего пространства", "latestSavedOutput": "Последний сохранённый вывод", + "fullOutputUnavailable": "Не удалось загрузить полный вывод.", + "retryFullOutput": "Повторить", "backToList": "Все автоматизации", "tableName": "Название", "tableProject": "Проект", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index c8075a723df..7d1922dc02e 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -17615,6 +17615,8 @@ "noRunUsageYet": "Henüz çalıştırma kullanımı yok", "noWorkspace": "Çalışma alanı yok", "latestSavedOutput": "En son kaydedilen çıktı", + "fullOutputUnavailable": "Çıktının tamamı yüklenemedi.", + "retryFullOutput": "Yeniden dene", "backToList": "Tüm otomasyonlar", "tableName": "Ad", "tableProject": "Proje", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 31f9e8c7bcc..3c23f28c76c 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -17398,6 +17398,8 @@ "noRunUsageYet": "尚無執行用量", "noWorkspace": "無工作區", "latestSavedOutput": "最近儲存的輸出", + "fullOutputUnavailable": "無法載入完整輸出。", + "retryFullOutput": "重試", "backToList": "全部自動化", "tableName": "名稱", "tableProject": "專案", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7e07fda44e7..6097cb81688 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -16975,6 +16975,8 @@ "noRunUsageYet": "尚无运行用量", "noWorkspace": "无工作区", "latestSavedOutput": "最近保存的输出", + "fullOutputUnavailable": "无法加载完整输出。", + "retryFullOutput": "重试", "backToList": "全部自动化", "tableName": "名称", "tableProject": "项目", diff --git a/src/shared/automation-run-cursor.ts b/src/shared/automation-run-cursor.ts index a2d8f4b834c..b1fe20c042e 100644 --- a/src/shared/automation-run-cursor.ts +++ b/src/shared/automation-run-cursor.ts @@ -21,6 +21,11 @@ function decodeAutomationRunCursor(cursor: string | undefined): AutomationRunCur return Number.isFinite(createdAt) && id ? { kind: 'key', createdAt, id } : null } +/** The keyset cursor that resumes after `run`. */ +export function automationRunCursor(run: Pick): string { + return `${run.createdAt}:${run.id}` +} + /** Order by createdAt then id so pruning a boundary cannot hide tied runs. */ export function compareAutomationRunsNewestFirst( left: Pick, @@ -62,6 +67,6 @@ export function paginateAutomationRuns( const last = page.at(-1) return { runs: page, - nextCursor: last && start + page.length < runs.length ? `${last.createdAt}:${last.id}` : null + nextCursor: last && start + page.length < runs.length ? automationRunCursor(last) : null } } diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts index df3fc8609cb..006afb0a1fd 100644 --- a/src/shared/rpc-contract/automation-params.ts +++ b/src/shared/rpc-contract/automation-params.ts @@ -180,7 +180,11 @@ export const AutomationRuns = z.object({ automationId: OptionalString, expectedOwner: ExpectedOwner, limit: OptionalPositiveInt, - cursor: OptionalString + cursor: OptionalString, + /** Reply with a byte-bounded page of rows whose output is cut to a preview. */ + preview: OptionalBoolean, + /** Reply with this one full run as `{ run }`; requires `automationId`. */ + runId: OptionalString }) // Why strict: null or a non-string must fail rather than fall back to "omitted", which preserves.