mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -3982,6 +3982,8 @@
|
||||
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "元のAgentペインは利用できません。保存済みの出力はここに残ります。",
|
||||
"openAgent": "Agentを開く",
|
||||
"retryHistory": "履歴を再読み込み",
|
||||
"fullOutputUnavailable": "完全な出力を読み込めませんでした。",
|
||||
"retryFullOutput": "再試行",
|
||||
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "読み込んだ履歴に一致する実行はありません。さらに読み込んでください。",
|
||||
"noMatchingRunsYet": "一致する実行はまだありません。",
|
||||
"runSummary": "{{title}}、{{status}}、{{date}}",
|
||||
|
||||
@@ -3982,6 +3982,8 @@
|
||||
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "원래 에이전트 창을 사용할 수 없습니다. 저장된 출력은 여기에 유지됩니다.",
|
||||
"openAgent": "에이전트 열기",
|
||||
"retryHistory": "기록 다시 불러오기",
|
||||
"fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.",
|
||||
"retryFullOutput": "다시 시도",
|
||||
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "불러온 기록에 일치하는 실행이 없습니다. 계속하려면 더 불러오세요.",
|
||||
"noMatchingRunsYet": "아직 일치하는 실행이 없습니다.",
|
||||
"runSummary": "{{title}}, {{status}}, {{date}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -4174,6 +4174,8 @@
|
||||
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "Исходная панель агента недоступна. Сохранённый вывод остаётся здесь.",
|
||||
"openAgent": "Открыть агента",
|
||||
"retryHistory": "Повторить загрузку истории",
|
||||
"fullOutputUnavailable": "Не удалось загрузить полный вывод.",
|
||||
"retryFullOutput": "Повторить",
|
||||
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "В загруженной истории нет подходящих запусков. Загрузите ещё, чтобы продолжить.",
|
||||
"noMatchingRunsYet": "Подходящих запусков пока нет.",
|
||||
"runSummary": "{{title}}, {{status}}, {{date}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -3974,6 +3974,8 @@
|
||||
"theOriginalAgentPaneIsUnavailableSavedOutputRemainsHere": "原始代理程式窗格無法使用。已儲存的輸出仍保留在此處。",
|
||||
"openAgent": "開啟代理程式",
|
||||
"retryHistory": "重試歷程記錄",
|
||||
"fullOutputUnavailable": "無法載入完整輸出。",
|
||||
"retryFullOutput": "重試",
|
||||
"noMatchingRunsInTheLoadedHistoryLoadMoreToContinue": "已載入的歷程記錄中沒有符合的執行。請載入更多以繼續。",
|
||||
"noMatchingRunsYet": "尚無符合的執行。",
|
||||
"runSummary": "{{title}},{{status}},{{date}}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16975,6 +16975,8 @@
|
||||
"noRunUsageYet": "まだ実行の使用量がありません",
|
||||
"noWorkspace": "ワークスペースなし",
|
||||
"latestSavedOutput": "最新の保存済み出力",
|
||||
"fullOutputUnavailable": "完全な出力を読み込めませんでした。",
|
||||
"retryFullOutput": "再試行",
|
||||
"backToList": "すべての自動化",
|
||||
"tableName": "名前",
|
||||
"tableProject": "プロジェクト",
|
||||
|
||||
@@ -16975,6 +16975,8 @@
|
||||
"noRunUsageYet": "아직 실행 사용량 없음",
|
||||
"noWorkspace": "워크스페이스 없음",
|
||||
"latestSavedOutput": "최근 저장된 출력",
|
||||
"fullOutputUnavailable": "전체 출력을 불러오지 못했습니다.",
|
||||
"retryFullOutput": "다시 시도",
|
||||
"backToList": "모든 자동화",
|
||||
"tableName": "이름",
|
||||
"tableProject": "프로젝트",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18139,6 +18139,8 @@
|
||||
"noRunUsageYet": "Данных об использовании запусков пока нет",
|
||||
"noWorkspace": "Нет рабочего пространства",
|
||||
"latestSavedOutput": "Последний сохранённый вывод",
|
||||
"fullOutputUnavailable": "Не удалось загрузить полный вывод.",
|
||||
"retryFullOutput": "Повторить",
|
||||
"backToList": "Все автоматизации",
|
||||
"tableName": "Название",
|
||||
"tableProject": "Проект",
|
||||
|
||||
@@ -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": "專案",
|
||||
|
||||
@@ -16975,6 +16975,8 @@
|
||||
"noRunUsageYet": "尚无运行用量",
|
||||
"noWorkspace": "无工作区",
|
||||
"latestSavedOutput": "最近保存的输出",
|
||||
"fullOutputUnavailable": "无法加载完整输出。",
|
||||
"retryFullOutput": "重试",
|
||||
"backToList": "全部自动化",
|
||||
"tableName": "名称",
|
||||
"tableProject": "项目",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user