@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,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,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
|
+
}
|