@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.
- package/README.md +47 -0
- package/app/api/projects/[id]/session-logs/route.ts +39 -0
- package/app/api/session-logs/[id]/route.ts +18 -0
- package/components/Pane/DesktopTabBar.tsx +6 -1
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/Pane/PaneViewToggle.tsx +14 -2
- package/components/Pane/index.tsx +28 -1
- package/components/PrStageBadge.tsx +78 -0
- package/components/Projects/ProjectSettingsDialog.tsx +11 -0
- package/components/SessionList/LiveLine.tsx +75 -0
- package/components/SessionList/RowParts.tsx +1 -7
- package/components/SessionList/RowShell.tsx +1 -1
- package/components/SessionList/RowSubtitle.tsx +4 -0
- package/components/SessionList/SessionList.types.ts +2 -0
- package/components/SessionList/SessionRow.tsx +9 -1
- package/components/SessionLogs/LogDocument.tsx +29 -0
- package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
- package/components/views/types.ts +2 -0
- package/contexts/PaneContext.tsx +1 -1
- package/data/session-logs/index.ts +73 -0
- package/lib/db/migrations.ts +24 -0
- package/lib/done/index.ts +2 -0
- package/lib/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/brief.ts +1 -0
- package/lib/orchestrator/checks.ts +2 -0
- package/lib/orchestrator/facts.ts +1 -0
- package/lib/orchestrator/logs.ts +81 -0
- package/lib/orchestrator/serve.ts +5 -0
- package/lib/orchestrator/task-state.ts +30 -7
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +13 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/session-logs/build.ts +253 -0
- package/lib/session-logs/folder.ts +145 -0
- package/lib/session-logs/gather.ts +301 -0
- package/lib/session-logs/index.ts +178 -0
- package/lib/session-logs/store.ts +105 -0
- package/lib/sidebar/live-status.ts +52 -0
- package/lib/sidebar/shelves.ts +5 -0
- package/lib/status/collect.ts +6 -0
- package/lib/status/pr-stages.ts +55 -0
- package/lib/tasks/finish.ts +3 -0
- package/lib/tasks/pr-stage.ts +106 -0
- package/lib/tasks/session.ts +17 -0
- package/package.json +1 -1
- 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(" · ");
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -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
|
}
|
package/lib/status/collect.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -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
|
+
};
|
package/lib/tasks/session.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
);
|