@saadnvd1/agent-os 0.3.662 → 0.3.664
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/app/api/sessions/[id]/route.ts +3 -3
- package/app/api/worktree-sweep/route.test.ts +19 -1
- package/app/api/worktree-sweep/route.ts +25 -6
- package/components/Health/SweepRuns.tsx +76 -1
- package/data/worktree-sweep/index.ts +15 -0
- package/lib/agentos-home.ts +11 -0
- package/lib/agents/launch.ts +3 -7
- package/lib/artifacts/store.ts +2 -5
- package/lib/chat/drivers/pi-approvals.ts +2 -2
- package/lib/chat/worker/protocol.ts +2 -2
- package/lib/connect/config.ts +2 -3
- package/lib/db/backup.ts +2 -2
- package/lib/db/migrations/20261018T1600_health_boot_deploys.ts +13 -0
- package/lib/dev-instance/processInfo.ts +48 -27
- package/lib/dev-instance/startDevServer.ts +1 -1
- package/lib/dev-instance/stopDevServer.ts +20 -17
- package/lib/dev-servers.ts +2 -1
- package/lib/done/index.ts +2 -2
- package/lib/health/boots.ts +37 -0
- package/lib/health/ci.ts +23 -2
- package/lib/health/detectors.ts +2 -1
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +3 -3
- package/lib/health/fixtures.ts +1 -0
- package/lib/health/tick.ts +6 -1
- package/lib/health/types.ts +2 -0
- package/lib/orchestrator/home.ts +2 -5
- package/lib/orchestrator/shell-guard.ts +2 -0
- package/lib/orchestrator/write-guard.ts +3 -2
- package/lib/plans/features.ts +2 -2
- package/lib/program-status/claude-hooks.ts +2 -2
- package/lib/program-status/tap.ts +2 -2
- package/lib/project-config/database.ts +71 -16
- package/lib/project-config/session.ts +18 -0
- package/lib/security/tailnet-https.ts +2 -2
- package/lib/session-artifacts/collect.ts +18 -1
- package/lib/session-artifacts/keep.ts +2 -4
- package/lib/session-audit/store.ts +2 -5
- package/lib/session-logs/folder.ts +2 -3
- package/lib/sessions/launch.ts +2 -2
- package/lib/sessions/leftovers.ts +61 -0
- package/lib/simulator/agent-device.ts +2 -2
- package/lib/tasks/finish.ts +2 -2
- package/lib/tasks/import.ts +3 -5
- package/lib/tasks/move.ts +2 -2
- package/lib/tasks/moved-away.ts +2 -0
- package/lib/tasks/state.ts +7 -3
- package/lib/tasks/ui-brief.ts +2 -2
- package/lib/worktree-deps/fastcopy.ts +2 -2
- package/lib/worktree-deps/spare.ts +2 -2
- package/lib/worktree-sweep/caches.ts +123 -0
- package/lib/worktree-sweep/databases.ts +222 -0
- package/lib/worktree-sweep/du.ts +13 -0
- package/lib/worktree-sweep/extras.ts +188 -0
- package/lib/worktree-sweep/findOrphans.ts +19 -8
- package/lib/worktree-sweep/report.ts +85 -0
- package/lib/worktree-sweep/runSweep.ts +46 -13
- package/lib/worktree-sweep/settings.ts +24 -0
- package/lib/worktree-sweep/state.ts +5 -0
- package/lib/worktree-sweep/store.ts +11 -0
- package/lib/worktree-sweep/types.ts +44 -1
- package/lib/worktrees.ts +3 -0
- package/package.json +2 -1
- package/scripts/disk-dry-run.ts +57 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task leaves outside its checkout, released when it ends (merge,
|
|
3
|
+
* drop, done, move, delete): its dev server, its private database and what
|
|
4
|
+
* the project derived from it, and its temp folder. The worktree itself is
|
|
5
|
+
* removed by the caller, which knows whether anything in it would be lost.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { stopDevServer } from "../dev-instance/stopDevServer";
|
|
12
|
+
import { dropSessionDatabase } from "../project-config/database";
|
|
13
|
+
|
|
14
|
+
export const SESSION_TMP_PREFIX = "agentos-s-";
|
|
15
|
+
|
|
16
|
+
// The whole id: a shortened one could name two sessions' folders alike.
|
|
17
|
+
export const tmpName = (sessionId: string) => sessionId.replace(/[^\w-]/g, "");
|
|
18
|
+
|
|
19
|
+
export const sessionTmpDir = (sessionId: string) =>
|
|
20
|
+
path.join(os.tmpdir(), `${SESSION_TMP_PREFIX}${tmpName(sessionId)}`);
|
|
21
|
+
|
|
22
|
+
export interface LeftoverDeps {
|
|
23
|
+
stopDev: (worktree: string) => Promise<unknown>;
|
|
24
|
+
dropDatabase: (sessionId: string) => Promise<void>;
|
|
25
|
+
removeDir: (dir: string) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const realDeps: LeftoverDeps = {
|
|
29
|
+
stopDev: (worktree) => stopDevServer(worktree),
|
|
30
|
+
dropDatabase: dropSessionDatabase,
|
|
31
|
+
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Never throws and never waits on a step that can't be reached: each is
|
|
36
|
+
* tried on its own. Callers read the session row first and don't await this.
|
|
37
|
+
*/
|
|
38
|
+
export async function releaseSessionLeftovers(
|
|
39
|
+
session: { id: string; worktree_path?: string | null },
|
|
40
|
+
deps: Partial<LeftoverDeps> = {}
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const d = { ...realDeps, ...deps };
|
|
43
|
+
const steps: Array<[string, () => Promise<unknown>]> = [
|
|
44
|
+
// First: its row is read before the caller deletes it.
|
|
45
|
+
["database", () => d.dropDatabase(session.id)],
|
|
46
|
+
[
|
|
47
|
+
"dev server",
|
|
48
|
+
async () => {
|
|
49
|
+
if (session.worktree_path) await d.stopDev(session.worktree_path);
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
["temp folder", () => d.removeDir(sessionTmpDir(session.id))],
|
|
53
|
+
];
|
|
54
|
+
for (const [what, step] of steps) {
|
|
55
|
+
try {
|
|
56
|
+
await step();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`[leftovers] ${what} for ${session.id}:`, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* here, so it can only drive its own simulator.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { execFile } from "child_process";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ const execFileAsync = promisify(execFile);
|
|
|
15
15
|
|
|
16
16
|
export const AGENT_DEVICE_VERSION = "0.21.27";
|
|
17
17
|
|
|
18
|
-
const root = () =>
|
|
18
|
+
const root = () => agentosHome();
|
|
19
19
|
export const toolDir = () =>
|
|
20
20
|
path.join(root(), "tools", `agent-device-${AGENT_DEVICE_VERSION}`);
|
|
21
21
|
export const toolBin = () =>
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
prFor,
|
|
35
35
|
projectPathFor,
|
|
36
36
|
} from "./session";
|
|
37
|
-
import {
|
|
37
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
38
38
|
import { captureMergedTask } from "../session-logs";
|
|
39
39
|
import { sessionEnded } from "../experiments/hooks";
|
|
40
40
|
import { snapshotWorktree } from "../resurrect/snapshot";
|
|
@@ -62,7 +62,7 @@ async function cleanup(
|
|
|
62
62
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
63
63
|
await endSessionAgent(session);
|
|
64
64
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
65
|
-
void
|
|
65
|
+
void releaseSessionLeftovers(session);
|
|
66
66
|
if (session.worktree_path && opts.merged) {
|
|
67
67
|
const fate = await settleWorktree(session, opts.merged).catch(() => null);
|
|
68
68
|
if (fate?.action === "kept")
|
package/lib/tasks/import.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* again returns the task it made the first time.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
@@ -13,10 +14,7 @@ import { db, queries, type Session } from "../db";
|
|
|
13
14
|
import { setupWorktree } from "../env-setup";
|
|
14
15
|
import { allocatePorts } from "../ports";
|
|
15
16
|
import { loadProjectConfig, portBases } from "../project-config";
|
|
16
|
-
import {
|
|
17
|
-
dropSessionDatabase,
|
|
18
|
-
ensureSessionDatabase,
|
|
19
|
-
} from "../project-config/database";
|
|
17
|
+
import { ensureSessionDatabase } from "../project-config/database";
|
|
20
18
|
import { runInBackground } from "../async-operations";
|
|
21
19
|
import { launchClaude } from "../agents/launch";
|
|
22
20
|
import { resolveModelForAgent } from "../model-catalog";
|
|
@@ -238,7 +236,7 @@ async function arrive(
|
|
|
238
236
|
} catch (err) {
|
|
239
237
|
// Nothing runs here, so the source may resume it: leave no row behind,
|
|
240
238
|
// and no database (the drop reads the row before it goes).
|
|
241
|
-
void
|
|
239
|
+
void releaseSessionLeftovers({ id });
|
|
242
240
|
if (bundle.chat) {
|
|
243
241
|
stopChat(id);
|
|
244
242
|
dropChat(id);
|
package/lib/tasks/move.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "./move-bundle";
|
|
27
27
|
import { stepProgress } from "./move-progress";
|
|
28
28
|
import { PLAN_FILE } from "../models/plan-step";
|
|
29
|
-
import {
|
|
29
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
30
30
|
import { releaseChat } from "../chat/runner";
|
|
31
31
|
import { collectWhileMoving } from "../chat/hold";
|
|
32
32
|
import { packChat, stopChatForMove } from "./move-chat";
|
|
@@ -258,7 +258,7 @@ export function markMoved(id: string, to: string): void {
|
|
|
258
258
|
{ task_status: ["running", "moving"] }
|
|
259
259
|
);
|
|
260
260
|
// It goes on there with a copy of its own.
|
|
261
|
-
if (moved) void
|
|
261
|
+
if (moved) void releaseSessionLeftovers({ id });
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
/**
|
package/lib/tasks/moved-away.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* no commit a remote lacks). The row stays, archived, as history.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { releaseSessionLeftovers } from "../sessions/leftovers";
|
|
8
9
|
import { db, type Session } from "../db";
|
|
9
10
|
import { endSessionAgent } from "../sessions/end";
|
|
10
11
|
import { releasePorts } from "../ports";
|
|
@@ -28,6 +29,7 @@ export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
|
|
|
28
29
|
path: s.worktree_path ?? "",
|
|
29
30
|
})
|
|
30
31
|
);
|
|
32
|
+
void releaseSessionLeftovers(s);
|
|
31
33
|
archiveSession(id);
|
|
32
34
|
if (fate.action === "kept")
|
|
33
35
|
console.warn(
|
package/lib/tasks/state.ts
CHANGED
|
@@ -137,6 +137,10 @@ const UNFINISHED = [
|
|
|
137
137
|
const outcome = (c: RollupEntry) =>
|
|
138
138
|
(c.conclusion || c.state || c.status || "").toUpperCase();
|
|
139
139
|
|
|
140
|
+
// A skipped or neutral run is settled and says nothing about a rerun, so it
|
|
141
|
+
// never makes a check look unsettled.
|
|
142
|
+
const counts = (c: RollupEntry) => !["SKIPPED", "NEUTRAL"].includes(outcome(c));
|
|
143
|
+
|
|
140
144
|
// Job names repeat across workflows, and a status context can share a check
|
|
141
145
|
// run's name, so a check is its kind, workflow and name together.
|
|
142
146
|
const checkKey = (c: RollupEntry) => {
|
|
@@ -198,7 +202,7 @@ const groupByCheck = <T extends RollupEntry>(rollup: T[]) => {
|
|
|
198
202
|
// rollup can't tell one attempt from the next.
|
|
199
203
|
function shownAttempts(rollup: RollupEntry[]): Map<string, number | null> {
|
|
200
204
|
const shown = new Map<string, number | null>();
|
|
201
|
-
for (const c of rollup) {
|
|
205
|
+
for (const c of rollup.filter(counts)) {
|
|
202
206
|
const run = actionsRunOf(c);
|
|
203
207
|
if (!run) continue;
|
|
204
208
|
const key = `${run.repo}#${run.id}`;
|
|
@@ -226,7 +230,7 @@ export function rerunsToLookUp(
|
|
|
226
230
|
string,
|
|
227
231
|
{ repo: string; id: string; attempt: string | null }
|
|
228
232
|
>();
|
|
229
|
-
for (const group of groupByCheck(rollup).values()) {
|
|
233
|
+
for (const group of groupByCheck(rollup.filter(counts)).values()) {
|
|
230
234
|
if (group.length < 2) continue;
|
|
231
235
|
for (const c of group) {
|
|
232
236
|
const run = actionsRunOf(c);
|
|
@@ -259,7 +263,7 @@ export function settleReruns<T extends RollupEntry>(
|
|
|
259
263
|
const shown = shownAttempts(rollup);
|
|
260
264
|
const drop = new Set<RollupEntry>();
|
|
261
265
|
const unsettled: RollupEntry[] = [];
|
|
262
|
-
for (const group of groupByCheck(rollup).values()) {
|
|
266
|
+
for (const group of groupByCheck(rollup.filter(counts)).values()) {
|
|
263
267
|
if (group.length < 2) continue;
|
|
264
268
|
const located = group.map((c) => ({ c, run: actionsRunOf(c) }));
|
|
265
269
|
// Not all GitHub Actions: nothing says which run is stale, so all count.
|
package/lib/tasks/ui-brief.ts
CHANGED
|
@@ -80,6 +80,6 @@ If your diff changes what a user sees, run the app and look at every changed scr
|
|
|
80
80
|
- Log in and go to pages by URL, from the login, pages and map under "Running this project" when they are there. Exploring the UI to find a page is the most expensive thing a session does.
|
|
81
81
|
- One snapshot to find a uid, then act. Verify with a screenshot of the one element or a small \`evaluate_script\` returning the one value you need, not another full snapshot.
|
|
82
82
|
- Verify the thing that would be wrong, not the thing that is easy to see: a page that rendered proves it rendered. Fix anything visibly off (gaps, clipping, overlap, horizontal scroll, stray bands, misalignment; measure rects with \`evaluate_script\` and report numbers) and read the console for errors.
|
|
83
|
-
- Save screenshots of the final changed screens with \`filePath\` under \`${SCREENSHOT_DIR}/\`, after \`mkdir -p ${SCREENSHOT_DIR} && printf '*\\n' > .agentos/.gitignore\`. That folder is inside the browser's allowed root and git ignores it; a path in /tmp is refused.
|
|
84
|
-
- Add a **Browser check** section to the PR body: the pages and widths you checked, what you fixed, and the screenshot file names. If you didn't check (no visible change, or no browser), say why in one line.`;
|
|
83
|
+
- Save screenshots of the final changed screens with \`filePath\` under \`${SCREENSHOT_DIR}/\`, after \`mkdir -p ${SCREENSHOT_DIR} && printf '*\\n' > .agentos/.gitignore\`. That folder is inside the browser's allowed root and git ignores it; a path in /tmp is refused. AgentOS keeps these files after the worktree is removed.
|
|
84
|
+
- Add a **Browser check** section to the PR body: the pages and widths you checked, what you fixed, and the screenshot file names (names only, never a worktree path: the worktree is removed when the task ends). If you didn't check (no visible change, or no browser), say why in one line.`;
|
|
85
85
|
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* none works, and the caller installs or copies plainly instead.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { execFile } from "child_process";
|
|
9
10
|
import * as fs from "fs";
|
|
10
|
-
import * as os from "os";
|
|
11
11
|
import * as path from "path";
|
|
12
12
|
import { promisify } from "util";
|
|
13
13
|
import type { CopyMode } from "./spare";
|
|
@@ -18,7 +18,7 @@ export type FastMode = Exclude<CopyMode, "copy">;
|
|
|
18
18
|
|
|
19
19
|
// Where hardlinked dependencies are kept: one real copy per lockfile, so a
|
|
20
20
|
// write through a link can only ever reach this, never the main checkout.
|
|
21
|
-
export const STORE_ROOT =
|
|
21
|
+
export const STORE_ROOT = agentosHome("deps-store");
|
|
22
22
|
|
|
23
23
|
// Files tools rewrite in place. A hardlink would carry the write back into
|
|
24
24
|
// the store, so these are real copies in every worktree.
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
* one made before the dependencies changed is never taken, and is pruned.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
11
12
|
import { createHash } from "crypto";
|
|
12
13
|
import { spawn } from "child_process";
|
|
13
14
|
import * as fs from "fs";
|
|
14
|
-
import * as os from "os";
|
|
15
15
|
import * as path from "path";
|
|
16
16
|
|
|
17
|
-
export const SPARE_ROOT =
|
|
17
|
+
export const SPARE_ROOT = agentosHome("spare");
|
|
18
18
|
|
|
19
19
|
// A half-built spare left by a restart is removed after this long.
|
|
20
20
|
const BUILDING_TTL_MS = 30 * 60 * 1000;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task leaves outside its checkout and the database: dependency
|
|
3
|
+
* spares nobody took, its temp folder, a crashed preview browser's profile,
|
|
4
|
+
* and Xcode's build folder for a worktree that no longer exists. Each is
|
|
5
|
+
* rebuilt or recreated on demand, so the only rule is "old enough, and not
|
|
6
|
+
* in use".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { execFile } from "child_process";
|
|
12
|
+
import { SESSION_TMP_PREFIX, tmpName } from "../sessions/leftovers";
|
|
13
|
+
import type { Candidate, SweepSkipped } from "./types";
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 3600_000;
|
|
16
|
+
const SPARE_MIN_AGE_MS = 7 * DAY_MS;
|
|
17
|
+
const BUILDING_MIN_AGE_MS = 3600_000;
|
|
18
|
+
|
|
19
|
+
export interface CacheDeps {
|
|
20
|
+
now: number;
|
|
21
|
+
spareRoot: string;
|
|
22
|
+
tmpRoot: string;
|
|
23
|
+
derivedRoot: string;
|
|
24
|
+
worktreesRoot: string;
|
|
25
|
+
// Ids of the sessions still running on this machine.
|
|
26
|
+
liveIds: Set<string>;
|
|
27
|
+
workspaceOf: (derivedDir: string) => Promise<string | null>;
|
|
28
|
+
size: (dir: string) => Promise<number>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const workspaceFromPlist = (dir: string): Promise<string | null> =>
|
|
32
|
+
new Promise((resolve) =>
|
|
33
|
+
execFile(
|
|
34
|
+
"plutil",
|
|
35
|
+
[
|
|
36
|
+
"-extract",
|
|
37
|
+
"WorkspacePath",
|
|
38
|
+
"raw",
|
|
39
|
+
"-o",
|
|
40
|
+
"-",
|
|
41
|
+
path.join(dir, "info.plist"),
|
|
42
|
+
],
|
|
43
|
+
{ timeout: 10_000 },
|
|
44
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim() || null)
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const subdirs = (root: string): string[] => {
|
|
49
|
+
try {
|
|
50
|
+
return fs
|
|
51
|
+
.readdirSync(root, { withFileTypes: true })
|
|
52
|
+
.filter((e) => e.isDirectory())
|
|
53
|
+
.map((e) => path.join(root, e.name));
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mtime = (p: string) => {
|
|
60
|
+
try {
|
|
61
|
+
return fs.statSync(p).mtimeMs;
|
|
62
|
+
} catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const inside = (root: string, p: string) => {
|
|
68
|
+
const rel = path.relative(path.resolve(root), path.resolve(p));
|
|
69
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function findCacheLeftovers(deps: CacheDeps): Promise<{
|
|
73
|
+
candidates: Candidate[];
|
|
74
|
+
skipped: SweepSkipped[];
|
|
75
|
+
}> {
|
|
76
|
+
const candidates: Candidate[] = [];
|
|
77
|
+
const skipped: SweepSkipped[] = [];
|
|
78
|
+
const add = async (dir: string, kind: Candidate["kind"], why: string) =>
|
|
79
|
+
candidates.push({ path: dir, kind, why, bytes: await deps.size(dir) });
|
|
80
|
+
|
|
81
|
+
for (const dir of subdirs(deps.spareRoot)) {
|
|
82
|
+
const building = path.basename(dir).includes(".");
|
|
83
|
+
const min = building ? BUILDING_MIN_AGE_MS : SPARE_MIN_AGE_MS;
|
|
84
|
+
if (deps.now - mtime(dir) >= min)
|
|
85
|
+
await add(
|
|
86
|
+
dir,
|
|
87
|
+
"spare",
|
|
88
|
+
building ? "an abandoned build" : "unused for a week"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const livePrefixes = [...deps.liveIds].map(tmpName);
|
|
93
|
+
for (const dir of subdirs(deps.tmpRoot)) {
|
|
94
|
+
const name = path.basename(dir);
|
|
95
|
+
const isSession = name.startsWith(SESSION_TMP_PREFIX);
|
|
96
|
+
if (!isSession && !name.startsWith("agentos-preview-")) continue;
|
|
97
|
+
if (deps.now - mtime(dir) < DAY_MS) continue;
|
|
98
|
+
if (
|
|
99
|
+
isSession &&
|
|
100
|
+
livePrefixes.includes(name.slice(SESSION_TMP_PREFIX.length))
|
|
101
|
+
) {
|
|
102
|
+
skipped.push({
|
|
103
|
+
path: dir,
|
|
104
|
+
kind: "temp",
|
|
105
|
+
reason: "its session is running",
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
await add(
|
|
110
|
+
dir,
|
|
111
|
+
"temp",
|
|
112
|
+
isSession ? "its task is over" : "a preview browser's profile"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const dir of subdirs(deps.derivedRoot)) {
|
|
117
|
+
const workspace = await deps.workspaceOf(dir);
|
|
118
|
+
if (!workspace || !inside(deps.worktreesRoot, workspace)) continue;
|
|
119
|
+
if (fs.existsSync(workspace)) continue;
|
|
120
|
+
await add(dir, "derived-data", "its worktree is gone");
|
|
121
|
+
}
|
|
122
|
+
return { candidates, skipped };
|
|
123
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task databases left behind: copies and what projects derived from them
|
|
3
|
+
* (`<copy>_test`), found by the `_aos_<12 hex>` token every session's name
|
|
4
|
+
* carries. A database goes only if its token belongs to no live session,
|
|
5
|
+
* nobody is connected, it isn't a project's declared source, and it is more
|
|
6
|
+
* than a day old. Anything without the token is never looked at.
|
|
7
|
+
*
|
|
8
|
+
* A session is over only when it is merged, dropped, done or moved away, or
|
|
9
|
+
* its row is gone: archiving and settling can be undone, and an interactive
|
|
10
|
+
* session has no end of its own, so neither keeps a database off the list.
|
|
11
|
+
*
|
|
12
|
+
* Proof of who made it: a copy must carry the `agentos session <id>` comment
|
|
13
|
+
* AgentOS gave it, and the id must hash to the token. Something a project
|
|
14
|
+
* derived from a copy (`<copy>_test`) has no such comment, so it also needs
|
|
15
|
+
* its token to belong to no session this machine has ever known, and the
|
|
16
|
+
* server to be local (a host that isn't localhost or a socket is never
|
|
17
|
+
* reaped on a name alone).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { db } from "../db";
|
|
21
|
+
import { loadProjectConfig } from "../project-config";
|
|
22
|
+
import { expandHome } from "../project-config/session";
|
|
23
|
+
import {
|
|
24
|
+
LOCAL_HOSTS,
|
|
25
|
+
MARK,
|
|
26
|
+
SOCKET_DIR,
|
|
27
|
+
pgEnv,
|
|
28
|
+
pgRun,
|
|
29
|
+
sessionHash,
|
|
30
|
+
taskHashOf,
|
|
31
|
+
type PgRun,
|
|
32
|
+
} from "../project-config/database";
|
|
33
|
+
import type { Candidate, SweepSkipped } from "./types";
|
|
34
|
+
|
|
35
|
+
const DAY_MS = 24 * 3600_000;
|
|
36
|
+
|
|
37
|
+
export interface PgServer {
|
|
38
|
+
host?: string;
|
|
39
|
+
port?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DatabaseDeps {
|
|
43
|
+
now: number;
|
|
44
|
+
run: PgRun;
|
|
45
|
+
servers: PgServer[];
|
|
46
|
+
// Hashes of the sessions that still need their database.
|
|
47
|
+
live: Set<string>;
|
|
48
|
+
// Hashes of every session this machine has a row for, over or not.
|
|
49
|
+
known: Set<string>;
|
|
50
|
+
// Databases a project declares as its source, never reaped.
|
|
51
|
+
froms: Set<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const where = (s: PgServer) =>
|
|
55
|
+
`${s.host ?? "local server"}${s.port ? `:${s.port}` : ""}`;
|
|
56
|
+
|
|
57
|
+
// Sessions whose work isn't over: everything with a row except a task that
|
|
58
|
+
// merged, was dropped, finished or moved away.
|
|
59
|
+
export function liveSessionIds(): Set<string> {
|
|
60
|
+
const rows = db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT id FROM session_rows
|
|
63
|
+
WHERE task_status IS NULL
|
|
64
|
+
OR task_status NOT IN ('merged', 'dropped', 'done', 'moved')`
|
|
65
|
+
)
|
|
66
|
+
.all() as Array<{ id: string }>;
|
|
67
|
+
return new Set(rows.map((r) => r.id));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const knownSessionIds = (): Set<string> =>
|
|
71
|
+
new Set(
|
|
72
|
+
(
|
|
73
|
+
db.prepare(`SELECT id FROM session_rows`).all() as Array<{ id: string }>
|
|
74
|
+
).map((r) => r.id)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export const hashesOf = (ids: Iterable<string>): Set<string> =>
|
|
78
|
+
new Set([...ids].map(sessionHash));
|
|
79
|
+
|
|
80
|
+
export const liveHashes = (ids = liveSessionIds()): Set<string> =>
|
|
81
|
+
hashesOf(ids);
|
|
82
|
+
|
|
83
|
+
const isLocal = (s: PgServer) =>
|
|
84
|
+
!s.host || LOCAL_HOSTS.has(s.host) || SOCKET_DIR.test(s.host);
|
|
85
|
+
|
|
86
|
+
// Every project's declared database source and server.
|
|
87
|
+
export function declaredDatabases(): {
|
|
88
|
+
servers: PgServer[];
|
|
89
|
+
froms: Set<string>;
|
|
90
|
+
} {
|
|
91
|
+
const rows = db
|
|
92
|
+
.prepare(
|
|
93
|
+
`SELECT working_directory FROM projects WHERE working_directory IS NOT NULL`
|
|
94
|
+
)
|
|
95
|
+
.all() as Array<{ working_directory: string }>;
|
|
96
|
+
const servers = new Map<string, PgServer>([[":", {}]]);
|
|
97
|
+
const froms = new Set<string>();
|
|
98
|
+
for (const r of rows) {
|
|
99
|
+
try {
|
|
100
|
+
const d = loadProjectConfig(expandHome(r.working_directory)).config
|
|
101
|
+
.database;
|
|
102
|
+
if (!d) continue;
|
|
103
|
+
froms.add(d.from);
|
|
104
|
+
servers.set(`${d.host ?? ""}:${d.port ?? ""}`, {
|
|
105
|
+
host: d.host,
|
|
106
|
+
port: d.port,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// A project whose config can't be read declares nothing.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { servers: [...servers.values()], froms };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const LIST = (age: boolean) =>
|
|
116
|
+
`select d.datname, pg_database_size(d.oid), coalesce(shobj_description(d.oid, 'pg_database'), ''),
|
|
117
|
+
(select count(*) from pg_stat_activity a where a.datname = d.datname),
|
|
118
|
+
${age ? `extract(epoch from (pg_stat_file('base/' || d.oid || '/PG_VERSION')).modification)::bigint` : "0"}
|
|
119
|
+
from pg_database d where d.datname ~ '_aos_[0-9a-f]{12}'`;
|
|
120
|
+
|
|
121
|
+
// Why this database can't be shown to be a session's, or null when it can.
|
|
122
|
+
function whoMade(
|
|
123
|
+
hash: string,
|
|
124
|
+
note: string,
|
|
125
|
+
known: Set<string>,
|
|
126
|
+
server: PgServer
|
|
127
|
+
): string | null {
|
|
128
|
+
const m = /^agentos session (\S+?)(?: \(copying\))?$/.exec(note);
|
|
129
|
+
if (m)
|
|
130
|
+
return sessionHash(m[1]) === hash
|
|
131
|
+
? null
|
|
132
|
+
: "its comment names another session";
|
|
133
|
+
if (note) return "it has a comment that isn't AgentOS's";
|
|
134
|
+
if (known.has(hash))
|
|
135
|
+
return "no AgentOS comment on it, and its session is known here";
|
|
136
|
+
return isLocal(server) ? null : "no AgentOS comment, and not a local server";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function list(deps: DatabaseDeps, server: PgServer) {
|
|
140
|
+
const env = pgEnv(server);
|
|
141
|
+
for (const age of [true, false]) {
|
|
142
|
+
const { code, out } = await deps.run(
|
|
143
|
+
"psql",
|
|
144
|
+
["-X", "-d", "postgres", "-tAq", "-F", "\t", "-c", LIST(age)],
|
|
145
|
+
env
|
|
146
|
+
);
|
|
147
|
+
if (code === 0)
|
|
148
|
+
return out
|
|
149
|
+
.split("\n")
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.map((line) => {
|
|
152
|
+
const [name, size, note, conns, at] = line.split("\t");
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
bytes: Number(size) || 0,
|
|
156
|
+
note: note ?? "",
|
|
157
|
+
conns: Number(conns) || 0,
|
|
158
|
+
madeAt: Number(at) * 1000 || 0,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
if (code === 127) return "psql isn't installed";
|
|
162
|
+
if (!age) return `Postgres isn't answering (${out.slice(0, 120)})`;
|
|
163
|
+
}
|
|
164
|
+
return "Postgres isn't answering";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function findTaskDatabases(deps: DatabaseDeps): Promise<{
|
|
168
|
+
candidates: Candidate[];
|
|
169
|
+
skipped: SweepSkipped[];
|
|
170
|
+
}> {
|
|
171
|
+
const candidates: Candidate[] = [];
|
|
172
|
+
const skipped: SweepSkipped[] = [];
|
|
173
|
+
for (const server of deps.servers) {
|
|
174
|
+
const found = await list(deps, server);
|
|
175
|
+
if (typeof found === "string") {
|
|
176
|
+
skipped.push({
|
|
177
|
+
path: where(server),
|
|
178
|
+
kind: "database",
|
|
179
|
+
reason: found,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
for (const d of found) {
|
|
184
|
+
const hash = taskHashOf(d.name);
|
|
185
|
+
const skip = (reason: string) =>
|
|
186
|
+
skipped.push({ path: d.name, kind: "database", reason });
|
|
187
|
+
if (!hash) continue;
|
|
188
|
+
const proof = whoMade(hash, d.note, deps.known, server);
|
|
189
|
+
if (deps.froms.has(d.name)) skip("a project's declared source");
|
|
190
|
+
else if (proof) skip(proof);
|
|
191
|
+
else if (deps.live.has(hash)) skip("its session is still running");
|
|
192
|
+
else if (d.conns > 0) skip(`${d.conns} connection(s) open`);
|
|
193
|
+
else if (d.madeAt && deps.now - d.madeAt < DAY_MS)
|
|
194
|
+
skip("made less than a day ago");
|
|
195
|
+
else
|
|
196
|
+
candidates.push({
|
|
197
|
+
path: d.name,
|
|
198
|
+
kind: "database",
|
|
199
|
+
server,
|
|
200
|
+
bytes: d.bytes,
|
|
201
|
+
why: `no session needs it${d.madeAt ? "" : " (age unknown)"}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { candidates, skipped };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Re-checked here as well: only a name carrying the token is ever dropped,
|
|
209
|
+
// and a plain drop, which Postgres refuses while anyone is connected.
|
|
210
|
+
export async function dropTaskDatabase(
|
|
211
|
+
c: Candidate,
|
|
212
|
+
froms: Set<string>,
|
|
213
|
+
run: PgRun = pgRun
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
if (!taskHashOf(c.path) || froms.has(c.path)) return false;
|
|
216
|
+
const { code } = await run(
|
|
217
|
+
"dropdb",
|
|
218
|
+
["--if-exists", "--", c.path],
|
|
219
|
+
pgEnv(c.server ?? {})
|
|
220
|
+
);
|
|
221
|
+
return code === 0;
|
|
222
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
|
|
3
|
+
// Disk use of a folder in bytes; 0 when it can't be measured in time.
|
|
4
|
+
export default function duBytes(
|
|
5
|
+
dir: string,
|
|
6
|
+
timeout = 300_000
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
return new Promise((resolve) =>
|
|
9
|
+
execFile("du", ["-sk", dir], { timeout }, (error, stdout) =>
|
|
10
|
+
resolve(error ? 0 : (parseInt(String(stdout), 10) || 0) * 1024)
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
}
|