Skip to content

Commit 6d58813

Browse files
committed
load cloud run history from presigned log urls
1 parent 73b695c commit 6d58813

5 files changed

Lines changed: 435 additions & 20 deletions

File tree

apps/mobile/src/features/tasks/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,9 +719,11 @@ export async function fetchSessionLogs(
719719
offset: String(options.offset ?? 0),
720720
});
721721

722+
// Big runs can take the server a long time per page (it re-reads the whole
723+
// log chain each request), so this needs far more than the default budget.
722724
const response = await authedFetch(
723725
`${baseUrl}/api/projects/${projectId}/tasks/${taskId}/runs/${runId}/session_logs/?${params}`,
724-
{ signal: createTimeoutSignal(10_000) },
726+
{ signal: createTimeoutSignal(120_000) },
725727
);
726728

727729
if (!response.ok) {

apps/mobile/src/features/tasks/lib/cloudTaskStream.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -805,14 +805,18 @@ async function fetchTaskRunState(
805805

806806
/**
807807
* Loads the historical log entries for the run, mirroring the desktop's
808-
* dual-source strategy:
809-
* 1. Try the paginated `session_logs/` API — the live source while a run
808+
* strategy:
809+
* 1. Prefer the presigned resume-chain `log_urls` (S3 NDJSON, oldest
810+
* first) when the server provides them. Downloading straight from S3
811+
* avoids the paginated API's per-page full-chain re-read, which times
812+
* out on runs with very large histories.
813+
* 2. Try the paginated `session_logs/` API — the live source while a run
810814
* is active. For older / archived runs this can come back empty even
811815
* though the canonical log exists on S3.
812-
* 2. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the
816+
* 3. Fall back to the run's presigned `log_url` (S3 NDJSON), which is the
813817
* canonical archive for completed runs.
814818
*
815-
* Returns `null` only when both sources fail outright (so the bootstrap can
819+
* Returns `null` only when the sources fail outright (so the bootstrap can
816820
* surface a retryable error). An empty paginated result is treated as "no
817821
* data yet" and falls through to S3 — if S3 also has nothing we return the
818822
* empty array so the snapshot can still flip the session to `"connected"`.
@@ -821,6 +825,12 @@ async function fetchHistoricalEntries(
821825
watcher: WatcherState,
822826
run: TaskRun,
823827
): Promise<StoredLogEntry[] | null> {
828+
if (run.log_urls?.length) {
829+
const chainEntries = await fetchChainLogEntries(watcher, run.log_urls);
830+
if (watcher.stopped || watcher.failed) return null;
831+
if (chainEntries) return chainEntries;
832+
}
833+
824834
const paginated = await fetchAllSessionLogs(watcher);
825835
if (watcher.stopped || watcher.failed) return null;
826836
if (paginated && paginated.length > 0) return paginated;
@@ -837,13 +847,29 @@ async function fetchHistoricalEntries(
837847
return paginated ?? null;
838848
}
839849

850+
async function fetchChainLogEntries(
851+
watcher: WatcherState,
852+
logUrls: string[],
853+
): Promise<StoredLogEntry[] | null> {
854+
const entries: StoredLogEntry[] = [];
855+
for (const logUrl of logUrls) {
856+
const chunk = await fetchS3LogEntries(watcher, logUrl);
857+
if (watcher.stopped || watcher.failed) return null;
858+
if (chunk === null) return null;
859+
entries.push(...chunk);
860+
}
861+
return entries;
862+
}
863+
840864
async function fetchS3LogEntries(
841865
watcher: WatcherState,
842866
logUrl: string,
843867
): Promise<StoredLogEntry[] | null> {
844868
try {
869+
// Chain logs of long-running tasks can be hundreds of MB; RN fetch buffers
870+
// the whole body, so the budget covers the full download, not just TTFB.
845871
const response = await fetch(logUrl, {
846-
signal: createTimeoutSignal(15_000),
872+
signal: createTimeoutSignal(120_000),
847873
});
848874
if (response.status === 404) {
849875
// No archived log yet for this run — not an error, just no data.

apps/mobile/src/features/tasks/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ export interface TaskRun {
6464
environment?: "local" | "cloud";
6565
status: TaskRunStatus;
6666
log_url: string;
67+
/** Presigned S3 URLs for every log in the run's resume chain, oldest first.
68+
* Absent on old servers; empty when the server can't presign. */
69+
log_urls?: string[];
6770
error_message: string | null;
6871
reasoning_effort?: string | null;
6972
output: Record<string, unknown> | null;

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,203 @@ describe("CloudTaskService", () => {
483483
expect(messages()).toEqual(["before retry", "after retry"]);
484484
});
485485

486+
const consoleLogEntry = (message: string) => ({
487+
type: "notification",
488+
timestamp: "2026-01-01T00:00:01Z",
489+
notification: {
490+
jsonrpc: "2.0",
491+
method: "_posthog/console",
492+
params: { sessionId: "run-1", level: "info", message },
493+
},
494+
});
495+
496+
it("bootstraps history from presigned chain log urls without touching session_logs", async () => {
497+
const updates: unknown[] = [];
498+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
499+
500+
const sessionLogsCalls: string[] = [];
501+
mockNetFetch.mockImplementation((input: string | Request) => {
502+
const url = typeof input === "string" ? input : input.url;
503+
if (url.includes("/session_logs/")) {
504+
sessionLogsCalls.push(url);
505+
return Promise.resolve(
506+
createJsonResponse([], 200, { "X-Has-More": "false" }),
507+
);
508+
}
509+
if (url.startsWith("https://storage.example/run-0.jsonl")) {
510+
return Promise.resolve(
511+
new Response(
512+
`${JSON.stringify(consoleLogEntry("ancestor 1"))}\n${JSON.stringify(consoleLogEntry("ancestor 2"))}\n`,
513+
),
514+
);
515+
}
516+
if (url.startsWith("https://storage.example/run-1.jsonl")) {
517+
// No trailing newline: the final line of a log object is still a complete entry.
518+
return Promise.resolve(
519+
new Response(JSON.stringify(consoleLogEntry("current"))),
520+
);
521+
}
522+
return Promise.resolve(
523+
createJsonResponse({
524+
id: "run-1",
525+
status: "completed",
526+
stage: null,
527+
output: null,
528+
error_message: null,
529+
branch: "main",
530+
updated_at: "2026-01-01T00:00:00Z",
531+
log_urls: [
532+
"https://storage.example/run-0.jsonl?sig=1",
533+
"https://storage.example/run-1.jsonl?sig=2",
534+
],
535+
}),
536+
);
537+
});
538+
539+
service.watch({
540+
taskId: "task-1",
541+
runId: "run-1",
542+
apiHost: "https://app.example.com",
543+
teamId: 2,
544+
});
545+
546+
await waitFor(() =>
547+
updates.some((u) => (u as { kind?: string }).kind === "snapshot"),
548+
);
549+
550+
const snapshot = updates.find(
551+
(u) => (u as { kind?: string }).kind === "snapshot",
552+
) as { newEntries: unknown[]; totalEntryCount: number };
553+
expect(snapshot.newEntries).toEqual([
554+
consoleLogEntry("ancestor 1"),
555+
consoleLogEntry("ancestor 2"),
556+
consoleLogEntry("current"),
557+
]);
558+
expect(snapshot.totalEntryCount).toBe(3);
559+
expect(sessionLogsCalls).toEqual([]);
560+
});
561+
562+
it("falls back to the paginated API when a chain log download fails", async () => {
563+
const updates: unknown[] = [];
564+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
565+
566+
mockNetFetch.mockImplementation((input: string | Request) => {
567+
const url = typeof input === "string" ? input : input.url;
568+
if (url.includes("/session_logs/")) {
569+
return Promise.resolve(
570+
createJsonResponse([consoleLogEntry("from api")], 200, {
571+
"X-Has-More": "false",
572+
}),
573+
);
574+
}
575+
if (url.startsWith("https://storage.example/")) {
576+
return Promise.resolve(new Response("expired", { status: 403 }));
577+
}
578+
return Promise.resolve(
579+
createJsonResponse({
580+
id: "run-1",
581+
status: "completed",
582+
stage: null,
583+
output: null,
584+
error_message: null,
585+
branch: "main",
586+
updated_at: "2026-01-01T00:00:00Z",
587+
log_urls: ["https://storage.example/run-1.jsonl?sig=1"],
588+
}),
589+
);
590+
});
591+
592+
service.watch({
593+
taskId: "task-1",
594+
runId: "run-1",
595+
apiHost: "https://app.example.com",
596+
teamId: 2,
597+
});
598+
599+
await waitFor(() =>
600+
updates.some((u) => (u as { kind?: string }).kind === "snapshot"),
601+
);
602+
603+
const snapshot = updates.find(
604+
(u) => (u as { kind?: string }).kind === "snapshot",
605+
) as { newEntries: unknown[] };
606+
expect(snapshot.newEntries).toEqual([consoleLogEntry("from api")]);
607+
});
608+
609+
it("resumes paginated history from the last fetched page after a retry", async () => {
610+
vi.useFakeTimers();
611+
const updates: unknown[] = [];
612+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
613+
614+
const sessionLogsOffsets: string[] = [];
615+
let failPageFetches = true;
616+
mockNetFetch.mockImplementation((input: string | Request) => {
617+
const url = typeof input === "string" ? input : input.url;
618+
if (url.includes("/session_logs/")) {
619+
const offset = new URL(url).searchParams.get("offset") ?? "";
620+
sessionLogsOffsets.push(offset);
621+
if (offset === "0") {
622+
return Promise.resolve(
623+
createJsonResponse([consoleLogEntry("page one")], 200, {
624+
"X-Has-More": "true",
625+
}),
626+
);
627+
}
628+
if (failPageFetches) {
629+
return Promise.reject(new Error("socket hang up"));
630+
}
631+
return Promise.resolve(
632+
createJsonResponse([consoleLogEntry("page two")], 200, {
633+
"X-Has-More": "false",
634+
}),
635+
);
636+
}
637+
return Promise.resolve(
638+
createJsonResponse({
639+
id: "run-1",
640+
status: "completed",
641+
stage: null,
642+
output: null,
643+
error_message: null,
644+
branch: "main",
645+
updated_at: "2026-01-01T00:00:00Z",
646+
}),
647+
);
648+
});
649+
650+
service.watch({
651+
taskId: "task-1",
652+
runId: "run-1",
653+
apiHost: "https://app.example.com",
654+
teamId: 2,
655+
});
656+
657+
// Page 0 succeeds; the offset-1 page fails all retry attempts and the
658+
// watcher surfaces a retryable error instead of a snapshot.
659+
await waitFor(
660+
() => updates.some((u) => (u as { kind?: string }).kind === "error"),
661+
30_000,
662+
);
663+
expect(sessionLogsOffsets).toEqual(["0", "1", "1", "1"]);
664+
665+
failPageFetches = false;
666+
await service.retry("task-1", "run-1");
667+
await waitFor(
668+
() => updates.some((u) => (u as { kind?: string }).kind === "snapshot"),
669+
30_000,
670+
);
671+
672+
// The retry resumed from offset 1 instead of refetching page 0.
673+
expect(sessionLogsOffsets).toEqual(["0", "1", "1", "1", "1"]);
674+
const snapshot = updates.find(
675+
(u) => (u as { kind?: string }).kind === "snapshot",
676+
) as { newEntries: unknown[] };
677+
expect(snapshot.newEntries).toEqual([
678+
consoleLogEntry("page one"),
679+
consoleLogEntry("page two"),
680+
]);
681+
});
682+
486683
it("reconnects with Last-Event-ID after a stream error", async () => {
487684
vi.useFakeTimers();
488685

0 commit comments

Comments
 (0)