@saadnvd1/agent-os 0.3.505 → 0.3.507

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 CHANGED
@@ -1111,11 +1111,27 @@ or code spans, so it can't add headings or links to the document, and
1111
1111
  credential-shaped strings are redacted.
1112
1112
 
1113
1113
  The database is the record: `session_logs`, one row per task, never deleted.
1114
- Each log is also written to
1115
- `~/.agent-os/session-logs/<workspace>/<project>/YYYY-MM-DD-<task>.md`. A
1116
- project's settings have a **Session log folder** (an absolute or `~` path) to
1117
- put its files elsewhere; a folder inside the project's repository is refused,
1118
- because repositories can be public, and the files are never written into one.
1114
+ Each log is also written to a file, `YYYY-MM-DD-<task>.md`, in the first of:
1115
+
1116
+ 1. the project's **Session log folder** (project settings; an absolute or `~`
1117
+ path),
1118
+ 2. the workspace's **HQ folder** (workspace settings):
1119
+ `<hq>/docs/sessions/<project>/`,
1120
+ 3. `~/.agent-os/session-logs/<workspace>/<project>/`.
1121
+
1122
+ A folder inside the project's repository is refused, because repositories can
1123
+ be public, and the files are never written into one.
1124
+
1125
+ The HQ folder is a git repo. Saving it checks that it is a repository, that it
1126
+ isn't inside or equal to any of the workspace's project repos, and (with one
1127
+ `gh repo view --json visibility`) that its GitHub remote isn't public; the
1128
+ result is stored. When a log lands in the HQ it is committed there as
1129
+ `session log: <task name>`, only that file, whatever else is changed in the
1130
+ tree, and pushed to the current branch's upstream only if the repo is
1131
+ private. A failed commit or push is logged and never reaches the merge; the
1132
+ next push carries the earlier commit. Commits to one HQ run one at a time.
1133
+ The database row is the record either way.
1134
+
1119
1135
  The same dialog lists the project's logs, newest first, and a merged task's
1120
1136
  view has a **Log** tab. The orchestrator reads them with its `logs` tool: the
1121
1137
  latest headers of a workspace or project, or one log in full. It only reads.
@@ -1126,10 +1142,14 @@ failure is logged and never reaches the merge. For tasks that merged before
1126
1142
  this existed:
1127
1143
 
1128
1144
  ```bash
1129
- npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry]
1145
+ npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry] [--reexport]
1130
1146
  ```
1131
1147
 
1132
- Their plan files are gone, so those logs say "plan not kept".
1148
+ Their plan files are gone, so those logs say "plan not kept". Files go to the
1149
+ same destinations as above; into an HQ the backfill commits once at the end.
1150
+ `--reexport` writes no new logs: it rewrites the files of logs already in the
1151
+ database to where they'd go now, from the stored markdown (no rebuild, no
1152
+ model), leaves the old files in place, and does nothing on a second run.
1133
1153
 
1134
1154
  ### Orchestrator
1135
1155
 
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getHq, setHq } from "@/lib/session-logs";
3
+ import { getWorkspace } from "@/lib/workspaces";
4
+ import { requireLocalTrust } from "@/lib/security/route-guard";
5
+
6
+ type RouteParams = { params: Promise<{ id: string }> };
7
+
8
+ const state = (id: string) => ({ hq: getHq(id) });
9
+
10
+ // GET/PUT /api/workspaces/:id/session-logs {folder} - the workspace's HQ
11
+ // folder, where session logs are written and committed.
12
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
13
+ const { id } = await params;
14
+ if (!getWorkspace(id))
15
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
16
+ return NextResponse.json(state(id));
17
+ }
18
+
19
+ // Saving checks the folder and asks GitHub about it, so only from this
20
+ // machine or the tailnet.
21
+ export async function PUT(request: NextRequest, { params }: RouteParams) {
22
+ const refused = requireLocalTrust(request);
23
+ if (refused) return refused;
24
+ const { id } = await params;
25
+ if (!getWorkspace(id))
26
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
27
+ try {
28
+ const { folder } = await request.json();
29
+ await setHq(id, typeof folder === "string" ? folder : null);
30
+ return NextResponse.json(state(id));
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return NextResponse.json({ error: message }, { status: 400 });
34
+ }
35
+ }
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { useSetWorkspaceHq, useWorkspaceHqQuery } from "@/data/session-logs";
8
+
9
+ // The git repo this workspace's session logs are written into and
10
+ // committed to. Blank keeps them in AgentOS's own folder.
11
+ export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
12
+ const { data, isPending, isError, error } = useWorkspaceHqQuery(workspaceId);
13
+ const save = useSetWorkspaceHq(workspaceId);
14
+ const [draft, setDraft] = useState<string | null>(null);
15
+ const saved = data?.hq?.folder ?? "";
16
+ const folder = draft ?? saved;
17
+ const id = `hq-folder-${workspaceId}`;
18
+
19
+ if (isPending)
20
+ return <div className="bg-muted/60 h-11 animate-pulse rounded-md" />;
21
+ if (isError)
22
+ return <p className="text-destructive text-xs">{error.message}</p>;
23
+
24
+ return (
25
+ <form
26
+ onSubmit={(e) => {
27
+ e.preventDefault();
28
+ save.mutate(folder, {
29
+ onSuccess: () => {
30
+ setDraft(null);
31
+ toast.success(
32
+ folder.trim() ? "HQ folder saved" : "HQ folder cleared"
33
+ );
34
+ },
35
+ onError: (err) => toast.error(err.message),
36
+ });
37
+ }}
38
+ className="space-y-1.5"
39
+ >
40
+ <label htmlFor={id} className="text-sm">
41
+ HQ folder
42
+ </label>
43
+ <div className="flex gap-2">
44
+ <Input
45
+ id={id}
46
+ value={folder}
47
+ onChange={(e) => setDraft(e.target.value)}
48
+ placeholder="~/path/to/a/private/git/repo"
49
+ className="h-11 flex-1 text-base md:h-9 md:text-sm"
50
+ />
51
+ <Button
52
+ type="submit"
53
+ size="sm"
54
+ className="h-11 sm:h-9"
55
+ disabled={folder === saved || save.isPending}
56
+ >
57
+ Save
58
+ </Button>
59
+ </div>
60
+ <p className="text-muted-foreground text-xs">
61
+ Session logs go to docs/sessions/&lt;project&gt;/ in this repo and are
62
+ committed there, one file per commit.{" "}
63
+ {data.hq?.visibility === "private"
64
+ ? "The repo is private, so each commit is pushed."
65
+ : data.hq
66
+ ? "Commits stay local: the repo isn't a private GitHub repository."
67
+ : "It must be a private git repo outside the workspace's projects."}
68
+ </p>
69
+ </form>
70
+ );
71
+ }
@@ -6,6 +6,7 @@ import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
8
  import { McpServersForm } from "./McpServersForm";
9
+ import { HqFolderForm } from "./HqFolderForm";
9
10
  import { StallForm } from "./StallForm";
10
11
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
11
12
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
@@ -47,6 +48,7 @@ export function WorkspaceSettings() {
47
48
  <RulesForm workspaceId={w.id} />
48
49
  <WorkspaceModelsForm workspaceId={w.id} />
49
50
  <McpServersForm workspace={w} />
51
+ <HqFolderForm workspaceId={w.id} />
50
52
  <SettingToggle
51
53
  title="Wake tasks on CI failures and reviews"
52
54
  detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
@@ -71,3 +71,40 @@ export function useSetLogFolder(projectId: string) {
71
71
  queryClient.setQueryData(sessionLogKeys.project(projectId), data),
72
72
  });
73
73
  }
74
+
75
+ export interface WorkspaceHq {
76
+ hq: { folder: string; visibility: "private" | "internal" | "none" } | null;
77
+ }
78
+
79
+ const hqKey = (workspaceId: string) =>
80
+ [...sessionLogKeys.all, "hq", workspaceId] as const;
81
+
82
+ async function hqState(res: Response): Promise<WorkspaceHq> {
83
+ const data = await res.json();
84
+ if (!res.ok) throw new Error(data.error || "Request failed");
85
+ return data;
86
+ }
87
+
88
+ export function useWorkspaceHqQuery(workspaceId: string) {
89
+ return useQuery({
90
+ queryKey: hqKey(workspaceId),
91
+ queryFn: async () =>
92
+ hqState(await fetch(`/api/workspaces/${workspaceId}/session-logs`)),
93
+ staleTime: 30_000,
94
+ });
95
+ }
96
+
97
+ export function useSetWorkspaceHq(workspaceId: string) {
98
+ const queryClient = useQueryClient();
99
+ return useMutation({
100
+ mutationFn: async (folder: string) =>
101
+ hqState(
102
+ await fetch(`/api/workspaces/${workspaceId}/session-logs`, {
103
+ method: "PUT",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ folder }),
106
+ })
107
+ ),
108
+ onSuccess: (data) => queryClient.setQueryData(hqKey(workspaceId), data),
109
+ });
110
+ }
@@ -123,11 +123,17 @@ const usd = (x: number, floor: boolean) =>
123
123
  const url = (u: string) =>
124
124
  /^https?:\/\/[^\s<>()[\]`]+$/.test(u) ? `<${u}>` : codeSpan(u);
125
125
 
126
+ // A commit hash is not a secret, so it skips redact(): only a plain hash
127
+ // (or one the log labels as the PR head) is printed, anything else is not.
128
+ const SHA = /^[0-9a-f]{7,40}( \(PR head\))?$/;
129
+ export const commitText = (sha: string | null): string =>
130
+ sha && SHA.test(sha) ? `\`${sha}\`` : "unknown";
131
+
126
132
  function header(d: LogData): string {
127
133
  const start = sqliteMs(d.startedAt);
128
134
  const end = sqliteMs(d.mergedAt);
129
135
  const duration =
130
- start !== null && end !== null ? formatDuration(end - start) : "unknown";
136
+ start !== null && end !== null ? formatDuration(end - start) : null;
131
137
  const rows: [string, string][] = [
132
138
  ["Task", `${codeSpan(d.name)} (${codeSpan(d.id)})`],
133
139
  ["Workspace", d.workspace ? codeSpan(d.workspace) : "none"],
@@ -139,10 +145,10 @@ function header(d: LogData): string {
139
145
  ? `#${d.prNumber}${d.prUrl ? ` ${url(d.prUrl)}` : ""}`
140
146
  : "none recorded",
141
147
  ],
142
- ["Merge commit", d.mergeCommit ? codeSpan(d.mergeCommit) : "not recorded"],
148
+ ["Merge commit", commitText(d.mergeCommit)],
143
149
  ["Started", when(d.startedAt)],
144
- ["Merged", when(d.mergedAt)],
145
- ["Duration", duration],
150
+ ["Merged", d.mergedAt ? when(d.mergedAt) : "unknown"],
151
+ ...(duration ? [["Duration", duration] as [string, string]] : []),
146
152
  ];
147
153
  return rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n");
148
154
  }
@@ -1,8 +1,9 @@
1
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.
2
+ * Where a log's markdown file goes, first match wins: the project's own
3
+ * folder, then the workspace's HQ repo (<hq>/docs/sessions/<project>/), then
4
+ * ~/.agent-os/session-logs/<workspace>/<project>/. All as
5
+ * YYYY-MM-DD-<task>.md. Never inside the project's repository: repositories
6
+ * can be public, and a log holds briefs and reviews.
6
7
  */
7
8
 
8
9
  import fs from "fs";
@@ -10,7 +11,9 @@ import os from "os";
10
11
  import path from "path";
11
12
  import { db } from "../db";
12
13
  import { slugify } from "../git";
13
- import { getProject, getProjectRepositories } from "../projects";
14
+ import { getProject } from "../projects";
15
+ import { usableHq, type Visibility } from "./hq";
16
+ import { expand, repoPaths, resolved, within } from "./paths";
14
17
 
15
18
  // AGENTOS_SESSION_LOGS_DIR moves the default (tests keep it off the real one).
16
19
  export const DEFAULT_ROOT = () =>
@@ -19,39 +22,6 @@ export const DEFAULT_ROOT = () =>
19
22
 
20
23
  const key = (projectId: string) => `session-logs.folder.${projectId}`;
21
24
 
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
25
  // Throws a plain message when `dir` is inside one of the project's repos.
56
26
  export function assertOutsideRepos(projectId: string, dir: string): void {
57
27
  const target = resolved(dir);
@@ -91,6 +61,7 @@ export function setLogFolder(projectId: string, value: string | null): void {
91
61
 
92
62
  export interface FileTarget {
93
63
  workspace: string | null;
64
+ workspaceId?: string | null;
94
65
  project: string | null;
95
66
  projectId: string | null;
96
67
  name: string;
@@ -99,33 +70,56 @@ export interface FileTarget {
99
70
  day: string;
100
71
  }
101
72
 
73
+ export interface Destination {
74
+ dir: string;
75
+ // The HQ repo the folder belongs to, when the log goes into one.
76
+ hq: { dir: string; visibility: Visibility } | null;
77
+ }
78
+
102
79
  // 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 {
80
+ // still passes the repository check, else the workspace's HQ, else the
81
+ // default.
82
+ export function destination(t: FileTarget): Destination {
83
+ const projectSlug = slugify(t.project ?? "") || "no-project";
105
84
  const custom = t.projectId ? logFolder(t.projectId) : null;
106
85
  if (custom && t.projectId) {
107
86
  try {
108
87
  const dir = expand(custom);
109
88
  assertOutsideRepos(t.projectId, dir);
110
- return dir;
89
+ return { dir, hq: null };
111
90
  } catch (error) {
112
91
  console.error(
113
92
  `[session-logs] ignoring the project's log folder: ${error instanceof Error ? error.message : error}`
114
93
  );
115
94
  }
116
95
  }
117
- return path.join(
118
- DEFAULT_ROOT(),
119
- slugify(t.workspace ?? "") || "no-workspace",
120
- slugify(t.project ?? "") || "no-project"
121
- );
96
+ const workspaceId =
97
+ t.workspaceId ??
98
+ (t.projectId ? getProject(t.projectId)?.workspace_id : null) ??
99
+ null;
100
+ const hq = workspaceId ? usableHq(workspaceId) : null;
101
+ if (hq)
102
+ return { dir: path.join(hq.dir, "docs", "sessions", projectSlug), hq };
103
+ return {
104
+ dir: path.join(
105
+ DEFAULT_ROOT(),
106
+ slugify(t.workspace ?? "") || "no-workspace",
107
+ projectSlug
108
+ ),
109
+ hq: null,
110
+ };
122
111
  }
123
112
 
113
+ export const targetDir = (t: FileTarget): string => destination(t).dir;
114
+
124
115
  // Writes the log once and returns its path. A file that's already there
125
116
  // (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))
117
+ export function writeLogFileTo(
118
+ t: FileTarget,
119
+ markdown: string
120
+ ): { file: string; hq: Destination["hq"] } {
121
+ const { dir, hq } = destination(t);
122
+ if (t.projectId && !logFolder(t.projectId) && !hq)
129
123
  assertOutsideRepos(t.projectId, dir);
130
124
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
131
125
  const slug = slugify(t.name) || t.id.slice(0, 8);
@@ -136,10 +130,18 @@ export function writeLogFile(t: FileTarget, markdown: string): string {
136
130
  const full = path.join(dir, file);
137
131
  try {
138
132
  fs.writeFileSync(full, markdown, { flag: "wx", mode: 0o600 });
139
- return full;
133
+ return { file: full, hq };
140
134
  } catch (error) {
141
135
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
142
136
  }
143
137
  }
144
138
  throw new Error(`a log file for ${t.name} already exists in ${dir}`);
145
139
  }
140
+
141
+ export const writeLogFile = (t: FileTarget, markdown: string): string =>
142
+ writeLogFileTo(t, markdown).file;
143
+
144
+ // Rewrites a log's own file in place (never creates one, never moves it).
145
+ export function overwriteLogFile(file: string, markdown: string): void {
146
+ if (fs.existsSync(file)) fs.writeFileSync(file, markdown, { mode: 0o600 });
147
+ }
@@ -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
+ }
@@ -14,13 +14,27 @@ import { db, type Session } from "../db";
14
14
  import { getProject } from "../projects";
15
15
  import { run } from "../tasks/gh";
16
16
  import { buildLogMarkdown, sqliteMs } from "./build";
17
- import { writeLogFile } from "./folder";
17
+ import {
18
+ overwriteLogFile,
19
+ destination,
20
+ writeLogFileTo,
21
+ type Destination,
22
+ } from "./folder";
23
+ import { commitLogs } from "./hq";
18
24
  import { gatherLogData, readPlanFile } from "./gather";
19
- import { hasLog, insertLog, setFilePath } from "./store";
25
+ import {
26
+ getLog,
27
+ hasLog,
28
+ headerOf,
29
+ insertLog,
30
+ setFilePath,
31
+ updateLog,
32
+ } from "./store";
20
33
 
21
34
  export * from "./store";
22
35
  export { buildLogMarkdown } from "./build";
23
36
  export { logFolder, setLogFolder } from "./folder";
37
+ export { getHq, setHq } from "./hq";
24
38
 
25
39
  const inFlight = new Map<string, Promise<void>>();
26
40
  // Tasks known to have a log, so a poll that sees a merged PR again asks
@@ -38,6 +52,13 @@ export interface CaptureOpts {
38
52
  planKept?: boolean;
39
53
  // Skip asking GitHub for the merge commit (a batch of old tasks).
40
54
  noGh?: boolean;
55
+ // Given, the file is handed over instead of committed: a batch commits
56
+ // once at its end.
57
+ onFile?: (file: string, hq: NonNullable<Destination["hq"]>) => void;
58
+ // Regenerate a log that exists: update its row and file in place.
59
+ rewrite?: boolean;
60
+ // With rewrite: report whether the log would change, write nothing.
61
+ dryRun?: boolean;
41
62
  }
42
63
 
43
64
  function eligible(s: Session | undefined): s is Session {
@@ -50,31 +71,51 @@ function eligible(s: Session | undefined): s is Session {
50
71
  );
51
72
  }
52
73
 
53
- async function mergeCommitOf(s: Session): Promise<string | null> {
74
+ // GitHub's ISO time as SQLite's UTC "YYYY-MM-DD HH:MM:SS"; null when absent.
75
+ export function ghTimeToSqlite(iso: unknown): string | null {
76
+ if (typeof iso !== "string") return null;
77
+ const ms = Date.parse(iso);
78
+ return Number.isNaN(ms)
79
+ ? null
80
+ : new Date(ms).toISOString().replace("T", " ").slice(0, 19);
81
+ }
82
+
83
+ async function mergeInfoOf(
84
+ s: Session
85
+ ): Promise<{ commit: string | null; mergedAt: string | null }> {
86
+ const none = { commit: null, mergedAt: null };
54
87
  const project = s.project_id ? getProject(s.project_id) : undefined;
55
88
  if (!project || !s.pr_number || process.env.AGENTOS_SESSION_LOGS_NO_GH)
56
- return null;
89
+ return none;
57
90
  try {
58
91
  const out = await run(
59
92
  "gh",
60
- [
61
- "pr",
62
- "view",
63
- String(s.pr_number),
64
- "--json",
65
- "mergeCommit",
66
- "-q",
67
- ".mergeCommit.oid",
68
- ],
93
+ ["pr", "view", String(s.pr_number), "--json", "mergeCommit,mergedAt"],
69
94
  project.working_directory.replace(/^~/, os.homedir()),
70
95
  15_000
71
96
  );
72
- return out.trim() || null;
97
+ const j = JSON.parse(out) as {
98
+ mergeCommit?: { oid?: string } | null;
99
+ mergedAt?: string | null;
100
+ };
101
+ return {
102
+ commit: j.mergeCommit?.oid?.trim() || null,
103
+ mergedAt: ghTimeToSqlite(j.mergedAt),
104
+ };
73
105
  } catch {
74
- return null;
106
+ return none;
75
107
  }
76
108
  }
77
109
 
110
+ const planSection = (md: string) =>
111
+ /^## Plan\n[\s\S]*?(?=\n## Timeline\n)/m.exec(md)?.[0] ?? null;
112
+
113
+ function keepPlan(fresh: string, old: string): string {
114
+ const was = planSection(old);
115
+ const now = planSection(fresh);
116
+ return was && now ? fresh.replace(now, () => was) : fresh;
117
+ }
118
+
78
119
  export async function writeLog(
79
120
  id: string,
80
121
  opts: CaptureOpts & { plan?: string | null } = {}
@@ -82,23 +123,81 @@ export async function writeLog(
82
123
  const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
83
124
  | Session
84
125
  | 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);
126
+ const existing = getLog(id);
127
+ if (!eligible(row) || (existing && !opts.rewrite)) return false;
128
+ const info = opts.noGh
129
+ ? { commit: null, mergedAt: null }
130
+ : await mergeInfoOf(row);
131
+ const mergeCommit = info.commit;
132
+ const s = { ...row, merged_at: row.merged_at ?? info.mergedAt };
133
+ const kept = existing ? headerOf(existing) : null;
88
134
  const data = gatherLogData(s, {
89
135
  plan: opts.plan ?? null,
90
- planKept: opts.planKept ?? true,
136
+ planKept: kept ? kept.planKept : (opts.planKept ?? true),
91
137
  head: opts.head ?? null,
92
138
  mergeCommit: mergeCommit ?? (opts.head ? `${opts.head} (PR head)` : null),
93
139
  });
94
- const markdown = buildLogMarkdown(data);
140
+ let markdown = buildLogMarkdown(data);
141
+ // The plan file went with the worktree: keep the plan the log has.
142
+ if (existing && kept?.hasPlan)
143
+ markdown = keepPlan(markdown, existing.markdown);
95
144
  const start = sqliteMs(data.startedAt);
96
145
  const end = sqliteMs(data.mergedAt);
146
+ const workspaceId =
147
+ s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null;
148
+ if (existing) {
149
+ const changed = existing.markdown !== markdown;
150
+ if (opts.dryRun || !changed) return changed;
151
+ // Only the generated text changes; the row, its id and its file stay.
152
+ updateLog(id, markdown, {
153
+ id,
154
+ name: data.name,
155
+ workspace: data.workspace,
156
+ project: data.project,
157
+ branch: data.branch,
158
+ prNumber: data.prNumber,
159
+ prUrl: data.prUrl,
160
+ mergeCommit: data.mergeCommit,
161
+ startedAt: data.startedAt,
162
+ mergedAt: data.mergedAt,
163
+ durationMs: start !== null && end !== null ? end - start : null,
164
+ costUsd: data.usage.reduce((t, u) => t + u.costUsd, 0),
165
+ planKept: data.planKept,
166
+ hasPlan: kept?.hasPlan ?? data.plan !== null,
167
+ });
168
+ if (existing.file_path) {
169
+ try {
170
+ overwriteLogFile(existing.file_path, markdown);
171
+ const { hq } = destination({
172
+ workspace: data.workspace,
173
+ workspaceId,
174
+ project: data.project,
175
+ projectId: s.project_id,
176
+ name: data.name,
177
+ id,
178
+ day: "",
179
+ });
180
+ if (hq && existing.file_path.startsWith(hq.dir)) {
181
+ if (opts.onFile) opts.onFile(existing.file_path, hq);
182
+ else
183
+ await commitLogs(
184
+ hq.dir,
185
+ [existing.file_path],
186
+ `session log rewritten: ${data.name.replace(/\s+/g, " ").trim().slice(0, 100)}`,
187
+ hq.visibility
188
+ );
189
+ }
190
+ } catch (error) {
191
+ console.error(`[session-logs] file for ${id}:`, error);
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+ if (opts.dryRun) return true;
97
197
  const stored = insertLog({
98
198
  sessionId: id,
99
199
  projectId: s.project_id,
100
- workspaceId:
101
- s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null,
200
+ workspaceId,
102
201
  markdown,
103
202
  header: {
104
203
  id,
@@ -120,9 +219,10 @@ export async function writeLog(
120
219
  });
121
220
  if (!stored) return false;
122
221
  try {
123
- const file = writeLogFile(
222
+ const { file, hq } = writeLogFileTo(
124
223
  {
125
224
  workspace: data.workspace,
225
+ workspaceId,
126
226
  project: data.project,
127
227
  projectId: s.project_id,
128
228
  name: data.name,
@@ -132,6 +232,14 @@ export async function writeLog(
132
232
  markdown
133
233
  );
134
234
  setFilePath(id, file);
235
+ if (hq && opts.onFile) opts.onFile(file, hq);
236
+ else if (hq)
237
+ await commitLogs(
238
+ hq.dir,
239
+ [file],
240
+ `session log: ${data.name.replace(/\s+/g, " ").trim().slice(0, 100)}`,
241
+ hq.visibility
242
+ );
135
243
  } catch (error) {
136
244
  // The database holds the log; the file is a copy.
137
245
  console.error(`[session-logs] file for ${id}:`, error);
@@ -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
+ }
@@ -70,6 +70,17 @@ export function insertLog(row: {
70
70
  );
71
71
  }
72
72
 
73
+ // Replaces the text and header of a log that exists; the row stays.
74
+ export function updateLog(
75
+ sessionId: string,
76
+ markdown: string,
77
+ header: Omit<LogHeader, "createdAt" | "filePath">
78
+ ): void {
79
+ db.prepare(
80
+ `UPDATE session_logs SET markdown = ?, data = ? WHERE session_id = ?`
81
+ ).run(markdown, JSON.stringify(header), sessionId);
82
+ }
83
+
73
84
  export function setFilePath(sessionId: string, filePath: string): void {
74
85
  db.prepare(`UPDATE session_logs SET file_path = ? WHERE session_id = ?`).run(
75
86
  filePath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.505",
3
+ "version": "0.3.507",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * Writes session logs for tasks that merged before logs existed, from what
3
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.
4
+ * each says "plan not kept". Idempotent: a task with a log is skipped, unless --rewrite.
5
5
  *
6
- * npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry]
6
+ * npx tsx scripts/backfill-session-logs.ts --db <path to agent-os.db> [--no-gh] [--dry] [--dry-run] [--rewrite] [--reexport]
7
7
  *
8
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
9
+ * where new logs go: a project's folder, else the workspace's HQ repo (one
10
+ * commit per HQ at the end of the run), else ~/.agent-os/session-logs; run
10
11
  * with HOME set elsewhere to keep a trial away from the real folder.
11
- * --no-gh skips asking GitHub for each merge commit.
12
+ * --no-gh skips asking GitHub for each merge commit and time.
13
+ * --reexport writes the files of logs already in the database to where they
14
+ * go now, from the stored markdown (no rebuild, no model), and writes no new
15
+ * logs. Old files are left in place.
16
+ * --rewrite regenerates logs that exist (row and file updated in place, a
17
+ * kept plan stays); no model is called, only the one gh call per PR.
18
+ * --dry-run prints how many logs would be written or change, and writes none.
12
19
  */
13
20
 
14
21
  const args = process.argv.slice(2);
@@ -17,16 +24,27 @@ const dbPath = args[args.indexOf("--db") + 1];
17
24
 
18
25
  if (!flag("--db") || !dbPath || dbPath.startsWith("--")) {
19
26
  console.error(
20
- "usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry]"
27
+ "usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry] [--dry-run] [--rewrite] [--reexport]"
21
28
  );
22
29
  process.exit(2);
23
30
  }
24
31
  process.env.DB_PATH = dbPath;
25
32
 
33
+ type Visibility = "private" | "internal" | "none";
34
+
26
35
  async function main() {
36
+ if (flag("--reexport")) {
37
+ const { reexportLogs } = await import("../lib/session-logs/reexport");
38
+ const r = await reexportLogs({ dry: flag("--dry"), log: console.log });
39
+ console.log(
40
+ `${r.moved} ${flag("--dry") ? "to move" : "moved"}, ${r.unchanged} already in place, ${r.failed} failed, ${r.committed} committed`
41
+ );
42
+ return;
43
+ }
27
44
  const { db } = await import("../lib/db");
28
45
  const { writeLog } = await import("../lib/session-logs");
29
46
  const { hasLog } = await import("../lib/session-logs/store");
47
+ const { commitLogs } = await import("../lib/session-logs/hq");
30
48
  const rows = db
31
49
  .prepare(
32
50
  `SELECT id, name FROM sessions
@@ -35,30 +53,48 @@ async function main() {
35
53
  ORDER BY COALESCE(merged_at, updated_at)`
36
54
  )
37
55
  .all() as { id: string; name: string }[];
38
- const todo = rows.filter((r) => !hasLog(r.id));
56
+ const rewrite = flag("--rewrite");
57
+ const dry = flag("--dry") || flag("--dry-run");
58
+ const todo = rewrite ? rows : rows.filter((r) => !hasLog(r.id));
39
59
  console.log(
40
- `${rows.length} merged tasks, ${rows.length - todo.length} already logged, ${todo.length} to write${flag("--dry") ? " (dry run)" : ""}`
60
+ `${rows.length} merged tasks, ${rows.length - rows.filter((r) => !hasLog(r.id)).length} already logged, ${todo.length} to ${rewrite ? "regenerate" : "write"}${dry ? " (dry run)" : ""}`
41
61
  );
42
62
  let written = 0;
63
+ const byHq = new Map<string, { files: string[]; visibility: Visibility }>();
43
64
  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
65
  try {
49
66
  const ok = await writeLog(r.id, {
50
67
  planKept: false,
51
68
  noGh: flag("--no-gh"),
69
+ onFile: (file, hq) => {
70
+ const e = byHq.get(hq.dir) ?? {
71
+ files: [],
72
+ visibility: hq.visibility,
73
+ };
74
+ e.files.push(file);
75
+ byHq.set(hq.dir, e);
76
+ },
77
+ rewrite,
78
+ dryRun: dry,
52
79
  });
53
80
  if (ok) written++;
54
81
  console.log(
55
- ` ${ok ? "logged" : "skipped"} ${r.name} (${r.id.slice(0, 8)})`
82
+ ` ${ok ? (dry ? "would change" : rewrite ? "rewrote" : "logged") : "skipped"} ${r.name} (${r.id.slice(0, 8)})`
56
83
  );
57
84
  } catch (error) {
58
85
  console.error(` FAILED ${r.name} (${r.id.slice(0, 8)}):`, error);
59
86
  }
60
87
  }
61
- console.log(`wrote ${written} logs`);
88
+ for (const [dir, { files, visibility }] of byHq)
89
+ await commitLogs(
90
+ dir,
91
+ files,
92
+ `session logs: ${files.length} backfilled`,
93
+ visibility
94
+ );
95
+ console.log(
96
+ `${dry ? `${written} logs would change` : `wrote ${written} logs`}`
97
+ );
62
98
  }
63
99
 
64
100
  main().then(