@saadnvd1/agent-os 0.3.502 → 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 (46) 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 +6 -1
  5. package/components/Pane/MobileTabBar.tsx +14 -1
  6. package/components/Pane/PaneViewToggle.tsx +14 -2
  7. package/components/Pane/index.tsx +28 -1
  8. package/components/PrStageBadge.tsx +78 -0
  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/SessionList.types.ts +2 -0
  15. package/components/SessionList/SessionRow.tsx +9 -1
  16. package/components/SessionLogs/LogDocument.tsx +29 -0
  17. package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
  18. package/components/views/types.ts +2 -0
  19. package/contexts/PaneContext.tsx +1 -1
  20. package/data/session-logs/index.ts +73 -0
  21. package/lib/db/migrations.ts +24 -0
  22. package/lib/done/index.ts +2 -0
  23. package/lib/orchestrator/armed.ts +2 -0
  24. package/lib/orchestrator/brief.ts +1 -0
  25. package/lib/orchestrator/checks.ts +2 -0
  26. package/lib/orchestrator/facts.ts +1 -0
  27. package/lib/orchestrator/logs.ts +81 -0
  28. package/lib/orchestrator/serve.ts +5 -0
  29. package/lib/orchestrator/task-state.ts +30 -7
  30. package/lib/orchestrator/tool-names.ts +1 -0
  31. package/lib/orchestrator/tool-schemas.ts +13 -0
  32. package/lib/orchestrator/tools.ts +1 -0
  33. package/lib/session-logs/build.ts +253 -0
  34. package/lib/session-logs/folder.ts +145 -0
  35. package/lib/session-logs/gather.ts +301 -0
  36. package/lib/session-logs/index.ts +178 -0
  37. package/lib/session-logs/store.ts +105 -0
  38. package/lib/sidebar/live-status.ts +52 -0
  39. package/lib/sidebar/shelves.ts +5 -0
  40. package/lib/status/collect.ts +6 -0
  41. package/lib/status/pr-stages.ts +55 -0
  42. package/lib/tasks/finish.ts +3 -0
  43. package/lib/tasks/pr-stage.ts +106 -0
  44. package/lib/tasks/session.ts +17 -0
  45. package/package.json +1 -1
  46. package/scripts/backfill-session-logs.ts +70 -0
@@ -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
+ }
@@ -0,0 +1,105 @@
1
+ import { db } from "../db";
2
+
3
+ // What the list and the orchestrator's tool show of a log without its text.
4
+ export interface LogHeader {
5
+ id: string;
6
+ name: string;
7
+ workspace: string | null;
8
+ project: string | null;
9
+ branch: string | null;
10
+ prNumber: number | null;
11
+ prUrl: string | null;
12
+ mergeCommit: string | null;
13
+ startedAt: string | null;
14
+ mergedAt: string | null;
15
+ durationMs: number | null;
16
+ costUsd: number;
17
+ planKept: boolean;
18
+ hasPlan: boolean;
19
+ createdAt: string;
20
+ filePath: string | null;
21
+ }
22
+
23
+ export interface LogRow {
24
+ session_id: string;
25
+ project_id: string | null;
26
+ workspace_id: string | null;
27
+ created_at: string;
28
+ markdown: string;
29
+ data: string;
30
+ file_path: string | null;
31
+ }
32
+
33
+ export const hasLog = (sessionId: string): boolean =>
34
+ !!db
35
+ .prepare(`SELECT 1 FROM session_logs WHERE session_id = ?`)
36
+ .get(sessionId);
37
+
38
+ export function getLog(sessionId: string): LogRow | null {
39
+ return (
40
+ (db
41
+ .prepare(`SELECT * FROM session_logs WHERE session_id = ?`)
42
+ .get(sessionId) as LogRow | undefined) ?? null
43
+ );
44
+ }
45
+
46
+ // True when this call stored it: a log already there is never replaced.
47
+ export function insertLog(row: {
48
+ sessionId: string;
49
+ projectId: string | null;
50
+ workspaceId: string | null;
51
+ markdown: string;
52
+ header: Omit<LogHeader, "createdAt" | "filePath">;
53
+ filePath: string | null;
54
+ }): boolean {
55
+ return (
56
+ db
57
+ .prepare(
58
+ `INSERT OR IGNORE INTO session_logs
59
+ (session_id, project_id, workspace_id, markdown, data, file_path)
60
+ VALUES (?, ?, ?, ?, ?, ?)`
61
+ )
62
+ .run(
63
+ row.sessionId,
64
+ row.projectId,
65
+ row.workspaceId,
66
+ row.markdown,
67
+ JSON.stringify(row.header),
68
+ row.filePath
69
+ ).changes > 0
70
+ );
71
+ }
72
+
73
+ export function setFilePath(sessionId: string, filePath: string): void {
74
+ db.prepare(`UPDATE session_logs SET file_path = ? WHERE session_id = ?`).run(
75
+ filePath,
76
+ sessionId
77
+ );
78
+ }
79
+
80
+ const toHeader = (r: LogRow): LogHeader => ({
81
+ ...(JSON.parse(r.data) as LogHeader),
82
+ createdAt: r.created_at,
83
+ filePath: r.file_path,
84
+ });
85
+
86
+ export const headerOf = (r: LogRow): LogHeader => toHeader(r);
87
+
88
+ // Newest merge first.
89
+ export function listLogs(
90
+ where: { projectId?: string; workspaceId?: string },
91
+ limit = 50
92
+ ): LogHeader[] {
93
+ const rows = db
94
+ .prepare(
95
+ `SELECT * FROM session_logs
96
+ WHERE (@p IS NULL OR project_id = @p) AND (@w IS NULL OR workspace_id = @w)
97
+ ORDER BY json_extract(data, '$.mergedAt') DESC, created_at DESC LIMIT @n`
98
+ )
99
+ .all({
100
+ p: where.projectId ?? null,
101
+ w: where.workspaceId ?? null,
102
+ n: Math.max(1, Math.min(limit, 500)),
103
+ }) as LogRow[];
104
+ return rows.map(toHeader);
105
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ PR_STAGE_BUSY,
3
+ PR_STAGE_SHORT,
4
+ type PrStage,
5
+ type PrStageName,
6
+ } from "../tasks/pr-stage";
7
+
8
+ export type LiveTone = "blue" | "amber" | "red" | "green" | "purple" | "muted";
9
+
10
+ export interface LivePart {
11
+ text: string;
12
+ tone: LiveTone;
13
+ // Still moving on its own: the UI shows a spinner.
14
+ busy?: boolean;
15
+ }
16
+
17
+ export const PR_STAGE_TONE: Record<PrStageName, LiveTone> = {
18
+ merged: "purple",
19
+ conflicts: "amber",
20
+ "ci-failed": "red",
21
+ "ci-running": "blue",
22
+ "ci-settling": "blue",
23
+ "review-running": "blue",
24
+ "review-blocked": "red",
25
+ waiting: "amber",
26
+ armed: "green",
27
+ open: "muted",
28
+ };
29
+
30
+ // What the row's second line says about the session right now, in order:
31
+ // "Compacting", then "PR CI". Empty when there is nothing to say.
32
+ export function liveParts(input: {
33
+ compacting?: boolean;
34
+ prStage?: PrStage;
35
+ }): LivePart[] {
36
+ const parts: LivePart[] = [];
37
+ if (input.compacting)
38
+ parts.push({ text: "Compacting", tone: "blue", busy: true });
39
+ const s = input.prStage;
40
+ if (s) {
41
+ const word = PR_STAGE_SHORT[s.stage];
42
+ parts.push({
43
+ text: s.stage === "open" ? "PR" : `PR ${word}`,
44
+ tone: PR_STAGE_TONE[s.stage],
45
+ busy: PR_STAGE_BUSY.has(s.stage),
46
+ });
47
+ }
48
+ return parts;
49
+ }
50
+
51
+ export const liveText = (parts: LivePart[]) =>
52
+ parts.map((p) => p.text).join(" · ");
@@ -2,6 +2,7 @@
2
2
  // orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
3
3
  import type { Session } from "@/lib/db/types";
4
4
  import { fromSqliteTime } from "@/lib/session-meta";
5
+ import type { PrStage } from "@/lib/tasks/pr-stage";
5
6
  import type { TaskState } from "@/lib/tasks/state";
6
7
 
7
8
  // Why a session is blocked on you.
@@ -33,6 +34,7 @@ export interface RowStatus {
33
34
  // A chat summarising its context (status stays "running").
34
35
  compacting?: { since: number; before?: number };
35
36
  asks?: number;
37
+ prStage?: PrStage;
36
38
  // A program's own message (OSC 7501): untrusted, plain text only.
37
39
  detail?: string | null;
38
40
  }
@@ -264,6 +266,9 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
264
266
  (a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
265
267
  (a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
266
268
  a.asks === b.asks &&
269
+ (a.prStage?.stage ?? null) === (b.prStage?.stage ?? null) &&
270
+ (a.prStage?.number ?? null) === (b.prStage?.number ?? null) &&
271
+ (a.prStage?.sha ?? null) === (b.prStage?.sha ?? null) &&
267
272
  (a.detail ?? null) === (b.detail ?? null)
268
273
  );
269
274
  }
@@ -30,6 +30,8 @@ import {
30
30
  terminalStatus,
31
31
  } from "../needs-you";
32
32
  import type { SessionNeed } from "../sidebar/shelves";
33
+ import type { PrStage } from "../tasks/pr-stage";
34
+ import { addPrStages } from "./pr-stages";
33
35
  import type { Compacting } from "../chat/events";
34
36
  import { openAskCount } from "../orchestrator/asks";
35
37
  import { lastUserTask } from "../chat/store";
@@ -65,6 +67,8 @@ export interface SessionStatusResponse {
65
67
  stalled?: boolean;
66
68
  // A chat whose agent is summarising its context ("status" stays running).
67
69
  compacting?: Compacting;
70
+ // Where a task's PR is in the gates (lib/tasks/pr-stage.ts).
71
+ prStage?: PrStage;
68
72
  // A program's own message (OSC 7501): untrusted, plain text only.
69
73
  detail?: string | null;
70
74
  progress?: number | null;
@@ -477,6 +481,8 @@ async function collect(): Promise<StatusSnapshot> {
477
481
  };
478
482
  }
479
483
 
484
+ addPrStages(statusMap);
485
+
480
486
  // Cleanup old trackers
481
487
  statusDetector.cleanup();
482
488
 
@@ -0,0 +1,55 @@
1
+ // A task's PR stage for the sidebar, from state AgentOS already holds: the
2
+ // PR as the last poll saw it, the stored review and CI checks, escalations,
3
+ // asks and the armed flag. Nothing here asks GitHub.
4
+ import { db, type Session } from "../db";
5
+ import { cachedPR, storedPR } from "../tasks/session";
6
+ import { derivePrStage } from "../tasks/pr-stage";
7
+ import { getCheck } from "../orchestrator/checks";
8
+ import { holdingEscalations } from "../orchestrator/gates";
9
+ import { openBySubject, workSubject } from "../orchestrator/asks";
10
+ import { ciSettleLeft } from "../orchestrator/task-state";
11
+ import { knownCommitTime } from "../orchestrator/facts";
12
+ import type { SessionStatusResponse } from "./collect";
13
+
14
+ type TaskRow = Session & { workspace_id: string };
15
+
16
+ export function addPrStages(
17
+ statuses: Record<string, SessionStatusResponse>
18
+ ): void {
19
+ const tasks = db
20
+ .prepare(
21
+ `SELECT s.*, p.workspace_id FROM sessions s
22
+ JOIN projects p ON p.id = s.project_id
23
+ WHERE s.task_status = 'running' AND s.pr_number IS NOT NULL
24
+ AND s.archived_at IS NULL`
25
+ )
26
+ .all() as TaskRow[];
27
+ for (const t of tasks) {
28
+ const status = statuses[t.id];
29
+ if (!status) continue;
30
+ try {
31
+ const pr = cachedPR(t.id) ?? storedPR(t);
32
+ const head = pr?.head;
33
+ const stage = derivePrStage({
34
+ pr,
35
+ settleIn:
36
+ pr?.checks === "pass" && head
37
+ ? ciSettleLeft({
38
+ taskId: t.id,
39
+ sha: head,
40
+ checkCount: pr.checkCount ?? 0,
41
+ committedAt: knownCommitTime(head),
42
+ })
43
+ : 0,
44
+ review: head ? (getCheck(t.id, head, "review")?.status ?? null) : null,
45
+ held:
46
+ holdingEscalations(t.id).length > 0 ||
47
+ !!openBySubject(t.workspace_id, workSubject(t.id)),
48
+ armed: !!t.merge_armed_at,
49
+ });
50
+ if (stage) status.prStage = stage;
51
+ } catch (error) {
52
+ console.error(`[status] PR stage of ${t.id.slice(0, 8)}:`, error);
53
+ }
54
+ }
55
+ }
@@ -23,6 +23,7 @@ import { getProject } from "../projects";
23
23
  import { canSignOff } from "./state";
24
24
  import { forgetPR, getTaskSession, prFor, projectPathFor } from "./session";
25
25
  import { dropSessionDatabase } from "../project-config/database";
26
+ import { captureMergedTask } from "../session-logs";
26
27
 
27
28
  // Never deleted, whatever a setting says.
28
29
  export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
@@ -154,6 +155,8 @@ export async function signOffTask(
154
155
  signingOff.delete(id);
155
156
  }
156
157
  syncTaskCardInBackground(session, "merged", pr);
158
+ // Reads the plan file now: cleanup below removes the worktree.
159
+ void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
157
160
  const head = opts.head ?? pr!.head;
158
161
  resolveMergedTaskAsks(
159
162
  id,
@@ -0,0 +1,106 @@
1
+ // Where a task's PR is in the gates, as one word for the UI. Pure: the
2
+ // inputs are what AgentOS already tracks for the PR (tasks/state.ts,
3
+ // orchestrator/checks.ts), so nothing here asks GitHub or a model.
4
+
5
+ export const PR_STAGES = [
6
+ "merged",
7
+ "conflicts",
8
+ "ci-failed",
9
+ "ci-running",
10
+ "ci-settling",
11
+ "review-running",
12
+ "review-blocked",
13
+ "waiting",
14
+ "armed",
15
+ "open",
16
+ ] as const;
17
+
18
+ export type PrStageName = (typeof PR_STAGES)[number];
19
+
20
+ export interface PrStage {
21
+ stage: PrStageName;
22
+ number: number;
23
+ url: string;
24
+ // The PR's head, 7 characters.
25
+ sha?: string;
26
+ }
27
+
28
+ export interface PrStageInput {
29
+ pr: {
30
+ number: number;
31
+ url: string;
32
+ state: "OPEN" | "MERGED" | "CLOSED";
33
+ checks: "pass" | "fail" | "pending" | "none";
34
+ head?: string;
35
+ mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
36
+ } | null;
37
+ // Seconds until CI on the head counts as settled (0 or unset: settled).
38
+ settleIn?: number;
39
+ // The newest review of the head commit.
40
+ review?: "running" | "pass" | "block" | "error" | null;
41
+ // Escalated to Saad, or an ask of his open on it.
42
+ held?: boolean;
43
+ armed?: boolean;
44
+ }
45
+
46
+ // In priority order: the first that applies wins, so a conflict hides the
47
+ // CI behind it and a failed CI hides a review that would be moot.
48
+ export function derivePrStage(input: PrStageInput): PrStage | null {
49
+ const { pr } = input;
50
+ if (!pr || pr.state === "CLOSED") return null;
51
+ const base = {
52
+ number: pr.number,
53
+ url: pr.url,
54
+ ...(pr.head && { sha: pr.head.slice(0, 7) }),
55
+ };
56
+ const at = (stage: PrStageName): PrStage => ({ stage, ...base });
57
+ if (pr.state === "MERGED") return at("merged");
58
+ if (pr.mergeable === "CONFLICTING") return at("conflicts");
59
+ if (pr.checks === "fail") return at("ci-failed");
60
+ if (pr.checks === "pending") return at("ci-running");
61
+ if (pr.checks === "pass" && (input.settleIn ?? 0) > 0)
62
+ return at("ci-settling");
63
+ if (input.review === "running") return at("review-running");
64
+ if (input.review === "block") return at("review-blocked");
65
+ if (input.held) return at("waiting");
66
+ if (input.armed) return at("armed");
67
+ return at("open");
68
+ }
69
+
70
+ // Stages still moving on their own: the badge spins for these.
71
+ export const PR_STAGE_BUSY: ReadonlySet<PrStageName> = new Set([
72
+ "conflicts",
73
+ "ci-running",
74
+ "ci-settling",
75
+ "review-running",
76
+ ]);
77
+
78
+ export const PR_STAGE_LABEL: Record<PrStageName, string> = {
79
+ merged: "merged",
80
+ conflicts: "rebasing",
81
+ "ci-failed": "CI failed",
82
+ "ci-running": "CI running",
83
+ "ci-settling": "CI settling",
84
+ "review-running": "reviewing",
85
+ "review-blocked": "review blocked",
86
+ waiting: "waiting on you",
87
+ armed: "armed",
88
+ open: "open",
89
+ };
90
+
91
+ export const prStageText = (s: Pick<PrStage, "stage" | "number">) =>
92
+ `PR #${s.number} · ${PR_STAGE_LABEL[s.stage]}`;
93
+
94
+ // The short wording for the badge itself; the full wording is the tooltip.
95
+ export const PR_STAGE_SHORT: Record<PrStageName, string> = {
96
+ merged: "Merged",
97
+ conflicts: "Conflicts",
98
+ "ci-failed": "CI failed",
99
+ "ci-running": "CI",
100
+ "ci-settling": "CI",
101
+ "review-running": "Review",
102
+ "review-blocked": "Blocked",
103
+ waiting: "Waiting on you",
104
+ armed: "Armed",
105
+ open: "PR",
106
+ };
@@ -9,6 +9,8 @@ import { getProject } from "../projects";
9
9
  import { findPRStrict } from "./gh";
10
10
  import { currentBranch, git } from "../git-exec";
11
11
  import { lookupPR } from "./pr-poll";
12
+ import { captureMergedTask } from "../session-logs";
13
+ import { notifyStatusChanged } from "../status/hub";
12
14
  import type { TaskPR } from "./state";
13
15
 
14
16
  export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
@@ -128,6 +130,15 @@ export const isoUTC = (t: string) =>
128
130
 
129
131
  const prCache = new Map<string, { at: number; pr: TaskPR | null }>();
130
132
 
133
+ const prSignature = (pr: TaskPR | null | undefined) =>
134
+ pr
135
+ ? `${pr.number}|${pr.state}|${pr.checks}|${pr.head}|${pr.mergeable}|${pr.checkCount}`
136
+ : "";
137
+
138
+ // The PR as the last poll saw it, with no gh call; undefined before one.
139
+ export const cachedPR = (sessionId: string): TaskPR | null | undefined =>
140
+ prCache.get(sessionId)?.pr;
141
+
131
142
  // strict: a gh failure throws instead of reading as "no PR".
132
143
  export async function prFor(
133
144
  session: Session,
@@ -167,11 +178,17 @@ export async function prFor(
167
178
  }
168
179
  }
169
180
  if (renamed && !refused) moveBranch(session, renamed);
181
+ const before = prCache.get(session.id)?.pr;
170
182
  prCache.set(session.id, { at: Date.now(), pr });
183
+ // A PR whose stage inputs moved is news for the sidebar's badge.
184
+ if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
171
185
  if (pr) {
172
186
  db.prepare(
173
187
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
174
188
  ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
189
+ // Merged on GitHub rather than through AgentOS: its log is due too.
190
+ if (pr.state === "MERGED")
191
+ void captureMergedTask(session.id, { head: pr.head });
175
192
  }
176
193
  // Only an answer just read from GitHub: a cached one may predate a
177
194
  // retarget AgentOS made itself (a restack).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.502",
3
+ "version": "0.3.505",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Writes session logs for tasks that merged before logs existed, from what
3
+ * the database still holds. Their plan files went with their worktrees, so
4
+ * each says "plan not kept". Idempotent: a task with a log is skipped.
5
+ *
6
+ * npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry]
7
+ *
8
+ * --db is required so a run never lands on a database by accident. Files go
9
+ * where new logs go (~/.agent-os/session-logs, or a project's folder); run
10
+ * with HOME set elsewhere to keep a trial away from the real folder.
11
+ * --no-gh skips asking GitHub for each merge commit.
12
+ */
13
+
14
+ const args = process.argv.slice(2);
15
+ const flag = (name: string) => args.includes(name);
16
+ const dbPath = args[args.indexOf("--db") + 1];
17
+
18
+ if (!flag("--db") || !dbPath || dbPath.startsWith("--")) {
19
+ console.error(
20
+ "usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry]"
21
+ );
22
+ process.exit(2);
23
+ }
24
+ process.env.DB_PATH = dbPath;
25
+
26
+ async function main() {
27
+ const { db } = await import("../lib/db");
28
+ const { writeLog } = await import("../lib/session-logs");
29
+ const { hasLog } = await import("../lib/session-logs/store");
30
+ const rows = db
31
+ .prepare(
32
+ `SELECT id, name FROM sessions
33
+ WHERE task_prompt IS NOT NULL AND peer_mirror = 0
34
+ AND (task_status = 'merged' OR (pr_status = 'merged' AND task_status != 'dropped'))
35
+ ORDER BY COALESCE(merged_at, updated_at)`
36
+ )
37
+ .all() as { id: string; name: string }[];
38
+ const todo = rows.filter((r) => !hasLog(r.id));
39
+ console.log(
40
+ `${rows.length} merged tasks, ${rows.length - todo.length} already logged, ${todo.length} to write${flag("--dry") ? " (dry run)" : ""}`
41
+ );
42
+ let written = 0;
43
+ for (const r of todo) {
44
+ if (flag("--dry")) {
45
+ console.log(` would log ${r.name} (${r.id.slice(0, 8)})`);
46
+ continue;
47
+ }
48
+ try {
49
+ const ok = await writeLog(r.id, {
50
+ planKept: false,
51
+ noGh: flag("--no-gh"),
52
+ });
53
+ if (ok) written++;
54
+ console.log(
55
+ ` ${ok ? "logged" : "skipped"} ${r.name} (${r.id.slice(0, 8)})`
56
+ );
57
+ } catch (error) {
58
+ console.error(` FAILED ${r.name} (${r.id.slice(0, 8)}):`, error);
59
+ }
60
+ }
61
+ console.log(`wrote ${written} logs`);
62
+ }
63
+
64
+ main().then(
65
+ () => process.exit(0),
66
+ (error) => {
67
+ console.error(error);
68
+ process.exit(1);
69
+ }
70
+ );