@saadnvd1/agent-os 0.3.505 → 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 +27 -7
- package/app/api/workspaces/[id]/session-logs/route.ts +35 -0
- package/components/Workspaces/HqFolderForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/session-logs/index.ts +37 -0
- package/lib/session-logs/folder.ts +47 -50
- package/lib/session-logs/hq.ts +201 -0
- package/lib/session-logs/index.ts +19 -4
- package/lib/session-logs/paths.ts +37 -0
- package/lib/session-logs/reexport.ts +90 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +34 -3
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
|
-
|
|
1116
|
-
project's
|
|
1117
|
-
|
|
1118
|
-
|
|
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/<project>/ 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
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where a log's markdown file goes
|
|
3
|
-
*
|
|
4
|
-
* project
|
|
5
|
-
*
|
|
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
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
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,13 @@ 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;
|
|
@@ -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,15 @@ 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 {
|
|
17
|
+
import { writeLogFileTo, type Destination } from "./folder";
|
|
18
|
+
import { commitLogs } from "./hq";
|
|
18
19
|
import { gatherLogData, readPlanFile } from "./gather";
|
|
19
20
|
import { hasLog, insertLog, setFilePath } from "./store";
|
|
20
21
|
|
|
21
22
|
export * from "./store";
|
|
22
23
|
export { buildLogMarkdown } from "./build";
|
|
23
24
|
export { logFolder, setLogFolder } from "./folder";
|
|
25
|
+
export { getHq, setHq } from "./hq";
|
|
24
26
|
|
|
25
27
|
const inFlight = new Map<string, Promise<void>>();
|
|
26
28
|
// Tasks known to have a log, so a poll that sees a merged PR again asks
|
|
@@ -38,6 +40,9 @@ export interface CaptureOpts {
|
|
|
38
40
|
planKept?: boolean;
|
|
39
41
|
// Skip asking GitHub for the merge commit (a batch of old tasks).
|
|
40
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;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
function eligible(s: Session | undefined): s is Session {
|
|
@@ -94,11 +99,12 @@ export async function writeLog(
|
|
|
94
99
|
const markdown = buildLogMarkdown(data);
|
|
95
100
|
const start = sqliteMs(data.startedAt);
|
|
96
101
|
const end = sqliteMs(data.mergedAt);
|
|
102
|
+
const workspaceId =
|
|
103
|
+
s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null;
|
|
97
104
|
const stored = insertLog({
|
|
98
105
|
sessionId: id,
|
|
99
106
|
projectId: s.project_id,
|
|
100
|
-
workspaceId
|
|
101
|
-
s.workspace_id ?? getProject(s.project_id ?? "")?.workspace_id ?? null,
|
|
107
|
+
workspaceId,
|
|
102
108
|
markdown,
|
|
103
109
|
header: {
|
|
104
110
|
id,
|
|
@@ -120,9 +126,10 @@ export async function writeLog(
|
|
|
120
126
|
});
|
|
121
127
|
if (!stored) return false;
|
|
122
128
|
try {
|
|
123
|
-
const file =
|
|
129
|
+
const { file, hq } = writeLogFileTo(
|
|
124
130
|
{
|
|
125
131
|
workspace: data.workspace,
|
|
132
|
+
workspaceId,
|
|
126
133
|
project: data.project,
|
|
127
134
|
projectId: s.project_id,
|
|
128
135
|
name: data.name,
|
|
@@ -132,6 +139,14 @@ export async function writeLog(
|
|
|
132
139
|
markdown
|
|
133
140
|
);
|
|
134
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
|
+
);
|
|
135
150
|
} catch (error) {
|
|
136
151
|
// The database holds the log; the file is a copy.
|
|
137
152
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* the database still holds. Their plan files went with their worktrees, so
|
|
4
4
|
* each says "plan not kept". Idempotent: a task with a log is skipped.
|
|
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] [--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
|
|
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
12
|
* --no-gh skips asking GitHub for each merge commit.
|
|
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.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
const args = process.argv.slice(2);
|
|
@@ -17,16 +21,27 @@ const dbPath = args[args.indexOf("--db") + 1];
|
|
|
17
21
|
|
|
18
22
|
if (!flag("--db") || !dbPath || dbPath.startsWith("--")) {
|
|
19
23
|
console.error(
|
|
20
|
-
"usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry]"
|
|
24
|
+
"usage: backfill-session-logs.ts --db <path> [--no-gh] [--dry] [--reexport]"
|
|
21
25
|
);
|
|
22
26
|
process.exit(2);
|
|
23
27
|
}
|
|
24
28
|
process.env.DB_PATH = dbPath;
|
|
25
29
|
|
|
30
|
+
type Visibility = "private" | "internal" | "none";
|
|
31
|
+
|
|
26
32
|
async function main() {
|
|
33
|
+
if (flag("--reexport")) {
|
|
34
|
+
const { reexportLogs } = await import("../lib/session-logs/reexport");
|
|
35
|
+
const r = await reexportLogs({ dry: flag("--dry"), log: console.log });
|
|
36
|
+
console.log(
|
|
37
|
+
`${r.moved} ${flag("--dry") ? "to move" : "moved"}, ${r.unchanged} already in place, ${r.failed} failed, ${r.committed} committed`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
27
41
|
const { db } = await import("../lib/db");
|
|
28
42
|
const { writeLog } = await import("../lib/session-logs");
|
|
29
43
|
const { hasLog } = await import("../lib/session-logs/store");
|
|
44
|
+
const { commitLogs } = await import("../lib/session-logs/hq");
|
|
30
45
|
const rows = db
|
|
31
46
|
.prepare(
|
|
32
47
|
`SELECT id, name FROM sessions
|
|
@@ -40,6 +55,7 @@ async function main() {
|
|
|
40
55
|
`${rows.length} merged tasks, ${rows.length - todo.length} already logged, ${todo.length} to write${flag("--dry") ? " (dry run)" : ""}`
|
|
41
56
|
);
|
|
42
57
|
let written = 0;
|
|
58
|
+
const byHq = new Map<string, { files: string[]; visibility: Visibility }>();
|
|
43
59
|
for (const r of todo) {
|
|
44
60
|
if (flag("--dry")) {
|
|
45
61
|
console.log(` would log ${r.name} (${r.id.slice(0, 8)})`);
|
|
@@ -49,6 +65,14 @@ async function main() {
|
|
|
49
65
|
const ok = await writeLog(r.id, {
|
|
50
66
|
planKept: false,
|
|
51
67
|
noGh: flag("--no-gh"),
|
|
68
|
+
onFile: (file, hq) => {
|
|
69
|
+
const e = byHq.get(hq.dir) ?? {
|
|
70
|
+
files: [],
|
|
71
|
+
visibility: hq.visibility,
|
|
72
|
+
};
|
|
73
|
+
e.files.push(file);
|
|
74
|
+
byHq.set(hq.dir, e);
|
|
75
|
+
},
|
|
52
76
|
});
|
|
53
77
|
if (ok) written++;
|
|
54
78
|
console.log(
|
|
@@ -58,6 +82,13 @@ async function main() {
|
|
|
58
82
|
console.error(` FAILED ${r.name} (${r.id.slice(0, 8)}):`, error);
|
|
59
83
|
}
|
|
60
84
|
}
|
|
85
|
+
for (const [dir, { files, visibility }] of byHq)
|
|
86
|
+
await commitLogs(
|
|
87
|
+
dir,
|
|
88
|
+
files,
|
|
89
|
+
`session logs: ${files.length} backfilled`,
|
|
90
|
+
visibility
|
|
91
|
+
);
|
|
61
92
|
console.log(`wrote ${written} logs`);
|
|
62
93
|
}
|
|
63
94
|
|