Skip to content

Commit 0b3d32b

Browse files
authored
Merge pull request #1376 from transformerlab/fix/make-provider-logs-permanent
Make provider logs permanent
2 parents 9e373e6 + 763c5f6 commit 0b3d32b

6 files changed

Lines changed: 178 additions & 13 deletions

File tree

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
"soundfile==0.13.1",
3535
"tensorboardX==2.6.2.2",
3636
"timm==1.0.15",
37-
"transformerlab==0.0.81",
37+
"transformerlab==0.0.82",
3838
"transformerlab-inference==0.2.52",
3939
"transformers==4.57.1",
4040
"wandb==0.23.1",

api/transformerlab/routers/experiment/jobs.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,22 @@ async def get_provider_job_logs(
241241
experimentId: str,
242242
job_id: str,
243243
tail_lines: int = Query(400, ge=100, le=2000),
244+
live: bool = Query(
245+
False,
246+
description=(
247+
"If true, bypass cached provider_logs.txt and fetch logs directly from the underlying compute provider."
248+
),
249+
),
244250
user_and_team=Depends(get_user_and_team),
245251
session: AsyncSession = Depends(get_async_session),
246252
):
247253
"""
248254
Fetch the raw job logs directly from the underlying compute provider for a REMOTE job.
255+
256+
Preferred order:
257+
1. If `provider_logs.txt` exists in the job directory (written by the SDK wrapper),
258+
read and return that.
259+
2. Otherwise, fall back to provider-native log retrieval (existing behavior).
249260
"""
250261

251262
job = await job_service.job_get(job_id)
@@ -266,6 +277,36 @@ async def get_provider_job_logs(
266277
status_code=400, detail="Job does not contain provider metadata (provider_id/cluster_name missing)"
267278
)
268279

280+
# 1) If live=False (default), first try to read provider logs from the job directory
281+
# via the SDK's job_dir helper. This file is written by tfl-remote-trap inside
282+
# the remote environment.
283+
if not live:
284+
try:
285+
from lab.dirs import get_job_dir
286+
287+
job_dir = await get_job_dir(job_id)
288+
provider_logs_path = storage.join(job_dir, "provider_logs.txt")
289+
if await storage.exists(provider_logs_path):
290+
async with await storage.open(provider_logs_path, "r", encoding="utf-8") as f:
291+
logs_text = await f.read()
292+
if tail_lines is not None:
293+
lines = logs_text.splitlines()
294+
logs_text = "\n".join(lines[-tail_lines:])
295+
296+
return {
297+
"cluster_name": cluster_name,
298+
"provider_id": provider_id,
299+
"provider_job_id": None,
300+
"provider_name": job_data.get("provider_name"),
301+
"tail_lines": tail_lines,
302+
"logs": logs_text,
303+
"job_candidates": [],
304+
}
305+
except Exception:
306+
# If anything goes wrong with the file-based path, fall back to provider-native logs.
307+
pass
308+
309+
# 2) Fall back to existing provider-native log retrieval.
269310
provider = await get_team_provider(session, user_and_team["team_id"], provider_id)
270311
if not provider:
271312
raise HTTPException(status_code=404, detail="Provider not found")

lab-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab"
7-
version = "0.0.81"
7+
version = "0.0.82"
88
description = "Python SDK for Transformer Lab"
99
readme = "README.md"
1010
requires-python = ">=3.10"

lab-sdk/src/lab/remote_trap.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77
from typing import List
88

9-
from lab import Job
9+
from lab import Job, storage
1010

1111

1212
async def _set_live_status_async(job_id: str, status: str) -> None:
@@ -43,6 +43,54 @@ def _set_live_status(status: str) -> None:
4343
return
4444

4545

46+
async def _write_provider_logs_async(job_id: str, logs_text: str) -> None:
47+
"""
48+
Best-effort helper to write combined stdout/stderr logs to the job directory.
49+
50+
Uses _TFL_JOB_ID to resolve the job directory via lab.dirs.get_job_dir, then
51+
writes provider_logs.txt using the storage abstraction.
52+
"""
53+
try:
54+
# Import inside helper to avoid circular imports at module load time.
55+
from lab.dirs import get_job_dir
56+
57+
job_dir = await get_job_dir(job_id)
58+
log_path = storage.join(job_dir, "provider_logs.txt")
59+
60+
# Ensure the directory exists (no-op for remote storage that doesn't require mkdirs).
61+
try:
62+
await storage.makedirs(job_dir, exist_ok=True)
63+
except Exception:
64+
# Some storage backends may not support makedirs for virtual paths; ignore.
65+
pass
66+
67+
async with await storage.open(log_path, "w", encoding="utf-8") as f:
68+
await f.write(logs_text or "")
69+
except Exception:
70+
# Never let logging failures break the wrapped command.
71+
return
72+
73+
74+
def _write_provider_logs(logs_text: str) -> None:
75+
"""Entry-point wrapper for writing provider logs for the current job."""
76+
job_id = os.environ.get("_TFL_JOB_ID")
77+
if not job_id or logs_text is None:
78+
return
79+
80+
try:
81+
asyncio.run(_write_provider_logs_async(job_id, logs_text))
82+
except RuntimeError:
83+
# Fallback in case an event loop already exists.
84+
try:
85+
loop = asyncio.get_event_loop()
86+
if loop.is_running():
87+
loop.create_task(_write_provider_logs_async(job_id, logs_text))
88+
else:
89+
loop.run_until_complete(_write_provider_logs_async(job_id, logs_text))
90+
except Exception:
91+
return
92+
93+
4694
def main(argv: List[str] | None = None) -> int:
4795
"""
4896
Wrapper entrypoint for remote jobs.
@@ -74,7 +122,40 @@ def main(argv: List[str] | None = None) -> int:
74122
_set_live_status("started")
75123

76124
# Run the original command in the shell so it behaves exactly as submitted.
77-
completed = subprocess.run(command_str, shell=True)
125+
# Capture stdout/stderr so we can save a copy to provider_logs.txt while still
126+
# echoing output to the current process streams.
127+
completed = subprocess.run(
128+
command_str,
129+
shell=True,
130+
capture_output=True,
131+
text=True,
132+
)
133+
134+
# Echo captured output back to the current stdout/stderr so provider-native logs
135+
# (e.g., SkyPilot, SLURM, RunPod) still see the same content.
136+
if completed.stdout:
137+
try:
138+
sys.stdout.write(completed.stdout)
139+
sys.stdout.flush()
140+
except Exception:
141+
pass
142+
if completed.stderr:
143+
try:
144+
sys.stderr.write(completed.stderr)
145+
sys.stderr.flush()
146+
except Exception:
147+
pass
148+
149+
# Combine stdout + stderr into a single text blob and store it alongside the job.
150+
combined_logs_parts: List[str] = []
151+
if completed.stdout:
152+
combined_logs_parts.append(completed.stdout)
153+
if completed.stderr:
154+
combined_logs_parts.append(completed.stderr)
155+
combined_logs = "\n".join(part.rstrip("\n") for part in combined_logs_parts)
156+
157+
_write_provider_logs(combined_logs)
158+
78159
exit_code = completed.returncode
79160

80161
# Update live_status based on outcome (best-effort).

src/renderer/components/Experiment/Tasks/ViewOutputModalStreaming.tsx

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
22
import {
33
Alert,
44
Box,
5+
Checkbox,
56
CircularProgress,
67
Modal,
78
ModalClose,
@@ -117,6 +118,8 @@ function ViewOutputModalStreaming({
117118
}: ViewOutputModalStreamingProps) {
118119
const { experimentInfo } = useExperimentInfo();
119120
const [activeTab, setActiveTab] = useState<'output' | 'provider'>('output');
121+
const [viewLiveProviderLogs, setViewLiveProviderLogs] =
122+
useState<boolean>(false);
120123

121124
const tabs = tabsProp.length > 0 ? tabsProp : ['output', 'provider'];
122125
const showTabList = tabs.length > 1;
@@ -128,6 +131,7 @@ function ViewOutputModalStreaming({
128131
? current
129132
: ((tabs[0] ?? 'output') as 'output' | 'provider'),
130133
);
134+
setViewLiveProviderLogs(false);
131135
// tabsKey is a stable serialization of tabs to avoid array reference churn
132136
// eslint-disable-next-line react-hooks/exhaustive-deps
133137
}, [jobId, tabsKey]);
@@ -139,8 +143,10 @@ function ViewOutputModalStreaming({
139143
return chatAPI.Endpoints.Experiment.GetProviderLogs(
140144
experimentInfo.id,
141145
String(jobId),
146+
400,
147+
viewLiveProviderLogs,
142148
);
143-
}, [experimentInfo?.id, jobId]);
149+
}, [experimentInfo?.id, jobId, viewLiveProviderLogs]);
144150

145151
const {
146152
data: providerLogsData,
@@ -210,29 +216,53 @@ function ViewOutputModalStreaming({
210216
</TabList>
211217
</Tabs>
212218
)}
219+
{activeTab === 'provider' && (
220+
<Box
221+
sx={{
222+
mt: 1,
223+
display: 'flex',
224+
alignItems: 'center',
225+
gap: 1.5,
226+
}}
227+
>
228+
<Checkbox
229+
size="sm"
230+
checked={viewLiveProviderLogs}
231+
onChange={(event) =>
232+
setViewLiveProviderLogs(!!event.target.checked)
233+
}
234+
label="View live provider logs"
235+
/>
236+
{viewLiveProviderLogs && (
237+
<Typography level="body-xs" color="warning">
238+
Live logs are fetched directly from the remote machine and may
239+
disappear once the machine stops running.
240+
</Typography>
241+
)}
242+
</Box>
243+
)}
213244
<Box
214245
sx={{
215-
mt: 1,
246+
mt: activeTab === 'provider' ? 0.5 : 1,
216247
flex: 1,
217248
minHeight: 0,
218249
width: '100%',
219250
display: 'flex',
220-
padding: '8px 0px 8px 11px',
221-
backgroundColor: '#000',
222-
borderRadius: '8px',
251+
flexDirection: 'column',
223252
}}
224253
>
225254
{tabs.includes('output') && activeTab === 'output' ? (
226255
<Box
227256
sx={{
228-
padding: `0`,
257+
padding: 0,
229258
backgroundColor: '#000',
230259
width: '100%',
231260
flex: 1,
232261
minHeight: 0,
233262
overflow: 'hidden',
234263
display: 'flex',
235264
flexDirection: 'column',
265+
borderRadius: '8px',
236266
}}
237267
>
238268
<PollingOutputTerminal
@@ -244,7 +274,19 @@ function ViewOutputModalStreaming({
244274
/>
245275
</Box>
246276
) : (
247-
<>
277+
<Box
278+
sx={{
279+
flex: 1,
280+
minHeight: 0,
281+
width: '100%',
282+
display: 'flex',
283+
flexDirection: 'column',
284+
backgroundColor: '#000',
285+
borderRadius: '8px',
286+
padding: '8px 11px',
287+
gap: 1,
288+
}}
289+
>
248290
{providerLogsLoading && (
249291
<Box
250292
sx={{
@@ -302,7 +344,7 @@ function ViewOutputModalStreaming({
302344
{!providerLogsLoading && !providerLogsError && (
303345
<ProviderLogsTerminal logsText={providerLogText} />
304346
)}
305-
</>
347+
</Box>
306348
)}
307349
</Box>
308350
</ModalDialog>

src/renderer/lib/api-client/endpoints.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,9 @@ Endpoints.Experiment = {
540540
experimentId: string,
541541
jobId: string,
542542
tailLines: number = 400,
543+
live: boolean = false,
543544
) =>
544-
`${API_URL()}experiment/${experimentId}/jobs/${jobId}/provider_logs?tail_lines=${tailLines}`,
545+
`${API_URL()}experiment/${experimentId}/jobs/${jobId}/provider_logs?tail_lines=${tailLines}&live=${live}`,
545546
GetTunnelInfo: (
546547
experimentId: string,
547548
jobId: string,

0 commit comments

Comments
 (0)