@saadnvd1/agent-os 0.3.503 → 0.3.506

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +67 -0
  2. package/app/api/projects/[id]/session-logs/route.ts +39 -0
  3. package/app/api/session-logs/[id]/route.ts +18 -0
  4. package/app/api/workspaces/[id]/session-logs/route.ts +35 -0
  5. package/components/Pane/DesktopTabBar.tsx +4 -1
  6. package/components/Pane/MobileTabBar.tsx +12 -1
  7. package/components/Pane/PaneViewToggle.tsx +14 -2
  8. package/components/Pane/index.tsx +28 -1
  9. package/components/PrStageBadge.tsx +15 -6
  10. package/components/Projects/ProjectSettingsDialog.tsx +11 -0
  11. package/components/SessionList/LiveLine.tsx +75 -0
  12. package/components/SessionList/RowParts.tsx +1 -7
  13. package/components/SessionList/RowShell.tsx +1 -1
  14. package/components/SessionList/RowSubtitle.tsx +4 -0
  15. package/components/SessionList/SessionRow.tsx +9 -3
  16. package/components/SessionLogs/LogDocument.tsx +29 -0
  17. package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
  18. package/components/Workspaces/HqFolderForm.tsx +71 -0
  19. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  20. package/contexts/PaneContext.tsx +1 -1
  21. package/data/session-logs/index.ts +110 -0
  22. package/lib/db/migrations.ts +24 -0
  23. package/lib/done/index.ts +2 -0
  24. package/lib/orchestrator/brief.ts +1 -0
  25. package/lib/orchestrator/logs.ts +81 -0
  26. package/lib/orchestrator/serve.ts +5 -0
  27. package/lib/orchestrator/tool-names.ts +1 -0
  28. package/lib/orchestrator/tool-schemas.ts +13 -0
  29. package/lib/orchestrator/tools.ts +1 -0
  30. package/lib/session-logs/build.ts +253 -0
  31. package/lib/session-logs/folder.ts +142 -0
  32. package/lib/session-logs/gather.ts +301 -0
  33. package/lib/session-logs/hq.ts +201 -0
  34. package/lib/session-logs/index.ts +193 -0
  35. package/lib/session-logs/paths.ts +37 -0
  36. package/lib/session-logs/reexport.ts +90 -0
  37. package/lib/session-logs/store.ts +105 -0
  38. package/lib/sidebar/live-status.ts +52 -0
  39. package/lib/tasks/finish.ts +3 -0
  40. package/lib/tasks/pr-stage.ts +14 -0
  41. package/lib/tasks/session.ts +4 -0
  42. package/package.json +1 -1
  43. package/scripts/backfill-session-logs.ts +101 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * A merged task's log as markdown, from data gathered by gather.ts. A fixed
3
+ * template and nothing else: no model is ever asked to write or summarise
4
+ * it. Text that came from a task, CI, a reviewer or a person is untrusted:
5
+ * long text goes in a fence it can't close, short text in a code span, so it
6
+ * can't add headings, links or sections to the document.
7
+ */
8
+
9
+ import { redact } from "../orchestrator/untrusted";
10
+
11
+ export interface LogEvent {
12
+ // SQLite UTC time ("YYYY-MM-DD HH:MM:SS"), or null when none was recorded.
13
+ at: string | null;
14
+ // AgentOS's own words for what happened.
15
+ text: string;
16
+ sha?: string | null;
17
+ // A short untrusted value that belongs in the sentence (a check's name).
18
+ quote?: string | null;
19
+ // Longer untrusted text (a verdict, an answer).
20
+ detail?: string | null;
21
+ }
22
+
23
+ export interface LogUsageRow {
24
+ role: string;
25
+ model: string;
26
+ turns: number;
27
+ inputTokens: number;
28
+ outputTokens: number;
29
+ cacheReadTokens: number;
30
+ cacheWriteTokens: number;
31
+ costUsd: number;
32
+ // The price table doesn't list the model: the cost is a floor.
33
+ unpriced: boolean;
34
+ }
35
+
36
+ export interface LogData {
37
+ id: string;
38
+ name: string;
39
+ workspace: string | null;
40
+ project: string | null;
41
+ branch: string | null;
42
+ prNumber: number | null;
43
+ prUrl: string | null;
44
+ mergeCommit: string | null;
45
+ startedAt: string | null;
46
+ mergedAt: string | null;
47
+ prompt: string;
48
+ scopeChanges: { at: string; text: string }[];
49
+ // null: the task wrote no plan. planKept false: it may have, and the file
50
+ // was gone before the log was written.
51
+ plan: string | null;
52
+ planKept: boolean;
53
+ timeline: LogEvent[];
54
+ usage: LogUsageRow[];
55
+ outcome: string | null;
56
+ // The session in the AgentOS UI, as a path.
57
+ sessionPath: string;
58
+ // Caps applied to long text, so a log stays readable.
59
+ verdictChars?: number;
60
+ }
61
+
62
+ export const VERDICT_CHARS = 1500;
63
+
64
+ const fence = (text: string): string => {
65
+ const runs = text.match(/`+/g) ?? [];
66
+ const longest = runs.reduce((n, r) => Math.max(n, r.length), 0);
67
+ const ticks = "`".repeat(Math.max(3, longest + 1));
68
+ return `${ticks}text\n${text}\n${ticks}`;
69
+ };
70
+
71
+ const clean = (text: string) => redact(text.replace(/\r\n?/g, "\n")).trim();
72
+
73
+ export const trim = (text: string, max: number): string =>
74
+ text.length > max ? `${text.slice(0, max).trimEnd()}\n… (trimmed)` : text;
75
+
76
+ const oneLine = (text: string) => clean(text).replace(/\s+/g, " ");
77
+
78
+ // Inline untrusted text as a code span, whatever it contains.
79
+ export function codeSpan(text: string): string {
80
+ const t = oneLine(text) || "(empty)";
81
+ const runs = t.match(/`+/g) ?? [];
82
+ const longest = runs.reduce((n, r) => Math.max(n, r.length), 0);
83
+ const ticks = "`".repeat(longest + 1);
84
+ const pad = t.startsWith("`") || t.endsWith("`") ? " " : "";
85
+ return `${ticks}${pad}${t}${pad}${ticks}`;
86
+ }
87
+
88
+ // Untrusted text in a heading or a table cell: no markup survives.
89
+ const plain = (text: string) =>
90
+ oneLine(text).replace(/[\\`*_{}[\]<>()#+!|~&]/g, (c) => `\\${c}`);
91
+
92
+ const indent = (block: string, by = 2) =>
93
+ block
94
+ .split("\n")
95
+ .map((l) => (l ? " ".repeat(by) + l : l))
96
+ .join("\n");
97
+
98
+ const when = (at: string | null) =>
99
+ at ? `${at.slice(0, 16).replace("T", " ")} UTC` : "—";
100
+
101
+ export function formatDuration(ms: number): string {
102
+ const min = Math.max(0, Math.round(ms / 60_000));
103
+ const d = Math.floor(min / 1440);
104
+ const h = Math.floor((min % 1440) / 60);
105
+ const m = min % 60;
106
+ return [d && `${d}d`, (d || h) && `${h}h`, `${m}m`].filter(Boolean).join(" ");
107
+ }
108
+
109
+ // SQLite's UTC "YYYY-MM-DD HH:MM:SS" to ms.
110
+ export const sqliteMs = (at: string | null | undefined): number | null => {
111
+ if (!at) return null;
112
+ const ms = Date.parse(
113
+ `${at.replace(" ", "T")}${at.includes("Z") ? "" : "Z"}`
114
+ );
115
+ return Number.isNaN(ms) ? null : ms;
116
+ };
117
+
118
+ const n = (x: number) => x.toLocaleString("en-US");
119
+ const usd = (x: number, floor: boolean) =>
120
+ `${floor ? "≥ " : ""}$${x.toFixed(x >= 1 ? 2 : 4)}`;
121
+
122
+ // A link only when it is a plain web address; anything else is shown as text.
123
+ const url = (u: string) =>
124
+ /^https?:\/\/[^\s<>()[\]`]+$/.test(u) ? `<${u}>` : codeSpan(u);
125
+
126
+ function header(d: LogData): string {
127
+ const start = sqliteMs(d.startedAt);
128
+ const end = sqliteMs(d.mergedAt);
129
+ const duration =
130
+ start !== null && end !== null ? formatDuration(end - start) : "unknown";
131
+ const rows: [string, string][] = [
132
+ ["Task", `${codeSpan(d.name)} (${codeSpan(d.id)})`],
133
+ ["Workspace", d.workspace ? codeSpan(d.workspace) : "none"],
134
+ ["Project", d.project ? codeSpan(d.project) : "none"],
135
+ ["Branch", d.branch ? codeSpan(d.branch) : "none"],
136
+ [
137
+ "Pull request",
138
+ d.prNumber
139
+ ? `#${d.prNumber}${d.prUrl ? ` ${url(d.prUrl)}` : ""}`
140
+ : "none recorded",
141
+ ],
142
+ ["Merge commit", d.mergeCommit ? codeSpan(d.mergeCommit) : "not recorded"],
143
+ ["Started", when(d.startedAt)],
144
+ ["Merged", when(d.mergedAt)],
145
+ ["Duration", duration],
146
+ ];
147
+ return rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n");
148
+ }
149
+
150
+ function brief(d: LogData): string {
151
+ const parts = [
152
+ "## Brief",
153
+ "",
154
+ "### Original prompt",
155
+ "",
156
+ fence(clean(d.prompt)),
157
+ ];
158
+ if (d.scopeChanges.length) {
159
+ parts.push("", "### Scope changes", "");
160
+ d.scopeChanges.forEach((c, i) => {
161
+ parts.push(
162
+ `${i + 1}. ${when(c.at)}`,
163
+ "",
164
+ indent(fence(clean(c.text))),
165
+ ""
166
+ );
167
+ });
168
+ parts.pop();
169
+ }
170
+ return parts.join("\n");
171
+ }
172
+
173
+ function plan(d: LogData): string {
174
+ const body =
175
+ d.plan !== null
176
+ ? fence(clean(d.plan))
177
+ : d.planKept
178
+ ? "This task wrote no plan."
179
+ : "Plan not kept: this log was written after the task's worktree was removed.";
180
+ return `## Plan\n\n${body}`;
181
+ }
182
+
183
+ function timeline(d: LogData): string {
184
+ const cap = d.verdictChars ?? VERDICT_CHARS;
185
+ if (!d.timeline.length) return "## Timeline\n\nNothing was recorded.";
186
+ const lines = d.timeline.map((e) => {
187
+ const sha = e.sha ? ` on ${codeSpan(e.sha.slice(0, 7))}` : "";
188
+ const quote = e.quote ? `: ${codeSpan(trim(e.quote, 300))}` : "";
189
+ const head = `- ${e.at ? `${when(e.at)} — ` : ""}${e.text}${sha}${quote}`;
190
+ return e.detail
191
+ ? `${head}\n\n${indent(fence(trim(clean(e.detail), cap)))}\n`
192
+ : head;
193
+ });
194
+ return `## Timeline\n\n${lines.join("\n").replace(/\n+$/, "")}`;
195
+ }
196
+
197
+ function usage(d: LogData): string {
198
+ if (!d.usage.length)
199
+ return "## Usage\n\nNo model usage was recorded for this task.";
200
+ const head = [
201
+ "| Role | Model | Turns | Input | Output | Cache read | Cache write | Cost |",
202
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
203
+ ];
204
+ const rows = d.usage.map(
205
+ (u) =>
206
+ `| ${plain(u.role)} | ${plain(u.model)} | ${n(u.turns)} | ${n(u.inputTokens)} | ${n(u.outputTokens)} | ${n(u.cacheReadTokens)} | ${n(u.cacheWriteTokens)} | ${usd(u.costUsd, u.unpriced)} |`
207
+ );
208
+ const sum = d.usage.reduce(
209
+ (t, u) => ({
210
+ turns: t.turns + u.turns,
211
+ i: t.i + u.inputTokens,
212
+ o: t.o + u.outputTokens,
213
+ cr: t.cr + u.cacheReadTokens,
214
+ cw: t.cw + u.cacheWriteTokens,
215
+ c: t.c + u.costUsd,
216
+ f: t.f || u.unpriced,
217
+ }),
218
+ { turns: 0, i: 0, o: 0, cr: 0, cw: 0, c: 0, f: false }
219
+ );
220
+ const total = `| **Total** | | ${n(sum.turns)} | ${n(sum.i)} | ${n(sum.o)} | ${n(sum.cr)} | ${n(sum.cw)} | ${usd(sum.c, sum.f)} |`;
221
+ return `## Usage\n\n${[...head, ...rows, total].join("\n")}\n\nCosts are estimates at list prices.`;
222
+ }
223
+
224
+ function outcome(d: LogData): string {
225
+ return `## Outcome\n\n${
226
+ d.outcome
227
+ ? fence(clean(d.outcome))
228
+ : "The task agent left no final message (a terminal task keeps none)."
229
+ }`;
230
+ }
231
+
232
+ function links(d: LogData): string {
233
+ const items = [
234
+ d.prUrl ? `- Pull request: ${url(d.prUrl)}` : null,
235
+ `- Session in AgentOS: ${codeSpan(d.sessionPath)}`,
236
+ ].filter(Boolean);
237
+ return `## Links\n\n${items.join("\n")}`;
238
+ }
239
+
240
+ export function buildLogMarkdown(d: LogData): string {
241
+ return (
242
+ [
243
+ `# Session log: ${plain(d.name)}`,
244
+ header(d),
245
+ brief(d),
246
+ plan(d),
247
+ timeline(d),
248
+ usage(d),
249
+ outcome(d),
250
+ links(d),
251
+ ].join("\n\n") + "\n"
252
+ );
253
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Where a log's markdown file goes, first match wins: the project's own
3
+ * folder, then the workspace's HQ repo (<hq>/docs/sessions/<project>/), then
4
+ * ~/.agent-os/session-logs/<workspace>/<project>/. All as
5
+ * YYYY-MM-DD-<task>.md. Never inside the project's repository: repositories
6
+ * can be public, and a log holds briefs and reviews.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+ import { db } from "../db";
13
+ import { slugify } from "../git";
14
+ import { getProject } from "../projects";
15
+ import { usableHq, type Visibility } from "./hq";
16
+ import { expand, repoPaths, resolved, within } from "./paths";
17
+
18
+ // AGENTOS_SESSION_LOGS_DIR moves the default (tests keep it off the real one).
19
+ export const DEFAULT_ROOT = () =>
20
+ process.env.AGENTOS_SESSION_LOGS_DIR ||
21
+ path.join(os.homedir(), ".agent-os", "session-logs");
22
+
23
+ const key = (projectId: string) => `session-logs.folder.${projectId}`;
24
+
25
+ // Throws a plain message when `dir` is inside one of the project's repos.
26
+ export function assertOutsideRepos(projectId: string, dir: string): void {
27
+ const target = resolved(dir);
28
+ const repo = repoPaths(projectId).find((r) => within(target, r));
29
+ if (repo)
30
+ throw new Error(
31
+ `${dir} is inside the project's repository (${repo}). Repositories can be public, so logs are never written into one: choose a folder outside it.`
32
+ );
33
+ }
34
+
35
+ export function logFolder(projectId: string): string | null {
36
+ const row = db
37
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
38
+ .get(key(projectId)) as { value: string } | undefined;
39
+ return row?.value || null;
40
+ }
41
+
42
+ // Throws on a value that isn't an absolute or ~ path, or that points into
43
+ // the project's repository. An empty value goes back to the default.
44
+ export function setLogFolder(projectId: string, value: string | null): void {
45
+ const folder = value?.trim() ?? "";
46
+ if (!folder) {
47
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(key(projectId));
48
+ return;
49
+ }
50
+ if (!getProject(projectId)) throw new Error("Project not found");
51
+ if (!(folder === "~" || folder.startsWith("~/") || path.isAbsolute(folder)))
52
+ throw new Error(
53
+ `"${folder}" isn't an absolute path. Use a full path or one starting with ~/.`
54
+ );
55
+ assertOutsideRepos(projectId, expand(folder));
56
+ db.prepare(
57
+ `INSERT INTO settings (key, value) VALUES (?, ?)
58
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
59
+ ).run(key(projectId), folder);
60
+ }
61
+
62
+ export interface FileTarget {
63
+ workspace: string | null;
64
+ workspaceId?: string | null;
65
+ project: string | null;
66
+ projectId: string | null;
67
+ name: string;
68
+ id: string;
69
+ // YYYY-MM-DD
70
+ day: string;
71
+ }
72
+
73
+ export interface Destination {
74
+ dir: string;
75
+ // The HQ repo the folder belongs to, when the log goes into one.
76
+ hq: { dir: string; visibility: Visibility } | null;
77
+ }
78
+
79
+ // The folder this task's log goes into: the project's override when it
80
+ // still passes the repository check, else the workspace's HQ, else the
81
+ // default.
82
+ export function destination(t: FileTarget): Destination {
83
+ const projectSlug = slugify(t.project ?? "") || "no-project";
84
+ const custom = t.projectId ? logFolder(t.projectId) : null;
85
+ if (custom && t.projectId) {
86
+ try {
87
+ const dir = expand(custom);
88
+ assertOutsideRepos(t.projectId, dir);
89
+ return { dir, hq: null };
90
+ } catch (error) {
91
+ console.error(
92
+ `[session-logs] ignoring the project's log folder: ${error instanceof Error ? error.message : error}`
93
+ );
94
+ }
95
+ }
96
+ const workspaceId =
97
+ t.workspaceId ??
98
+ (t.projectId ? getProject(t.projectId)?.workspace_id : null) ??
99
+ null;
100
+ const hq = workspaceId ? usableHq(workspaceId) : null;
101
+ if (hq)
102
+ return { dir: path.join(hq.dir, "docs", "sessions", projectSlug), hq };
103
+ return {
104
+ dir: path.join(
105
+ DEFAULT_ROOT(),
106
+ slugify(t.workspace ?? "") || "no-workspace",
107
+ projectSlug
108
+ ),
109
+ hq: null,
110
+ };
111
+ }
112
+
113
+ export const targetDir = (t: FileTarget): string => destination(t).dir;
114
+
115
+ // Writes the log once and returns its path. A file that's already there
116
+ // (another task, same name, same day) is never overwritten.
117
+ export function writeLogFileTo(
118
+ t: FileTarget,
119
+ markdown: string
120
+ ): { file: string; hq: Destination["hq"] } {
121
+ const { dir, hq } = destination(t);
122
+ if (t.projectId && !logFolder(t.projectId) && !hq)
123
+ assertOutsideRepos(t.projectId, dir);
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ const slug = slugify(t.name) || t.id.slice(0, 8);
126
+ for (const file of [
127
+ `${t.day}-${slug}.md`,
128
+ `${t.day}-${slug}-${t.id.slice(0, 8)}.md`,
129
+ ]) {
130
+ const full = path.join(dir, file);
131
+ try {
132
+ fs.writeFileSync(full, markdown, { flag: "wx", mode: 0o600 });
133
+ return { file: full, hq };
134
+ } catch (error) {
135
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
136
+ }
137
+ }
138
+ throw new Error(`a log file for ${t.name} already exists in ${dir}`);
139
+ }
140
+
141
+ export const writeLogFile = (t: FileTarget, markdown: string): string =>
142
+ writeLogFileTo(t, markdown).file;
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Everything a merged task's log says, read from what AgentOS already
3
+ * stores: the session row, the brief and its amendments, the independent
4
+ * review and CI records, the asks raised about it, the usage ledger and the
5
+ * task agent's last message. Nothing here is computed again or sent to a
6
+ * model.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+ import { db, type Session } from "../db";
13
+ import { itemsOfKind } from "../chat/store";
14
+ import { effectiveModels } from "../models/config";
15
+ import { ANTHROPIC } from "../models/roles";
16
+ import { getProject } from "../projects";
17
+ import { getWorkspace } from "../workspaces";
18
+ import { amendmentsOf } from "../orchestrator/amendments";
19
+ import { PLAN_FILE } from "../models/plan-step";
20
+ import { estimateCost, rowModel } from "../usage/cost";
21
+ import { sessionLink } from "../session-url";
22
+ import type { LogData, LogEvent, LogUsageRow } from "./build";
23
+
24
+ const expandHome = (p: string) => p.replace(/^~/, os.homedir());
25
+
26
+ // The task's plan file, read while its worktree still exists. null: none.
27
+ const MAX_PLAN_BYTES = 256 * 1024;
28
+ export function readPlanFile(worktreePath: string | null): string | null {
29
+ if (!worktreePath) return null;
30
+ try {
31
+ const file = path.join(expandHome(worktreePath), PLAN_FILE);
32
+ const stat = fs.statSync(file);
33
+ if (!stat.isFile() || stat.size > MAX_PLAN_BYTES) return null;
34
+ const text = fs.readFileSync(file, "utf8");
35
+ return text.trim() ? text : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ interface CheckRow {
42
+ sha: string;
43
+ kind: string;
44
+ status: string;
45
+ detail: string | null;
46
+ created_at: string;
47
+ }
48
+
49
+ const REVIEW_WORD: Record<string, string> = {
50
+ pass: "Review passed",
51
+ block: "Review blocked the merge",
52
+ error: "Review could not run",
53
+ running: "Review started",
54
+ };
55
+ const SCOPE_WORD: Record<string, string> = {
56
+ pass: "Scope check passed",
57
+ block: "Scope check blocked the merge",
58
+ error: "Scope check could not run",
59
+ running: "Scope check started",
60
+ };
61
+
62
+ function checkEvents(id: string, merged: string | null): LogEvent[] {
63
+ const rows = db
64
+ .prepare(
65
+ `SELECT sha, kind, status, detail, created_at FROM orchestrator_checks
66
+ WHERE session_id = ? ORDER BY created_at, id`
67
+ )
68
+ .all(id) as CheckRow[];
69
+ return rows.flatMap((c): LogEvent[] => {
70
+ if (c.kind === "ci")
71
+ // The settle record exists for every head that had checks; only the
72
+ // head that merged is known to have been green.
73
+ return c.sha === merged
74
+ ? [{ at: c.created_at, text: "CI green", sha: c.sha }]
75
+ : [];
76
+ const word = (c.kind === "review" ? REVIEW_WORD : SCOPE_WORD)[c.status];
77
+ if (!word) return [];
78
+ return [
79
+ {
80
+ at: c.created_at,
81
+ text: word,
82
+ sha: c.sha,
83
+ detail: c.status === "running" ? null : c.detail,
84
+ },
85
+ ];
86
+ });
87
+ }
88
+
89
+ const GATE_WORD: Record<string, string> = {
90
+ ci: "CI failed",
91
+ review: "Review gate failed",
92
+ "code-review": "Code-review gate failed",
93
+ scope: "Scope gate failed",
94
+ blocked: "Blocked gate failed",
95
+ stack: "Stack gate failed",
96
+ };
97
+
98
+ // The failure text names its failing check inside an untrusted fence;
99
+ // the fence tags carry no information here.
100
+ const unfence = (s: string) => s.replace(/<\/?untrusted[^>]*>/g, "");
101
+
102
+ function failureEvents(id: string): LogEvent[] {
103
+ const rows = db
104
+ .prepare(
105
+ `SELECT gate, sha, reason, created_at FROM orchestrator_gate_failure_commits
106
+ WHERE session_id = ? ORDER BY created_at`
107
+ )
108
+ .all(id) as {
109
+ gate: string;
110
+ sha: string;
111
+ reason: string | null;
112
+ created_at: string;
113
+ }[];
114
+ return rows.map((r) => {
115
+ const conflict = /conflict/i.test(r.reason ?? "");
116
+ const failing =
117
+ r.gate === "ci" ? /\(([^)]+)\)\s*$/.exec(unfence(r.reason ?? "")) : null;
118
+ return {
119
+ at: r.created_at,
120
+ text: conflict
121
+ ? "Merge conflict held the gates"
122
+ : (GATE_WORD[r.gate] ?? `Gate ${r.gate} failed`),
123
+ sha: r.sha,
124
+ quote: failing ? `failing check: ${failing[1]}` : null,
125
+ detail: failing || !r.reason ? null : unfence(r.reason),
126
+ };
127
+ });
128
+ }
129
+
130
+ function askEvents(id: string): LogEvent[] {
131
+ const rows = db
132
+ .prepare(
133
+ `SELECT kind, title, detail, status, answer, created_at, resolved_at, sha
134
+ FROM orchestrator_asks WHERE subject = ? ORDER BY id`
135
+ )
136
+ .all(`task:${id}`) as {
137
+ kind: string;
138
+ title: string;
139
+ detail: string;
140
+ status: string;
141
+ answer: string | null;
142
+ created_at: string;
143
+ resolved_at: string | null;
144
+ sha: string | null;
145
+ }[];
146
+ return rows.flatMap((a): LogEvent[] => {
147
+ const raised: LogEvent = {
148
+ at: a.created_at,
149
+ text: `Escalated to Saad (${a.kind})`,
150
+ sha: a.sha,
151
+ quote: a.title,
152
+ detail: a.detail || null,
153
+ };
154
+ if (!a.resolved_at) return [raised];
155
+ return [
156
+ raised,
157
+ {
158
+ at: a.resolved_at,
159
+ text: `Saad ${a.status === "approved" ? "approved" : a.status === "declined" ? "declined" : "answered"}`,
160
+ sha: a.sha,
161
+ quote: a.title,
162
+ detail: a.answer,
163
+ },
164
+ ];
165
+ });
166
+ }
167
+
168
+ function rebaseEvents(s: Session): LogEvent[] {
169
+ if (!s.rebased_from || !s.rebased_to) return [];
170
+ return [
171
+ {
172
+ at: null,
173
+ text: `Last automatic rebase moved the branch from ${s.rebased_from.slice(0, 7)} to ${s.rebased_to.slice(0, 7)}`,
174
+ },
175
+ ];
176
+ }
177
+
178
+ export function usageRows(id: string): LogUsageRow[] {
179
+ const calls = db
180
+ .prepare(
181
+ `SELECT role, model, provider, COUNT(*) AS turns,
182
+ SUM(input_tokens) AS input_tokens,
183
+ SUM(output_tokens) AS output_tokens,
184
+ SUM(cache_read_tokens) AS cache_read_tokens,
185
+ SUM(cache_write_5m_tokens + cache_write_1h_tokens) AS cache_write_tokens
186
+ FROM usage_calls WHERE task_id = ? GROUP BY role, model, provider`
187
+ )
188
+ .all(id) as {
189
+ role: string | null;
190
+ model: string | null;
191
+ provider: string | null;
192
+ turns: number;
193
+ input_tokens: number;
194
+ output_tokens: number;
195
+ cache_read_tokens: number;
196
+ cache_write_tokens: number;
197
+ }[];
198
+ const { prices } = effectiveModels();
199
+ const label = (role: string | null) =>
200
+ role?.startsWith("gate-") ? role.slice(5) : (role ?? "unrecorded");
201
+ const rows = calls.map((c): LogUsageRow => {
202
+ const priced = {
203
+ provider: c.provider ?? ANTHROPIC,
204
+ model: c.model,
205
+ cost_usd: 0,
206
+ input_tokens: c.input_tokens,
207
+ output_tokens: c.output_tokens,
208
+ cache_read_tokens: c.cache_read_tokens,
209
+ cache_write_tokens: c.cache_write_tokens,
210
+ };
211
+ const cost = estimateCost(priced, prices);
212
+ const m = rowModel(priced);
213
+ return {
214
+ role: label(c.role),
215
+ model: `${m.provider}/${m.model}`,
216
+ turns: c.turns,
217
+ inputTokens: c.input_tokens,
218
+ outputTokens: c.output_tokens,
219
+ cacheReadTokens: c.cache_read_tokens,
220
+ cacheWriteTokens: c.cache_write_tokens,
221
+ costUsd: cost.usd,
222
+ unpriced: !cost.priced,
223
+ };
224
+ });
225
+ const order = ["plan", "implement", "review", "scope"];
226
+ const rank = (r: string) =>
227
+ order.indexOf(r) === -1 ? order.length : order.indexOf(r);
228
+ return rows.sort(
229
+ (a, b) => rank(a.role) - rank(b.role) || b.costUsd - a.costUsd
230
+ );
231
+ }
232
+
233
+ // The last thing the task's agent said, verbatim. A terminal task keeps no
234
+ // conversation, so it has none.
235
+ export function finalMessage(id: string): string | null {
236
+ const said = itemsOfKind(id, "assistant").filter((i) => i.text.trim());
237
+ return said.at(-1)?.text ?? null;
238
+ }
239
+
240
+ export interface GatherOpts {
241
+ // The plan file's text when it was read before the worktree went; undefined
242
+ // when nobody could (the backfill), which a log says.
243
+ plan?: string | null;
244
+ planKept?: boolean;
245
+ // The PR's head when it merged, and the commit the merge made.
246
+ head?: string | null;
247
+ mergeCommit?: string | null;
248
+ }
249
+
250
+ export function gatherLogData(s: Session, opts: GatherOpts = {}): LogData {
251
+ const project = s.project_id ? getProject(s.project_id) : undefined;
252
+ const workspaceId = s.workspace_id ?? project?.workspace_id ?? null;
253
+ const workspace = workspaceId ? getWorkspace(workspaceId) : null;
254
+ const amendments = amendmentsOf(s.id);
255
+ const mergedAt = s.merged_at ?? null;
256
+ const head = opts.head ?? null;
257
+ const events: LogEvent[] = [
258
+ ...(s.pr_number
259
+ ? [{ at: null, text: `PR #${s.pr_number} opened` } as LogEvent]
260
+ : []),
261
+ ...failureEvents(s.id),
262
+ ...checkEvents(s.id, head),
263
+ ...askEvents(s.id),
264
+ ];
265
+ // Dated events in time order (a stable sort keeps same-second ones as
266
+ // recorded); the undated ones bracket them: the PR opening first, the
267
+ // rebase before the merge.
268
+ const dated = events
269
+ .filter((e) => e.at)
270
+ .sort((a, b) => (a.at as string).localeCompare(b.at as string));
271
+ const opened = events.filter((e) => !e.at);
272
+ const timeline = [
273
+ ...opened,
274
+ ...dated,
275
+ ...rebaseEvents(s),
276
+ {
277
+ at: mergedAt,
278
+ text: `PR${s.pr_number ? ` #${s.pr_number}` : ""} merged`,
279
+ },
280
+ ];
281
+ return {
282
+ id: s.id,
283
+ name: s.name,
284
+ workspace: workspace?.name ?? null,
285
+ project: project?.name ?? null,
286
+ branch: s.branch_name,
287
+ prNumber: s.pr_number,
288
+ prUrl: s.pr_url,
289
+ mergeCommit: opts.mergeCommit ?? null,
290
+ startedAt: s.created_at,
291
+ mergedAt,
292
+ prompt: s.task_prompt ?? "",
293
+ scopeChanges: amendments.map((a) => ({ at: a.created_at, text: a.text })),
294
+ plan: opts.plan ?? null,
295
+ planKept: opts.planKept ?? true,
296
+ timeline,
297
+ usage: usageRows(s.id),
298
+ outcome: finalMessage(s.id),
299
+ sessionPath: sessionLink("", s.id),
300
+ };
301
+ }