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
This commit is contained in:
Jinwoo Hong
2026-11-06 15:49:45 -05:00
committed by GitHub
parent 16f2d38acb
commit 01f65352d8
39 changed files with 587 additions and 31 deletions
@@ -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<ReturnType<typeof automationClient>>
root: () => ReactTestInstance
press: (label: string) => Promise<void>
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)
})
}
@@ -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(
@@ -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<string | null>(null)
@@ -177,6 +184,18 @@ export function AutomationRunDetails(props: {
</View>
{runError ? <Text style={styles.error}>{runError}</Text> : null}
<Text style={styles.heading}>{t('automations.mobile.output', 'Output')}</Text>
{full.failed ? (
<>
<Text accessibilityRole="alert" style={styles.error}>
{t('automations.mobile.fullOutputUnavailable', "Couldn't load the full output.")}
</Text>
<AutomationButton
label={t('automations.mobile.retryFullOutput', 'Retry')}
onPress={full.retry}
disabled={!props.connected}
/>
</>
) : null}
<Text selectable style={styles.output}>
{getAutomationRunContent(
{ ...run, error: runError },
@@ -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',
@@ -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<typeof useAutomationRuns>
@@ -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<string | null>(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)
}
}
}
+2
View File
@@ -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}}",
+2
View File
@@ -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.",
+2
View File
@@ -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}}",
+2
View File
@@ -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}}",
+2
View File
@@ -3982,6 +3982,8 @@
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "元のAgentペインは利用できません。保存済みの出力はここに残ります。",
"openAgent": "Agentを開く",
"retryHistory": "履歴を再読み込み",
"fullOutputUnavailable": "完全な出力を読み込めませんでした。",
"retryFullOutput": "再試行",
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "読み込んだ履歴に一致する実行はありません。さらに読み込んでください。",
"noMatchingRunsYet": "一致する実行はまだありません。",
"runSummary": "{{title}}、{{status}}、{{date}}",
+2
View File
@@ -3982,6 +3982,8 @@
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "원래 에이전트 창을 사용할 수 없습니다. 저장된 출력은 여기에 유지됩니다.",
"openAgent": "에이전트 열기",
"retryHistory": "기록 다시 불러오기",
"fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.",
"retryFullOutput": "다시 시도",
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "불러온 기록에 일치하는 실행이 없습니다. 계속하려면 더 불러오세요.",
"noMatchingRunsYet": "아직 일치하는 실행이 없습니다.",
"runSummary": "{{title}}, {{status}}, {{date}}",
+2
View File
@@ -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}}",
+2
View File
@@ -4174,6 +4174,8 @@
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Исходная панель агента недоступна. Сохранённый вывод остаётся здесь.",
"openAgent": "Открыть агента",
"retryHistory": "Повторить загрузку истории",
"fullOutputUnavailable": "Не удалось загрузить полный вывод.",
"retryFullOutput": "Повторить",
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "В загруженной истории нет подходящих запусков. Загрузите ещё, чтобы продолжить.",
"noMatchingRunsYet": "Подходящих запусков пока нет.",
"runSummary": "{{title}}, {{status}}, {{date}}",
+2
View File
@@ -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}}",
+2
View File
@@ -3974,6 +3974,8 @@
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "原始代理程式窗格無法使用。已儲存的輸出仍保留在此處。",
"openAgent": "開啟代理程式",
"retryHistory": "重試歷程記錄",
"fullOutputUnavailable": "無法載入完整輸出。",
"retryFullOutput": "重試",
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "已載入的歷程記錄中沒有符合的執行。請載入更多以繼續。",
"noMatchingRunsYet": "尚無符合的執行。",
"runSummary": "{{title}},{{status}},{{date}}",
+2
View File
@@ -3982,6 +3982,8 @@
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "原始代理窗格不可用。已保存的输出仍保留在此处。",
"openAgent": "打开代理",
"retryHistory": "重试历史记录",
"fullOutputUnavailable": "无法加载完整输出。",
"retryFullOutput": "重试",
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "已加载的历史记录中没有匹配的运行。请加载更多以继续。",
"noMatchingRunsYet": "暂无匹配的运行。",
"runSummary": "{{title}},{{status}},{{date}}",
@@ -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 }
}
@@ -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<string, unknown>) {
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')
})
})
@@ -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,
@@ -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')
@@ -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 ? (
<div role="alert" className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
{translate(
'auto.components.automations.AutomationsPage.fullOutputUnavailable',
"Couldn't load the full output."
)}
<Button type="button" variant="outline" size="xs" onClick={full.retry}>
{translate('auto.components.automations.AutomationsPage.retryFullOutput', 'Retry')}
</Button>
</div>
) : null}
<CommentMarkdown
variant="document"
content={content}
@@ -119,7 +119,11 @@ describe('AutomationsPage refresh', () => {
? { 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')
})
@@ -234,6 +234,7 @@ export function AutomationsPageSurface({
<AutomationRunDetailsPage
automation={selected}
run={selectedAutomationRunPage}
history={local.selectedAutomationRuns}
relativeNow={relativeNow}
workspaceDisplay={runPage.selectedAutomationRunPageWorkspaceDisplay}
viewState={runPage.selectedAutomationRunPageViewState}
@@ -155,18 +155,55 @@ export async function listAutomationsForOwner(
}
/** Owned and orphan history reads do not probe. Old hosts may ignore preconditions and return their stored rows; read-only calls cannot mutate behind an unhonored fence. */
async function listRunsFenced(
async function listRunsPageFenced(
authority: AutomationAuthorityRef,
automationId: string,
expectedOwner: AutomationOwnerPrecondition,
options: { limit?: number; cursor?: string } = {}
): Promise<AutomationRunsPage> {
const result = await callAuthority<AutomationRunsPage | { runs: AutomationRun[] }>(
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<AutomationRun[]> {
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<AutomationRun, 'automationId' | 'id'>
): Promise<AutomationRun | null> {
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<AutomationRunsPage> {
const result = await callAuthority<AutomationRunsPage | { runs: AutomationRun[] }>(
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<AutomationRunsPage> {
const result = await callAuthority<AutomationRunsPage | { runs: AutomationRun[] }>(
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(
@@ -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<string | null>(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)
}
}
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -16975,6 +16975,8 @@
"noRunUsageYet": "まだ実行の使用量がありません",
"noWorkspace": "ワークスペースなし",
"latestSavedOutput": "最新の保存済み出力",
"fullOutputUnavailable": "完全な出力を読み込めませんでした。",
"retryFullOutput": "再試行",
"backToList": "すべての自動化",
"tableName": "名前",
"tableProject": "プロジェクト",
+2
View File
@@ -16975,6 +16975,8 @@
"noRunUsageYet": "아직 실행 사용량 없음",
"noWorkspace": "워크스페이스 없음",
"latestSavedOutput": "최근 저장된 출력",
"fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.",
"retryFullOutput": "다시 시도",
"backToList": "모든 자동화",
"tableName": "이름",
"tableProject": "프로젝트",
+2
View File
@@ -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",
+2
View File
@@ -18139,6 +18139,8 @@
"noRunUsageYet": "Данных об использовании запусков пока нет",
"noWorkspace": "Нет рабочего пространства",
"latestSavedOutput": "Последний сохранённый вывод",
"fullOutputUnavailable": "Не удалось загрузить полный вывод.",
"retryFullOutput": "Повторить",
"backToList": "Все автоматизации",
"tableName": "Название",
"tableProject": "Проект",
+2
View File
@@ -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",
@@ -17398,6 +17398,8 @@
"noRunUsageYet": "尚無執行用量",
"noWorkspace": "無工作區",
"latestSavedOutput": "最近儲存的輸出",
"fullOutputUnavailable": "無法載入完整輸出。",
"retryFullOutput": "重試",
"backToList": "全部自動化",
"tableName": "名稱",
"tableProject": "專案",
+2
View File
@@ -16975,6 +16975,8 @@
"noRunUsageYet": "尚无运行用量",
"noWorkspace": "无工作区",
"latestSavedOutput": "最近保存的输出",
"fullOutputUnavailable": "无法加载完整输出。",
"retryFullOutput": "重试",
"backToList": "全部自动化",
"tableName": "名称",
"tableProject": "项目",
+6 -1
View File
@@ -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<AutomationRun, 'createdAt' | 'id'>): string {
return `${run.createdAt}:${run.id}`
}
/** Order by createdAt then id so pruning a boundary cannot hide tied runs. */
export function compareAutomationRunsNewestFirst(
left: Pick<AutomationRun, 'createdAt' | 'id'>,
@@ -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
}
}
+5 -1
View File
@@ -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.