@saadnvd1/agent-os 0.3.503 → 0.3.505

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 (37) hide show
  1. package/README.md +47 -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/components/Pane/DesktopTabBar.tsx +4 -1
  5. package/components/Pane/MobileTabBar.tsx +12 -1
  6. package/components/Pane/PaneViewToggle.tsx +14 -2
  7. package/components/Pane/index.tsx +28 -1
  8. package/components/PrStageBadge.tsx +15 -6
  9. package/components/Projects/ProjectSettingsDialog.tsx +11 -0
  10. package/components/SessionList/LiveLine.tsx +75 -0
  11. package/components/SessionList/RowParts.tsx +1 -7
  12. package/components/SessionList/RowShell.tsx +1 -1
  13. package/components/SessionList/RowSubtitle.tsx +4 -0
  14. package/components/SessionList/SessionRow.tsx +9 -3
  15. package/components/SessionLogs/LogDocument.tsx +29 -0
  16. package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
  17. package/contexts/PaneContext.tsx +1 -1
  18. package/data/session-logs/index.ts +73 -0
  19. package/lib/db/migrations.ts +24 -0
  20. package/lib/done/index.ts +2 -0
  21. package/lib/orchestrator/brief.ts +1 -0
  22. package/lib/orchestrator/logs.ts +81 -0
  23. package/lib/orchestrator/serve.ts +5 -0
  24. package/lib/orchestrator/tool-names.ts +1 -0
  25. package/lib/orchestrator/tool-schemas.ts +13 -0
  26. package/lib/orchestrator/tools.ts +1 -0
  27. package/lib/session-logs/build.ts +253 -0
  28. package/lib/session-logs/folder.ts +145 -0
  29. package/lib/session-logs/gather.ts +301 -0
  30. package/lib/session-logs/index.ts +178 -0
  31. package/lib/session-logs/store.ts +105 -0
  32. package/lib/sidebar/live-status.ts +52 -0
  33. package/lib/tasks/finish.ts +3 -0
  34. package/lib/tasks/pr-stage.ts +14 -0
  35. package/lib/tasks/session.ts +4 -0
  36. package/package.json +1 -1
  37. package/scripts/backfill-session-logs.ts +70 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Where a log's markdown file goes. By default
3
+ * ~/.agent-os/session-logs/<workspace>/<project>/YYYY-MM-DD-<task>.md; a
4
+ * project can name its own folder. Never inside the project's repository:
5
+ * repositories can be public, and a log holds briefs and reviews.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { db } from "../db";
12
+ import { slugify } from "../git";
13
+ import { getProject, getProjectRepositories } from "../projects";
14
+
15
+ // AGENTOS_SESSION_LOGS_DIR moves the default (tests keep it off the real one).
16
+ export const DEFAULT_ROOT = () =>
17
+ process.env.AGENTOS_SESSION_LOGS_DIR ||
18
+ path.join(os.homedir(), ".agent-os", "session-logs");
19
+
20
+ const key = (projectId: string) => `session-logs.folder.${projectId}`;
21
+
22
+ const expand = (p: string) =>
23
+ p === "~" ? os.homedir() : p.replace(/^~(?=\/)/, os.homedir());
24
+
25
+ // The path with symlinks resolved as far as it exists, so a link into a
26
+ // repository is seen for what it is.
27
+ function resolved(p: string): string {
28
+ let cur = path.resolve(p);
29
+ const rest: string[] = [];
30
+ while (!fs.existsSync(cur)) {
31
+ const up = path.dirname(cur);
32
+ if (up === cur) break;
33
+ rest.unshift(path.basename(cur));
34
+ cur = up;
35
+ }
36
+ return path.join(fs.realpathSync(cur), ...rest);
37
+ }
38
+
39
+ const within = (child: string, parent: string) => {
40
+ const rel = path.relative(parent, child);
41
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
42
+ };
43
+
44
+ function repoPaths(projectId: string): string[] {
45
+ const project = getProject(projectId);
46
+ if (!project) return [];
47
+ return [
48
+ project.working_directory,
49
+ ...getProjectRepositories(projectId).map((r) => r.path),
50
+ ]
51
+ .filter(Boolean)
52
+ .map((p) => resolved(expand(p)));
53
+ }
54
+
55
+ // Throws a plain message when `dir` is inside one of the project's repos.
56
+ export function assertOutsideRepos(projectId: string, dir: string): void {
57
+ const target = resolved(dir);
58
+ const repo = repoPaths(projectId).find((r) => within(target, r));
59
+ if (repo)
60
+ throw new Error(
61
+ `${dir} is inside the project's repository (${repo}). Repositories can be public, so logs are never written into one: choose a folder outside it.`
62
+ );
63
+ }
64
+
65
+ export function logFolder(projectId: string): string | null {
66
+ const row = db
67
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
68
+ .get(key(projectId)) as { value: string } | undefined;
69
+ return row?.value || null;
70
+ }
71
+
72
+ // Throws on a value that isn't an absolute or ~ path, or that points into
73
+ // the project's repository. An empty value goes back to the default.
74
+ export function setLogFolder(projectId: string, value: string | null): void {
75
+ const folder = value?.trim() ?? "";
76
+ if (!folder) {
77
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(key(projectId));
78
+ return;
79
+ }
80
+ if (!getProject(projectId)) throw new Error("Project not found");
81
+ if (!(folder === "~" || folder.startsWith("~/") || path.isAbsolute(folder)))
82
+ throw new Error(
83
+ `"${folder}" isn't an absolute path. Use a full path or one starting with ~/.`
84
+ );
85
+ assertOutsideRepos(projectId, expand(folder));
86
+ db.prepare(
87
+ `INSERT INTO settings (key, value) VALUES (?, ?)
88
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
89
+ ).run(key(projectId), folder);
90
+ }
91
+
92
+ export interface FileTarget {
93
+ workspace: string | null;
94
+ project: string | null;
95
+ projectId: string | null;
96
+ name: string;
97
+ id: string;
98
+ // YYYY-MM-DD
99
+ day: string;
100
+ }
101
+
102
+ // The folder this task's log goes into: the project's override when it
103
+ // still passes the repository check, else the default.
104
+ export function targetDir(t: FileTarget): string {
105
+ const custom = t.projectId ? logFolder(t.projectId) : null;
106
+ if (custom && t.projectId) {
107
+ try {
108
+ const dir = expand(custom);
109
+ assertOutsideRepos(t.projectId, dir);
110
+ return dir;
111
+ } catch (error) {
112
+ console.error(
113
+ `[session-logs] ignoring the project's log folder: ${error instanceof Error ? error.message : error}`
114
+ );
115
+ }
116
+ }
117
+ return path.join(
118
+ DEFAULT_ROOT(),
119
+ slugify(t.workspace ?? "") || "no-workspace",
120
+ slugify(t.project ?? "") || "no-project"
121
+ );
122
+ }
123
+
124
+ // Writes the log once and returns its path. A file that's already there
125
+ // (another task, same name, same day) is never overwritten.
126
+ export function writeLogFile(t: FileTarget, markdown: string): string {
127
+ const dir = targetDir(t);
128
+ if (t.projectId && !logFolder(t.projectId))
129
+ assertOutsideRepos(t.projectId, dir);
130
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ const slug = slugify(t.name) || t.id.slice(0, 8);
132
+ for (const file of [
133
+ `${t.day}-${slug}.md`,
134
+ `${t.day}-${slug}-${t.id.slice(0, 8)}.md`,
135
+ ]) {
136
+ const full = path.join(dir, file);
137
+ try {
138
+ fs.writeFileSync(full, markdown, { flag: "wx", mode: 0o600 });
139
+ return full;
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
142
+ }
143
+ }
144
+ throw new Error(`a log file for ${t.name} already exists in ${dir}`);
145
+ }
@@ -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
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Session logs: when a task merges, AgentOS writes a log of it from data it
3
+ * already holds. It costs no model tokens, on purpose: nothing in this
4
+ * folder may call a model (a test holds it to that).
5
+ *
6
+ * captureMergedTask is called from every place a task becomes merged
7
+ * (sign-off, done, a merge seen on GitHub). It reads the plan file at once,
8
+ * because cleanup removes the worktree, then does the rest in the
9
+ * background. It runs once per task and never throws into the merge.
10
+ */
11
+
12
+ import os from "os";
13
+ import { db, type Session } from "../db";
14
+ import { getProject } from "../projects";
15
+ import { run } from "../tasks/gh";
16
+ import { buildLogMarkdown, sqliteMs } from "./build";
17
+ import { writeLogFile } from "./folder";
18
+ import { gatherLogData, readPlanFile } from "./gather";
19
+ import { hasLog, insertLog, setFilePath } from "./store";
20
+
21
+ export * from "./store";
22
+ export { buildLogMarkdown } from "./build";
23
+ export { logFolder, setLogFolder } from "./folder";
24
+
25
+ const inFlight = new Map<string, Promise<void>>();
26
+ // Tasks known to have a log, so a poll that sees a merged PR again asks
27
+ // nothing of the database.
28
+ const done = new Set<string>();
29
+
30
+ const sqliteNow = () => new Date().toISOString().replace("T", " ").slice(0, 19);
31
+
32
+ export interface CaptureOpts {
33
+ // The PR's head at the merge, when the caller knows it.
34
+ head?: string | null;
35
+ // The plan, when it was read already; else the worktree is read now.
36
+ plan?: string | null;
37
+ // A log written after the worktree went (the backfill).
38
+ planKept?: boolean;
39
+ // Skip asking GitHub for the merge commit (a batch of old tasks).
40
+ noGh?: boolean;
41
+ }
42
+
43
+ function eligible(s: Session | undefined): s is Session {
44
+ return (
45
+ !!s &&
46
+ !!s.task_prompt &&
47
+ !s.peer_mirror &&
48
+ s.task_status !== "dropped" &&
49
+ (s.task_status === "merged" || s.pr_status === "merged")
50
+ );
51
+ }
52
+
53
+ async function mergeCommitOf(s: Session): Promise<string | null> {
54
+ const project = s.project_id ? getProject(s.project_id) : undefined;
55
+ if (!project || !s.pr_number || process.env.AGENTOS_SESSION_LOGS_NO_GH)
56
+ return null;
57
+ try {
58
+ const out = await run(
59
+ "gh",
60
+ [
61
+ "pr",
62
+ "view",
63
+ String(s.pr_number),
64
+ "--json",
65
+ "mergeCommit",
66
+ "-q",
67
+ ".mergeCommit.oid",
68
+ ],
69
+ project.working_directory.replace(/^~/, os.homedir()),
70
+ 15_000
71
+ );
72
+ return out.trim() || null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export async function writeLog(
79
+ id: string,
80
+ opts: CaptureOpts & { plan?: string | null } = {}
81
+ ): Promise<boolean> {
82
+ const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
83
+ | Session
84
+ | undefined;
85
+ if (!eligible(row) || hasLog(id)) return false;
86
+ const s = { ...row, merged_at: row.merged_at ?? sqliteNow() };
87
+ const mergeCommit = opts.noGh ? null : await mergeCommitOf(s);
88
+ const data = gatherLogData(s, {
89
+ plan: opts.plan ?? null,
90
+ planKept: opts.planKept ?? true,
91
+ head: opts.head ?? null,
92
+ mergeCommit: mergeCommit ?? (opts.head ? `${opts.head} (PR head)` : null),
93
+ });
94
+ const markdown = buildLogMarkdown(data);
95
+ const start = sqliteMs(data.startedAt);
96
+ const end = sqliteMs(data.mergedAt);
97
+ const stored = insertLog({
98
+ sessionId: id,
99
+ projectId: s.project_id,
100
+ workspaceId:
101
+ s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null,
102
+ markdown,
103
+ header: {
104
+ id,
105
+ name: data.name,
106
+ workspace: data.workspace,
107
+ project: data.project,
108
+ branch: data.branch,
109
+ prNumber: data.prNumber,
110
+ prUrl: data.prUrl,
111
+ mergeCommit: data.mergeCommit,
112
+ startedAt: data.startedAt,
113
+ mergedAt: data.mergedAt,
114
+ durationMs: start !== null && end !== null ? end - start : null,
115
+ costUsd: data.usage.reduce((t, u) => t + u.costUsd, 0),
116
+ planKept: data.planKept,
117
+ hasPlan: data.plan !== null,
118
+ },
119
+ filePath: null,
120
+ });
121
+ if (!stored) return false;
122
+ try {
123
+ const file = writeLogFile(
124
+ {
125
+ workspace: data.workspace,
126
+ project: data.project,
127
+ projectId: s.project_id,
128
+ name: data.name,
129
+ id,
130
+ day: (data.mergedAt ?? sqliteNow()).slice(0, 10),
131
+ },
132
+ markdown
133
+ );
134
+ setFilePath(id, file);
135
+ } catch (error) {
136
+ // The database holds the log; the file is a copy.
137
+ console.error(`[session-logs] file for ${id}:`, error);
138
+ }
139
+ return true;
140
+ }
141
+
142
+ export function captureMergedTask(
143
+ id: string,
144
+ opts: CaptureOpts = {}
145
+ ): Promise<void> {
146
+ if (done.has(id)) return Promise.resolve();
147
+ const running = inFlight.get(id);
148
+ if (running) return running;
149
+ let plan: string | null | undefined = opts.plan;
150
+ try {
151
+ if (plan === undefined) {
152
+ const row = db
153
+ .prepare(`SELECT worktree_path FROM sessions WHERE id = ?`)
154
+ .get(id) as { worktree_path: string | null } | undefined;
155
+ plan = readPlanFile(row?.worktree_path ?? null);
156
+ }
157
+ } catch (error) {
158
+ console.error(`[session-logs] plan for ${id}:`, error);
159
+ }
160
+ const job = (async () => {
161
+ // After the caller's own work: the merge never waits on this.
162
+ await new Promise<void>((r) => setImmediate(r));
163
+ try {
164
+ await writeLog(id, { ...opts, plan });
165
+ if (hasLog(id)) done.add(id);
166
+ } catch (error) {
167
+ console.error(`[session-logs] ${id}:`, error);
168
+ }
169
+ })().finally(() => inFlight.delete(id));
170
+ inFlight.set(id, job);
171
+ return job;
172
+ }
173
+
174
+ // Tests start from nothing.
175
+ export function resetSessionLogState(): void {
176
+ done.clear();
177
+ inFlight.clear();
178
+ }