Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/frontend/src/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,21 @@ export type WorkflowDefinition = {
steps: WorkflowStep[];
};

export type WorkflowLogSummary = {
name: string;
location: "var" | "tmp" | string;
path: string;
sizeBytes: number;
modifiedAt: string;
};

export type WorkflowLogTail = {
name: string;
location: "var" | "tmp" | string;
path: string;
tail: string;
};

export const api = {
getDashboard: () =>
request<{
Expand Down Expand Up @@ -478,6 +493,11 @@ export const api = {
request<{ workflows: WorkflowDefinition[] }>("/workflows"),
getWorkspaceWorkflows: () =>
request<{ workflows: WorkspaceWorkflowSummary[] }>("/workspace/workflows"),
getWorkflowLogs: () => request<{ logs: WorkflowLogSummary[] }>("/workflow-logs"),
getWorkflowLogTail: (location: string, logName: string) =>
request<WorkflowLogTail>(
`/workflow-logs/${encodeURIComponent(location)}/${encodeURIComponent(logName)}`,
),
terminateWorkflow: (workflowId: string) =>
request<{ success: boolean; message?: string }>(`/workflows/${encodeURIComponent(workflowId)}/terminate`, {
method: "POST",
Expand Down
96 changes: 92 additions & 4 deletions packages/frontend/src/pages/TasksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom";
import {
api,
ArtefactSummary,
WorkflowLogSummary,
WorkspaceWorkflowSummary,
} from "../hooks/useApi";
import { Panel } from "../components/Panel";
Expand Down Expand Up @@ -65,6 +66,19 @@ const formatWorkflowLastRun = (workflow: WorkspaceWorkflowSummary) => {
return formatDateTime(workflow.lastRun);
};

const formatBytes = (value: number) => {
if (!Number.isFinite(value) || value < 0) {
return "-";
}
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
};

const renderWorkflowDiagnosticsSummary = (diagnostics: WorkflowDiagnostics) => {
if (!diagnostics) {
return <span className="meta-secondary">No diagnostics yet</span>;
Expand Down Expand Up @@ -107,6 +121,13 @@ export const TasksPage: React.FC = () => {
const [workspaceWorkflows, setWorkspaceWorkflows] = useState<
WorkspaceWorkflowSummary[]
>([]);
const [workflowLogs, setWorkflowLogs] = useState<WorkflowLogSummary[]>([]);
const [logModal, setLogModal] = useState<{
open: boolean;
title: string;
content: string;
}>({ open: false, title: "", content: "" });
const [loadingLog, setLoadingLog] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [terminatingWorkflow, setTerminatingWorkflow] = useState<string | null>(null);
const [terminateModal, setTerminateModal] = useState(false);
Expand Down Expand Up @@ -138,11 +159,13 @@ export const TasksPage: React.FC = () => {

useEffect(() => {
loadTasks();
api
.getWorkspaceWorkflows()
.then((res) => setWorkspaceWorkflows(res.workflows))
Promise.all([api.getWorkspaceWorkflows(), api.getWorkflowLogs()])
.then(([workflowRes, logRes]) => {
setWorkspaceWorkflows(workflowRes.workflows);
setWorkflowLogs(logRes.logs);
})
.catch((error) =>
console.error("Failed to load workspace workflows", error),
console.error("Failed to load tasks page data", error),
);
}, []);

Expand Down Expand Up @@ -187,6 +210,27 @@ export const TasksPage: React.FC = () => {
}
};

const openLogTail = async (logFile: WorkflowLogSummary) => {
setLoadingLog(`${logFile.location}:${logFile.name}`);
try {
const result = await api.getWorkflowLogTail(logFile.location, logFile.name);
setLogModal({
open: true,
title: logFile.name,
content: result.tail || "-",
});
} catch (error) {
console.error("Failed to load workflow log tail", error);
setLogModal({
open: true,
title: logFile.name,
content: `Failed to read log file: ${error instanceof Error ? error.message : "Unknown error"}`,
});
} finally {
setLoadingLog(null);
}
};

return (
<div className="page">
<h1>Tasks</h1>
Expand Down Expand Up @@ -264,6 +308,42 @@ export const TasksPage: React.FC = () => {
</tbody>
</table>
)}

<h3>Available workflow logs</h3>
{workflowLogs.length === 0 ? (
<div className="empty">No workflow logs found.</div>
) : (
<table className="git-table">
<thead>
<tr>
<th>Filename</th>
<th>Location</th>
<th>Modified</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{workflowLogs.map((logFile) => (
<tr key={`${logFile.location}:${logFile.name}`}>
<td>{logFile.name}</td>
<td>{logFile.path}</td>
<td>{formatDateTime(logFile.modifiedAt)}</td>
<td>{formatBytes(logFile.sizeBytes)}</td>
<td>
<button
className="secondary"
onClick={() => void openLogTail(logFile)}
disabled={loadingLog === `${logFile.location}:${logFile.name}`}
>
{loadingLog === `${logFile.location}:${logFile.name}` ? "Loading..." : "View tail"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</Panel>

<div className="button-bar">
Expand Down Expand Up @@ -383,6 +463,14 @@ export const TasksPage: React.FC = () => {
</button>
</div>
</Modal>

<Modal
open={logModal.open}
title={logModal.title}
onClose={() => setLogModal({ open: false, title: "", content: "" })}
>
<pre className="workflow-log-tail">{logModal.content}</pre>
</Modal>
</div>
);
};
10 changes: 10 additions & 0 deletions packages/frontend/src/styles/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,13 @@
color: var(--text);
font-size: 0.82rem;
}

.workflow-log-tail {
margin: 0;
max-height: 24rem;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,18 @@ Example format:

Log destination preference:

1. `/var/log/<workflow>.log`
2. `/tmp/made-harness-logs/<workflow>.log`
1. `/var/log/made-<workflow-name>-<timestamp>-<PID>.log`
2. `/tmp/made-harness-logs/made-<workflow-name>-<timestamp>-<PID>.log`

Required filename format:

`made-[workflow-name]-[timestamp]-[PID].log`

Where:

• `workflow-name` is slug-safe (lowercase letters, digits, `-`)
• `timestamp` uses UTC `YYYYMMDDTHHMMSSZ`
• `PID` is shell `$$`

If `/var/log` cannot be written, automatically fallback.

Expand Down
28 changes: 28 additions & 0 deletions packages/pybackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
force_terminate_job,
get_cron_job_diagnostics,
get_cron_job_last_runs,
list_workflow_logs,
read_workflow_log_tail,
refresh_cron_clock,
start_cron_clock,
stop_cron_clock,
Expand Down Expand Up @@ -613,6 +615,32 @@ def workspace_workflows():
)


@app.get("/api/workflow-logs")
def workflow_logs():
try:
logger.info("Listing workflow log files")
return {"logs": list_workflow_logs()}
except Exception as exc:
logger.exception("Failed to list workflow log files")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)


@app.get("/api/workflow-logs/{location}/{log_name}")
def workflow_log_tail(location: str, log_name: str):
try:
logger.info("Reading workflow log tail for %s/%s", location, log_name)
return read_workflow_log_tail(location, log_name, max_lines=20)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
except Exception as exc:
logger.exception("Failed to read workflow log tail for %s/%s", location, log_name)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)
)


@app.post("/api/workflows/{workflow_id}/terminate")
def terminate_workflow(workflow_id: str):
"""Terminate a running workflow job."""
Expand Down
71 changes: 71 additions & 0 deletions packages/pybackend/cron_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@

DEFAULT_MAX_RUNTIME_MINUTES = 120 # 2 hours default
_workflow_max_runtime: dict[str, int] = {}
WORKFLOW_LOG_PREFIX = "made-"
WORKFLOW_LOG_LOCATIONS: dict[str, Path] = {
"var": Path("/var/log"),
"tmp": Path("/tmp/made-harness-logs"),
}


def _terminate_running_job_unlocked(workflow_id: str) -> None:
Expand Down Expand Up @@ -81,6 +86,72 @@ def _tail_output(value: str, max_lines: int = 20) -> str:
return "\n".join(lines[-max_lines:])


def _is_workflow_log_file(file_path: Path) -> bool:
return (
file_path.is_file()
and file_path.name.startswith(WORKFLOW_LOG_PREFIX)
and file_path.suffix == ".log"
)


def _validate_log_name(log_name: str) -> bool:
return (
bool(log_name)
and "/" not in log_name
and "\\" not in log_name
and log_name.startswith(WORKFLOW_LOG_PREFIX)
and log_name.endswith(".log")
)


def list_workflow_logs() -> list[dict[str, object]]:
log_files: list[tuple[float, dict[str, object]]] = []
for location, log_dir in WORKFLOW_LOG_LOCATIONS.items():
if not log_dir.exists() or not log_dir.is_dir():
continue
for file_path in log_dir.iterdir():
if not _is_workflow_log_file(file_path):
continue
stat = file_path.stat()
modified_at = datetime.fromtimestamp(stat.st_mtime, timezone.utc)
log_files.append(
(
stat.st_mtime,
{
"name": file_path.name,
"location": location,
"path": str(file_path),
"sizeBytes": stat.st_size,
"modifiedAt": modified_at.isoformat(),
},
)
)

return [entry for _, entry in sorted(log_files, key=lambda item: item[0], reverse=True)]


def read_workflow_log_tail(
location: str, log_name: str, max_lines: int = 20
) -> dict[str, object]:
log_dir = WORKFLOW_LOG_LOCATIONS.get(location)
if log_dir is None:
raise FileNotFoundError("Unknown log location")
if not _validate_log_name(log_name):
raise FileNotFoundError("Invalid log filename")

file_path = log_dir / log_name
if not file_path.exists() or not file_path.is_file():
raise FileNotFoundError("Workflow log file not found")

content = file_path.read_text(encoding="utf-8", errors="replace")
return {
"name": log_name,
"location": location,
"path": str(file_path),
"tail": _tail_output(content, max_lines=max_lines),
}


def _monitor_job_timeouts() -> None:
"""Periodically check for jobs exceeding runtime limits."""
current_time = datetime.now(timezone.utc)
Expand Down
37 changes: 37 additions & 0 deletions packages/pybackend/tests/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,43 @@ def test_workspace_workflows_success(self, mock_list, mock_last_runs, mock_diagn
{"sample:wf_1": {"lastExitCode": 0, "running": False}},
)

@patch("app.list_workflow_logs")
def test_workflow_logs_success(self, mock_list_logs):
mock_list_logs.return_value = [
{
"name": "made-nightly-20260325T120000Z-123.log",
"location": "tmp",
"path": "/tmp/made-harness-logs/made-nightly-20260325T120000Z-123.log",
"sizeBytes": 42,
"modifiedAt": "2026-03-25T12:00:00+00:00",
}
]

response = client.get("/api/workflow-logs")

assert response.status_code == 200
assert response.json()["logs"][0]["name"].startswith("made-")
mock_list_logs.assert_called_once_with()

@patch("app.read_workflow_log_tail")
def test_workflow_log_tail_success(self, mock_read_log_tail):
mock_read_log_tail.return_value = {
"name": "made-nightly-20260325T120000Z-123.log",
"location": "tmp",
"path": "/tmp/made-harness-logs/made-nightly-20260325T120000Z-123.log",
"tail": "line-1\nline-2",
}

response = client.get(
"/api/workflow-logs/tmp/made-nightly-20260325T120000Z-123.log"
)

assert response.status_code == 200
assert response.json()["tail"] == "line-1\nline-2"
mock_read_log_tail.assert_called_once_with(
"tmp", "made-nightly-20260325T120000Z-123.log", max_lines=20
)

@patch("app.read_workflows")
@patch("app._repository_path")
def test_repository_workflows_success(self, mock_repo_path, mock_read):
Expand Down
Loading