openhands / src /utils /automation-activity-log-export.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
a070805 verified
Raw History Blame Contribute Delete
5.57 kB
import type {
ActivityLogExportFormat,
Automation,
AutomationRun,
AutomationRunExportRow,
} from "#/types/automation";
import { downloadBlob } from "#/utils/utils";
import AutomationService from "#/api/automation-service/automation-service.api";
const EXPORT_PAGE_SIZE = 100;
const CSV_COLUMNS = [
"run_id",
"automation_id",
"automation_name",
"trigger",
"start_time",
"end_time",
"duration_seconds",
"status",
"conversation_id",
"conversation_url",
"error",
// Appended rather than grouped with the other run metrics so existing
// consumers that read the CSV by column position keep working.
"cost",
"phase",
] as const;
export function getActivityLogExportFilename(
automation: Pick<Automation, "id" | "name">,
format: ActivityLogExportFormat,
): string {
const slug = automation.name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return `${slug || automation.id}.activity-log.${format}`;
}
function csvEscape(value: string): string {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
export function serializeActivityLogRowsCsv(
rows: AutomationRunExportRow[],
): string {
const lines: string[] = [CSV_COLUMNS.join(",")];
for (const row of rows) {
const values = CSV_COLUMNS.map((key) => {
const raw = row[key];
if (raw == null) return "";
if (key === "trigger") {
return csvEscape(JSON.stringify(raw));
}
return csvEscape(String(raw));
});
lines.push(values.join(","));
}
return `${lines.join("\n")}\n`;
}
export function buildConversationUrl(
conversationId: string | null,
conversationBaseUrl?: string,
): string | null {
if (!conversationId) return null;
const path = `/conversations/${conversationId}`;
if (!conversationBaseUrl) return path;
return `${conversationBaseUrl.replace(/\/$/, "")}${path}`;
}
export function mapAutomationRunToExportRow(
run: AutomationRun,
automation: Pick<Automation, "id" | "name" | "trigger">,
conversationBaseUrl?: string,
): AutomationRunExportRow {
const start = run.started_at || null;
const end = run.completed_at;
let duration_seconds: number | null = null;
if (start && end) {
duration_seconds =
(new Date(end).getTime() - new Date(start).getTime()) / 1000;
}
return {
run_id: run.id,
automation_id: automation.id,
automation_name: automation.name,
trigger: automation.trigger,
start_time: start,
end_time: end,
duration_seconds,
status: run.status,
conversation_id: run.conversation_id,
conversation_url: buildConversationUrl(
run.conversation_id,
conversationBaseUrl,
),
error: run.error_detail,
// Exports carry the raw number and leave formatting to the consumer; the
// Activity Log row is the only place that renders it as USD.
cost: run.cost ?? null,
// Deliberately not filtered through `shouldShowRunPhase`: that predicate
// decides what is worth a user's attention on screen, and hides the phase
// of a run whose status already says everything. An export is a record,
// not a screen — a completed run's last phase is real data, and dropping
// it here would make the phase of a run that finished indistinguishable
// from one that never reported a phase at all.
//
// Falling back to the label: without it, a phase reported with only a
// label would export as an empty cell, indistinguishable from no phase.
// Trimmed rather than nullish-coalesced: the service stores a
// whitespace-only field as sent — it rejects only a phase blank on
// *both* — so `??` would let a blank code suppress a real label, which
// is exactly the empty cell this fallback exists to prevent.
phase: run.phase_code?.trim() || run.phase_label?.trim() || null,
};
}
/**
* Page ``GET /v1/{id}/runs`` until complete, then project export rows locally.
*/
export async function fetchAllActivityLogExportRows(
automation: Pick<Automation, "id" | "name" | "trigger">,
conversationBaseUrl?: string,
): Promise<AutomationRunExportRow[]> {
const rows: AutomationRunExportRow[] = [];
let offset = 0;
let total = Number.POSITIVE_INFINITY;
while (offset < total) {
const page = await AutomationService.listAutomationRuns(automation.id, {
limit: EXPORT_PAGE_SIZE,
offset,
});
for (const run of page.runs) {
rows.push(
mapAutomationRunToExportRow(run, automation, conversationBaseUrl),
);
}
total = page.total;
offset += page.runs.length;
if (page.runs.length === 0) break;
}
return rows;
}
/**
* Page the runs list endpoint and download one CSV or JSON file.
*/
export async function downloadActivityLogExport(options: {
automation: Pick<Automation, "id" | "name" | "trigger">;
format: ActivityLogExportFormat;
conversationBaseUrl?: string;
}): Promise<void> {
const { automation, format, conversationBaseUrl } = options;
const rows = await fetchAllActivityLogExportRows(
automation,
conversationBaseUrl,
);
if (format === "json") {
downloadBlob(
new Blob(
[`${JSON.stringify({ runs: rows, total: rows.length }, null, 2)}\n`],
{ type: "application/json" },
),
getActivityLogExportFilename(automation, "json"),
);
return;
}
downloadBlob(
new Blob([serializeActivityLogRowsCsv(rows)], {
type: "text/csv;charset=utf-8",
}),
getActivityLogExportFilename(automation, "csv"),
);
}