File size: 5,567 Bytes
a070805 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | 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"),
);
}
|