@saadnvd1/agent-os 0.3.503 → 0.3.506
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/app/api/projects/[id]/session-logs/route.ts +39 -0
- package/app/api/session-logs/[id]/route.ts +18 -0
- package/app/api/workspaces/[id]/session-logs/route.ts +35 -0
- package/components/Pane/DesktopTabBar.tsx +4 -1
- package/components/Pane/MobileTabBar.tsx +12 -1
- package/components/Pane/PaneViewToggle.tsx +14 -2
- package/components/Pane/index.tsx +28 -1
- package/components/PrStageBadge.tsx +15 -6
- 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/SessionRow.tsx +9 -3
- package/components/SessionLogs/LogDocument.tsx +29 -0
- package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
- package/components/Workspaces/HqFolderForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/contexts/PaneContext.tsx +1 -1
- package/data/session-logs/index.ts +110 -0
- package/lib/db/migrations.ts +24 -0
- package/lib/done/index.ts +2 -0
- package/lib/orchestrator/brief.ts +1 -0
- package/lib/orchestrator/logs.ts +81 -0
- package/lib/orchestrator/serve.ts +5 -0
- 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 +142 -0
- package/lib/session-logs/gather.ts +301 -0
- package/lib/session-logs/hq.ts +201 -0
- package/lib/session-logs/index.ts +193 -0
- package/lib/session-logs/paths.ts +37 -0
- package/lib/session-logs/reexport.ts +90 -0
- package/lib/session-logs/store.ts +105 -0
- package/lib/sidebar/live-status.ts +52 -0
- package/lib/tasks/finish.ts +3 -0
- package/lib/tasks/pr-stage.ts +14 -0
- package/lib/tasks/session.ts +4 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +101 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's HQ folder: a git repo that session logs are written into
|
|
3
|
+
* (<hq>/docs/sessions/<project>/) and committed to. Logs hold private task
|
|
4
|
+
* details, so a repo whose remote is public is refused when it's saved, and
|
|
5
|
+
* nothing is pushed unless the stored visibility says private.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { db } from "../db";
|
|
11
|
+
import { git } from "../git-exec";
|
|
12
|
+
import { run } from "../tasks/gh";
|
|
13
|
+
import { getWorkspace } from "../workspaces";
|
|
14
|
+
import { expand, repoPaths, resolved, within } from "./paths";
|
|
15
|
+
|
|
16
|
+
export type Visibility = "private" | "internal" | "none";
|
|
17
|
+
|
|
18
|
+
export interface Hq {
|
|
19
|
+
folder: string;
|
|
20
|
+
// "none": the repo has no remote, so its logs stay local.
|
|
21
|
+
visibility: Visibility;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const key = (workspaceId: string) => `session-logs.hq.${workspaceId}`;
|
|
25
|
+
|
|
26
|
+
export function getHq(workspaceId: string): Hq | null {
|
|
27
|
+
const row = db
|
|
28
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
29
|
+
.get(key(workspaceId)) as { value: string } | undefined;
|
|
30
|
+
if (!row) return null;
|
|
31
|
+
try {
|
|
32
|
+
const hq = JSON.parse(row.value) as Hq;
|
|
33
|
+
return hq.folder ? hq : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function projectRepos(workspaceId: string): string[] {
|
|
40
|
+
const ids = db
|
|
41
|
+
.prepare(`SELECT id FROM projects WHERE workspace_id = ?`)
|
|
42
|
+
.all(workspaceId) as { id: string }[];
|
|
43
|
+
return ids.flatMap((p) => repoPaths(p.id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const insideRepo = (hq: string, workspaceId: string) =>
|
|
47
|
+
projectRepos(workspaceId).find((r) => within(hq, r));
|
|
48
|
+
|
|
49
|
+
// The HQ when logs can still go into it: it's there, it's a git
|
|
50
|
+
// repo and no project's repository has swallowed it since it was saved.
|
|
51
|
+
export function usableHq(
|
|
52
|
+
workspaceId: string
|
|
53
|
+
): { dir: string; visibility: Visibility } | null {
|
|
54
|
+
const hq = getHq(workspaceId);
|
|
55
|
+
if (!hq) return null;
|
|
56
|
+
try {
|
|
57
|
+
const dir = resolved(expand(hq.folder));
|
|
58
|
+
if (!fs.existsSync(path.join(dir, ".git")) || insideRepo(dir, workspaceId))
|
|
59
|
+
throw new Error("not usable");
|
|
60
|
+
return { dir, visibility: hq.visibility };
|
|
61
|
+
} catch {
|
|
62
|
+
console.error(
|
|
63
|
+
`[session-logs] the HQ folder ${hq.folder} can't be used; logs go to the default`
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface HqDeps {
|
|
70
|
+
// gh's answer for the repo at `dir`: PUBLIC, PRIVATE or INTERNAL.
|
|
71
|
+
visibility: (dir: string) => Promise<string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ghVisibility = async (dir: string) =>
|
|
75
|
+
(
|
|
76
|
+
await run(
|
|
77
|
+
"gh",
|
|
78
|
+
["repo", "view", "--json", "visibility", "-q", ".visibility"],
|
|
79
|
+
dir,
|
|
80
|
+
20_000
|
|
81
|
+
)
|
|
82
|
+
).trim();
|
|
83
|
+
|
|
84
|
+
// Saves the HQ folder, or throws a plain message saying why not. An empty
|
|
85
|
+
// value clears it.
|
|
86
|
+
export async function setHq(
|
|
87
|
+
workspaceId: string,
|
|
88
|
+
value: string | null,
|
|
89
|
+
deps: HqDeps = { visibility: ghVisibility }
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
if (!getWorkspace(workspaceId)) throw new Error("Workspace not found");
|
|
92
|
+
const folder = value?.trim() ?? "";
|
|
93
|
+
if (!folder) {
|
|
94
|
+
db.prepare(`DELETE FROM settings WHERE key = ?`).run(key(workspaceId));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!(folder === "~" || folder.startsWith("~/") || path.isAbsolute(folder)))
|
|
98
|
+
throw new Error(
|
|
99
|
+
`"${folder}" isn't an absolute path. Use a full path or one starting with ~/.`
|
|
100
|
+
);
|
|
101
|
+
const dir = resolved(expand(folder));
|
|
102
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
103
|
+
throw new Error(`${folder} isn't a folder.`);
|
|
104
|
+
let top: string;
|
|
105
|
+
try {
|
|
106
|
+
top = resolved((await git(dir, ["rev-parse", "--show-toplevel"])).trim());
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`${folder} isn't a git repository.`);
|
|
109
|
+
}
|
|
110
|
+
if (top !== dir)
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${folder} is inside the git repository at ${top}. Give the repository's top folder.`
|
|
113
|
+
);
|
|
114
|
+
const repo = insideRepo(dir, workspaceId);
|
|
115
|
+
if (repo)
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${folder} is inside a project's repository (${repo}). Repositories can be public, so logs are never written into one.`
|
|
118
|
+
);
|
|
119
|
+
let visibility: Visibility = "none";
|
|
120
|
+
if ((await git(dir, ["remote"])).trim()) {
|
|
121
|
+
let seen: string;
|
|
122
|
+
try {
|
|
123
|
+
seen = (await deps.visibility(dir)).toUpperCase();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Couldn't check whether ${folder}'s GitHub repository is private: ${error instanceof Error ? error.message : error}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (seen === "PUBLIC")
|
|
130
|
+
throw new Error(
|
|
131
|
+
`${folder}'s GitHub repository is public. Logs hold private task details, so use a private repository.`
|
|
132
|
+
);
|
|
133
|
+
if (seen !== "PRIVATE" && seen !== "INTERNAL")
|
|
134
|
+
throw new Error(`Unexpected repository visibility "${seen}".`);
|
|
135
|
+
visibility = seen === "PRIVATE" ? "private" : "internal";
|
|
136
|
+
}
|
|
137
|
+
db.prepare(
|
|
138
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
139
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
140
|
+
).run(key(workspaceId), JSON.stringify({ folder, visibility }));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One commit at a time per HQ: two merges at once would race on its index.
|
|
144
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
145
|
+
|
|
146
|
+
function serialized<T>(hq: string, job: () => Promise<T>): Promise<T> {
|
|
147
|
+
const next = (queues.get(hq) ?? Promise.resolve()).then(job, job);
|
|
148
|
+
const tail = next.catch(() => {});
|
|
149
|
+
queues.set(hq, tail);
|
|
150
|
+
void tail.then(() => {
|
|
151
|
+
if (queues.get(hq) === tail) queues.delete(hq);
|
|
152
|
+
});
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function push(hq: string): Promise<void> {
|
|
157
|
+
const branch = (await git(hq, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
158
|
+
const [remote, ref] = (
|
|
159
|
+
await git(hq, [
|
|
160
|
+
"for-each-ref",
|
|
161
|
+
"--format=%(upstream:remotename) %(upstream)",
|
|
162
|
+
`refs/heads/${branch}`,
|
|
163
|
+
])
|
|
164
|
+
)
|
|
165
|
+
.trim()
|
|
166
|
+
.split(" ");
|
|
167
|
+
// The upstream's remote branch is "refs/remotes/<remote>/<name>".
|
|
168
|
+
const name = ref?.replace(/^refs\/remotes\/[^/]+\//, "");
|
|
169
|
+
if (!remote || !name) return;
|
|
170
|
+
await git(hq, ["push", remote, `HEAD:refs/heads/${name}`], {
|
|
171
|
+
timeout: 120_000,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Commits exactly these files (nothing else in the tree) and pushes when the
|
|
176
|
+
// stored visibility is private. Never throws: a failure is logged, and the
|
|
177
|
+
// next call's push carries the commit along.
|
|
178
|
+
export function commitLogs(
|
|
179
|
+
hq: string,
|
|
180
|
+
files: string[],
|
|
181
|
+
message: string,
|
|
182
|
+
visibility: Visibility
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
return serialized(hq, async () => {
|
|
185
|
+
const rel = files.map((f) => path.relative(hq, f));
|
|
186
|
+
try {
|
|
187
|
+
const opts = { literalPathspecs: true };
|
|
188
|
+
await git(hq, ["add", "--", ...rel], opts);
|
|
189
|
+
await git(hq, ["commit", "-m", message, "--only", "--", ...rel], opts);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error(`[session-logs] commit in ${hq}:`, error);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (visibility !== "private") return;
|
|
195
|
+
try {
|
|
196
|
+
await push(hq);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error(`[session-logs] push from ${hq} failed (kept):`, error);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
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 { writeLogFileTo, type Destination } from "./folder";
|
|
18
|
+
import { commitLogs } from "./hq";
|
|
19
|
+
import { gatherLogData, readPlanFile } from "./gather";
|
|
20
|
+
import { hasLog, insertLog, setFilePath } from "./store";
|
|
21
|
+
|
|
22
|
+
export * from "./store";
|
|
23
|
+
export { buildLogMarkdown } from "./build";
|
|
24
|
+
export { logFolder, setLogFolder } from "./folder";
|
|
25
|
+
export { getHq, setHq } from "./hq";
|
|
26
|
+
|
|
27
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
28
|
+
// Tasks known to have a log, so a poll that sees a merged PR again asks
|
|
29
|
+
// nothing of the database.
|
|
30
|
+
const done = new Set<string>();
|
|
31
|
+
|
|
32
|
+
const sqliteNow = () => new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
33
|
+
|
|
34
|
+
export interface CaptureOpts {
|
|
35
|
+
// The PR's head at the merge, when the caller knows it.
|
|
36
|
+
head?: string | null;
|
|
37
|
+
// The plan, when it was read already; else the worktree is read now.
|
|
38
|
+
plan?: string | null;
|
|
39
|
+
// A log written after the worktree went (the backfill).
|
|
40
|
+
planKept?: boolean;
|
|
41
|
+
// Skip asking GitHub for the merge commit (a batch of old tasks).
|
|
42
|
+
noGh?: boolean;
|
|
43
|
+
// Given, the file is handed over instead of committed: a batch commits
|
|
44
|
+
// once at its end.
|
|
45
|
+
onFile?: (file: string, hq: NonNullable<Destination["hq"]>) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function eligible(s: Session | undefined): s is Session {
|
|
49
|
+
return (
|
|
50
|
+
!!s &&
|
|
51
|
+
!!s.task_prompt &&
|
|
52
|
+
!s.peer_mirror &&
|
|
53
|
+
s.task_status !== "dropped" &&
|
|
54
|
+
(s.task_status === "merged" || s.pr_status === "merged")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function mergeCommitOf(s: Session): Promise<string | null> {
|
|
59
|
+
const project = s.project_id ? getProject(s.project_id) : undefined;
|
|
60
|
+
if (!project || !s.pr_number || process.env.AGENTOS_SESSION_LOGS_NO_GH)
|
|
61
|
+
return null;
|
|
62
|
+
try {
|
|
63
|
+
const out = await run(
|
|
64
|
+
"gh",
|
|
65
|
+
[
|
|
66
|
+
"pr",
|
|
67
|
+
"view",
|
|
68
|
+
String(s.pr_number),
|
|
69
|
+
"--json",
|
|
70
|
+
"mergeCommit",
|
|
71
|
+
"-q",
|
|
72
|
+
".mergeCommit.oid",
|
|
73
|
+
],
|
|
74
|
+
project.working_directory.replace(/^~/, os.homedir()),
|
|
75
|
+
15_000
|
|
76
|
+
);
|
|
77
|
+
return out.trim() || null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function writeLog(
|
|
84
|
+
id: string,
|
|
85
|
+
opts: CaptureOpts & { plan?: string | null } = {}
|
|
86
|
+
): Promise<boolean> {
|
|
87
|
+
const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
88
|
+
| Session
|
|
89
|
+
| undefined;
|
|
90
|
+
if (!eligible(row) || hasLog(id)) return false;
|
|
91
|
+
const s = { ...row, merged_at: row.merged_at ?? sqliteNow() };
|
|
92
|
+
const mergeCommit = opts.noGh ? null : await mergeCommitOf(s);
|
|
93
|
+
const data = gatherLogData(s, {
|
|
94
|
+
plan: opts.plan ?? null,
|
|
95
|
+
planKept: opts.planKept ?? true,
|
|
96
|
+
head: opts.head ?? null,
|
|
97
|
+
mergeCommit: mergeCommit ?? (opts.head ? `${opts.head} (PR head)` : null),
|
|
98
|
+
});
|
|
99
|
+
const markdown = buildLogMarkdown(data);
|
|
100
|
+
const start = sqliteMs(data.startedAt);
|
|
101
|
+
const end = sqliteMs(data.mergedAt);
|
|
102
|
+
const workspaceId =
|
|
103
|
+
s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null;
|
|
104
|
+
const stored = insertLog({
|
|
105
|
+
sessionId: id,
|
|
106
|
+
projectId: s.project_id,
|
|
107
|
+
workspaceId,
|
|
108
|
+
markdown,
|
|
109
|
+
header: {
|
|
110
|
+
id,
|
|
111
|
+
name: data.name,
|
|
112
|
+
workspace: data.workspace,
|
|
113
|
+
project: data.project,
|
|
114
|
+
branch: data.branch,
|
|
115
|
+
prNumber: data.prNumber,
|
|
116
|
+
prUrl: data.prUrl,
|
|
117
|
+
mergeCommit: data.mergeCommit,
|
|
118
|
+
startedAt: data.startedAt,
|
|
119
|
+
mergedAt: data.mergedAt,
|
|
120
|
+
durationMs: start !== null && end !== null ? end - start : null,
|
|
121
|
+
costUsd: data.usage.reduce((t, u) => t + u.costUsd, 0),
|
|
122
|
+
planKept: data.planKept,
|
|
123
|
+
hasPlan: data.plan !== null,
|
|
124
|
+
},
|
|
125
|
+
filePath: null,
|
|
126
|
+
});
|
|
127
|
+
if (!stored) return false;
|
|
128
|
+
try {
|
|
129
|
+
const { file, hq } = writeLogFileTo(
|
|
130
|
+
{
|
|
131
|
+
workspace: data.workspace,
|
|
132
|
+
workspaceId,
|
|
133
|
+
project: data.project,
|
|
134
|
+
projectId: s.project_id,
|
|
135
|
+
name: data.name,
|
|
136
|
+
id,
|
|
137
|
+
day: (data.mergedAt ?? sqliteNow()).slice(0, 10),
|
|
138
|
+
},
|
|
139
|
+
markdown
|
|
140
|
+
);
|
|
141
|
+
setFilePath(id, file);
|
|
142
|
+
if (hq && opts.onFile) opts.onFile(file, hq);
|
|
143
|
+
else if (hq)
|
|
144
|
+
await commitLogs(
|
|
145
|
+
hq.dir,
|
|
146
|
+
[file],
|
|
147
|
+
`session log: ${data.name.replace(/\s+/g, " ").trim().slice(0, 100)}`,
|
|
148
|
+
hq.visibility
|
|
149
|
+
);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// The database holds the log; the file is a copy.
|
|
152
|
+
console.error(`[session-logs] file for ${id}:`, error);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function captureMergedTask(
|
|
158
|
+
id: string,
|
|
159
|
+
opts: CaptureOpts = {}
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
if (done.has(id)) return Promise.resolve();
|
|
162
|
+
const running = inFlight.get(id);
|
|
163
|
+
if (running) return running;
|
|
164
|
+
let plan: string | null | undefined = opts.plan;
|
|
165
|
+
try {
|
|
166
|
+
if (plan === undefined) {
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare(`SELECT worktree_path FROM sessions WHERE id = ?`)
|
|
169
|
+
.get(id) as { worktree_path: string | null } | undefined;
|
|
170
|
+
plan = readPlanFile(row?.worktree_path ?? null);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error(`[session-logs] plan for ${id}:`, error);
|
|
174
|
+
}
|
|
175
|
+
const job = (async () => {
|
|
176
|
+
// After the caller's own work: the merge never waits on this.
|
|
177
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
178
|
+
try {
|
|
179
|
+
await writeLog(id, { ...opts, plan });
|
|
180
|
+
if (hasLog(id)) done.add(id);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error(`[session-logs] ${id}:`, error);
|
|
183
|
+
}
|
|
184
|
+
})().finally(() => inFlight.delete(id));
|
|
185
|
+
inFlight.set(id, job);
|
|
186
|
+
return job;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Tests start from nothing.
|
|
190
|
+
export function resetSessionLogState(): void {
|
|
191
|
+
done.clear();
|
|
192
|
+
inFlight.clear();
|
|
193
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getProject, getProjectRepositories } from "../projects";
|
|
5
|
+
|
|
6
|
+
export const expand = (p: string) =>
|
|
7
|
+
p === "~" ? os.homedir() : p.replace(/^~(?=\/)/, os.homedir());
|
|
8
|
+
|
|
9
|
+
// The path with symlinks resolved as far as it exists, so a link into a
|
|
10
|
+
// repository is seen for what it is.
|
|
11
|
+
export function resolved(p: string): string {
|
|
12
|
+
let cur = path.resolve(p);
|
|
13
|
+
const rest: string[] = [];
|
|
14
|
+
while (!fs.existsSync(cur)) {
|
|
15
|
+
const up = path.dirname(cur);
|
|
16
|
+
if (up === cur) break;
|
|
17
|
+
rest.unshift(path.basename(cur));
|
|
18
|
+
cur = up;
|
|
19
|
+
}
|
|
20
|
+
return path.join(fs.realpathSync(cur), ...rest);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const within = (child: string, parent: string) => {
|
|
24
|
+
const rel = path.relative(parent, child);
|
|
25
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function repoPaths(projectId: string): string[] {
|
|
29
|
+
const project = getProject(projectId);
|
|
30
|
+
if (!project) return [];
|
|
31
|
+
return [
|
|
32
|
+
project.working_directory,
|
|
33
|
+
...getProjectRepositories(projectId).map((r) => r.path),
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((p) => resolved(expand(p)));
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrites the files of logs already in the database to where they'd go
|
|
3
|
+
* now, from the stored markdown: no rebuild, no model. The old files are
|
|
4
|
+
* left alone. A log whose file is already in its destination is skipped, so
|
|
5
|
+
* a second run changes nothing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { destination, writeLogFileTo } from "./folder";
|
|
11
|
+
import { commitLogs } from "./hq";
|
|
12
|
+
import { headerOf, setFilePath, type LogRow } from "./store";
|
|
13
|
+
|
|
14
|
+
export interface ReexportResult {
|
|
15
|
+
moved: number;
|
|
16
|
+
unchanged: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
committed: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function reexportLogs(
|
|
22
|
+
opts: { dry?: boolean; log?: (line: string) => void } = {}
|
|
23
|
+
): Promise<ReexportResult> {
|
|
24
|
+
const log = opts.log ?? (() => {});
|
|
25
|
+
const rows = db
|
|
26
|
+
.prepare(
|
|
27
|
+
`SELECT * FROM session_logs
|
|
28
|
+
ORDER BY json_extract(data, '$.mergedAt'), created_at`
|
|
29
|
+
)
|
|
30
|
+
.all() as LogRow[];
|
|
31
|
+
const result: ReexportResult = {
|
|
32
|
+
moved: 0,
|
|
33
|
+
unchanged: 0,
|
|
34
|
+
failed: 0,
|
|
35
|
+
committed: 0,
|
|
36
|
+
};
|
|
37
|
+
const byHq = new Map<
|
|
38
|
+
string,
|
|
39
|
+
{ files: string[]; visibility: "private" | "internal" | "none" }
|
|
40
|
+
>();
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const h = headerOf(row);
|
|
43
|
+
const target = {
|
|
44
|
+
workspace: h.workspace,
|
|
45
|
+
workspaceId: row.workspace_id,
|
|
46
|
+
project: h.project,
|
|
47
|
+
projectId: row.project_id,
|
|
48
|
+
name: h.name,
|
|
49
|
+
id: row.session_id,
|
|
50
|
+
day: (h.mergedAt ?? row.created_at).slice(0, 10),
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
const dest = destination(target);
|
|
54
|
+
if (row.file_path && path.dirname(row.file_path) === dest.dir) {
|
|
55
|
+
result.unchanged++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (opts.dry) {
|
|
59
|
+
log(` would move ${h.name} to ${dest.dir}`);
|
|
60
|
+
result.moved++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const { file, hq } = writeLogFileTo(target, row.markdown);
|
|
64
|
+
setFilePath(row.session_id, file);
|
|
65
|
+
if (hq) {
|
|
66
|
+
const entry = byHq.get(hq.dir) ?? {
|
|
67
|
+
files: [],
|
|
68
|
+
visibility: hq.visibility,
|
|
69
|
+
};
|
|
70
|
+
entry.files.push(file);
|
|
71
|
+
byHq.set(hq.dir, entry);
|
|
72
|
+
}
|
|
73
|
+
result.moved++;
|
|
74
|
+
log(` wrote ${file}`);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
result.failed++;
|
|
77
|
+
log(` FAILED ${h.name} (${row.session_id.slice(0, 8)}): ${error}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [hq, { files, visibility }] of byHq) {
|
|
81
|
+
await commitLogs(
|
|
82
|
+
hq,
|
|
83
|
+
files,
|
|
84
|
+
`session logs: ${files.length} moved in`,
|
|
85
|
+
visibility
|
|
86
|
+
);
|
|
87
|
+
result.committed += files.length;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
@@ -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/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,
|