@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
package/lib/health/boots.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* next boot reads it back.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
9
12
|
import { db } from "../db";
|
|
10
13
|
import { gitSync } from "../git-exec";
|
|
11
14
|
import { getSetting, putSetting } from "../settings-store";
|
|
@@ -62,6 +65,7 @@ export interface BootRow {
|
|
|
62
65
|
head: string | null;
|
|
63
66
|
prev_seen_at: number | null;
|
|
64
67
|
prev_bg: string | null;
|
|
68
|
+
deploy: number | null;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export const bootsSince = (since: number) =>
|
|
@@ -76,4 +80,37 @@ export const recentBoots = (n = 6) =>
|
|
|
76
80
|
.prepare(`SELECT * FROM health_boots ORDER BY started_at DESC LIMIT ?`)
|
|
77
81
|
.all(n) as BootRow[];
|
|
78
82
|
|
|
83
|
+
export const DEPLOY_RESTART_FILE = path.join(
|
|
84
|
+
os.homedir(),
|
|
85
|
+
".agent-os",
|
|
86
|
+
"autodeploy",
|
|
87
|
+
"restarted"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// When autodeploy last restarted the server (it writes epoch seconds once the
|
|
91
|
+
// server is back up), or null.
|
|
92
|
+
export function lastDeployRestart(file = DEPLOY_RESTART_FILE): number | null {
|
|
93
|
+
try {
|
|
94
|
+
const s = Number.parseInt(fs.readFileSync(file, "utf8").trim(), 10);
|
|
95
|
+
return Number.isFinite(s) && s > 0 ? s * 1000 : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Autodeploy writes its file up to the startup wait after the restart.
|
|
102
|
+
const DEPLOY_LOOKBACK_MS = 15 * MIN;
|
|
103
|
+
|
|
104
|
+
// Stamps the latest boot that lines up with a planned restart, so it isn't
|
|
105
|
+
// counted as an unexpected one. A crash loop during a deploy keeps its extra
|
|
106
|
+
// boots.
|
|
107
|
+
export function markDeployBoot(restartedAt: number | null): void {
|
|
108
|
+
if (restartedAt === null) return;
|
|
109
|
+
db.prepare(
|
|
110
|
+
`UPDATE health_boots SET deploy = 1 WHERE id = (
|
|
111
|
+
SELECT id FROM health_boots WHERE started_at BETWEEN ? AND ?
|
|
112
|
+
ORDER BY started_at DESC LIMIT 1)`
|
|
113
|
+
).run(restartedAt - DEPLOY_LOOKBACK_MS, restartedAt + MIN);
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
export const BOOT_WINDOW_MS = 24 * 60 * MIN;
|
package/lib/health/ci.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* How long CI takes, learned from what the orchestrator already sees. Each
|
|
3
3
|
* diff it reads every open PR's verdict; the first time a head is pending is
|
|
4
4
|
* when its run started (to within a diff), and the first time it isn't is
|
|
5
|
-
* when it settled. No extra gh call.
|
|
5
|
+
* when it settled. No extra gh call. A row lives only while its PR is open.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { db } from "../db";
|
|
@@ -19,6 +19,7 @@ export function observeCi(
|
|
|
19
19
|
now: number
|
|
20
20
|
): void {
|
|
21
21
|
db.transaction(() => {
|
|
22
|
+
const open = new Set<string>();
|
|
22
23
|
for (const f of facts) {
|
|
23
24
|
const pr = f.task?.pr;
|
|
24
25
|
const project = f.project;
|
|
@@ -31,6 +32,7 @@ export function observeCi(
|
|
|
31
32
|
).run(project, pr.number);
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
35
|
+
open.add(`${project}#${pr.number}`);
|
|
34
36
|
if (pr.checks === "pending") {
|
|
35
37
|
// A newer head replaces the run still pending on an older one.
|
|
36
38
|
db.prepare(
|
|
@@ -50,13 +52,32 @@ export function observeCi(
|
|
|
50
52
|
).run(now, project, pr.number, head);
|
|
51
53
|
}
|
|
52
54
|
}
|
|
55
|
+
// A PR that left the facts (done, archived) can't be seen merging.
|
|
56
|
+
const rows = db
|
|
57
|
+
.prepare(
|
|
58
|
+
`SELECT project_id, pr_number FROM ci_runs
|
|
59
|
+
WHERE workspace_id = ? AND settled_at IS NULL`
|
|
60
|
+
)
|
|
61
|
+
.all(workspaceId) as { project_id: string; pr_number: number }[];
|
|
62
|
+
for (const r of rows)
|
|
63
|
+
if (!open.has(`${r.project_id}#${r.pr_number}`))
|
|
64
|
+
db.prepare(
|
|
65
|
+
`DELETE FROM ci_runs WHERE project_id = ? AND pr_number = ?
|
|
66
|
+
AND settled_at IS NULL`
|
|
67
|
+
).run(r.project_id, r.pr_number);
|
|
53
68
|
})();
|
|
54
69
|
}
|
|
55
70
|
|
|
56
71
|
export function pendingCi(now: number): CiFact[] {
|
|
57
72
|
const rows = db
|
|
58
73
|
.prepare(
|
|
59
|
-
`SELECT
|
|
74
|
+
`SELECT r.* FROM ci_runs r
|
|
75
|
+
WHERE r.settled_at IS NULL AND r.pending_since >= ?
|
|
76
|
+
AND NOT EXISTS (
|
|
77
|
+
SELECT 1 FROM session_rows s JOIN projects p ON p.id = s.project_id
|
|
78
|
+
WHERE p.name = r.project_id AND s.pr_number = r.pr_number
|
|
79
|
+
AND (s.pr_status IN ('merged', 'closed')
|
|
80
|
+
OR s.task_status = 'merged'))`
|
|
60
81
|
)
|
|
61
82
|
.all(now - LOST_AFTER_MS) as {
|
|
62
83
|
project_id: string;
|
package/lib/health/detectors.ts
CHANGED
|
@@ -90,12 +90,13 @@ export function restarts(f: HealthFacts, c: HealthConfig): Trip[] {
|
|
|
90
90
|
(t) => f.now - t <= c.restartsWindowMinutes * MIN
|
|
91
91
|
);
|
|
92
92
|
if (recent.length <= c.restartsMax) return [];
|
|
93
|
+
const deploys = f.deploys.length;
|
|
93
94
|
return [
|
|
94
95
|
trip({
|
|
95
96
|
check: "restarts",
|
|
96
97
|
subject: "server",
|
|
97
98
|
severity: "high",
|
|
98
|
-
line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes.`,
|
|
99
|
+
line: `AgentOS restarted ${recent.length} times in the last ${c.restartsWindowMinutes} minutes${deploys ? ` (besides ${deploys} autodeploy ${deploys === 1 ? "restart" : "restarts"})` : ""}.`,
|
|
99
100
|
detail: { boots: recent },
|
|
100
101
|
}),
|
|
101
102
|
];
|
package/lib/health/evidence.ts
CHANGED
|
@@ -96,7 +96,7 @@ function pipelineSnapshot(f: HealthFacts): string {
|
|
|
96
96
|
lines.push("Recent server starts:");
|
|
97
97
|
for (const b of recentBoots(6))
|
|
98
98
|
lines.push(
|
|
99
|
-
`- ${ago(f.now, b.started_at)}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
|
|
99
|
+
`- ${ago(f.now, b.started_at)}${b.deploy ? " (autodeploy restart)" : ""}${b.head ? ` at ${b.head}` : ""}${b.prev_seen_at ? `; the previous run was last seen ${ago(f.now, b.prev_seen_at)}` : ""}`
|
|
100
100
|
);
|
|
101
101
|
lines.push(
|
|
102
102
|
`Server errors in the last 10 minutes: ${f.errors.recent} (usual ${Math.round(f.errors.baselineMean)}).`
|
package/lib/health/facts.ts
CHANGED
|
@@ -144,6 +144,7 @@ function armed(): ArmedFact[] {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
|
|
147
|
+
const recent = bootsSince(now - c.restartsWindowMinutes * MIN);
|
|
147
148
|
return {
|
|
148
149
|
now,
|
|
149
150
|
machines: machines(now),
|
|
@@ -151,9 +152,8 @@ export function gatherFacts(now: number, c: HealthConfig): HealthFacts {
|
|
|
151
152
|
bg: backgroundJobs(now, c),
|
|
152
153
|
armed: armed(),
|
|
153
154
|
ci: pendingCi(now),
|
|
154
|
-
boots:
|
|
155
|
-
|
|
156
|
-
),
|
|
155
|
+
boots: recent.filter((b) => !b.deploy).map((b) => b.started_at),
|
|
156
|
+
deploys: recent.filter((b) => b.deploy).map((b) => b.started_at),
|
|
157
157
|
errors: errorRate(now),
|
|
158
158
|
};
|
|
159
159
|
}
|
package/lib/health/fixtures.ts
CHANGED
package/lib/health/tick.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { runClaude } from "../orchestrator/claude-cli";
|
|
9
|
-
import { leaveHeartbeat } from "./boots";
|
|
9
|
+
import { lastDeployRestart, leaveHeartbeat, markDeployBoot } from "./boots";
|
|
10
10
|
import { healthConfig, type HealthConfig } from "./config";
|
|
11
11
|
import { runDetectors } from "./detectors";
|
|
12
12
|
import { gatherFacts, liveBackgroundJobs } from "./facts";
|
|
@@ -134,6 +134,11 @@ export function healthTick(
|
|
|
134
134
|
if (now - last < EVERY_MS) return [];
|
|
135
135
|
last = now;
|
|
136
136
|
const c = deps.config();
|
|
137
|
+
try {
|
|
138
|
+
markDeployBoot(lastDeployRestart());
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error("[health] deploy restarts:", error);
|
|
141
|
+
}
|
|
137
142
|
const facts = deps.facts(now, c);
|
|
138
143
|
const found = recordTrips(runDetectors(facts, c), TICK_CHECKS, now);
|
|
139
144
|
for (const row of found.closed) deliverClosed(row, now, deps.route);
|
package/lib/health/types.ts
CHANGED
|
@@ -105,6 +105,8 @@ export interface HealthFacts {
|
|
|
105
105
|
ci: CiFact[];
|
|
106
106
|
// Server start times inside the restart window.
|
|
107
107
|
boots: number[];
|
|
108
|
+
// Autodeploy's planned restarts inside the same window.
|
|
109
|
+
deploys: number[];
|
|
108
110
|
errors: { recent: number; baselineMean: number };
|
|
109
111
|
}
|
|
110
112
|
|
package/lib/orchestrator/home.ts
CHANGED
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* sessions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import { resolveRoleOrDefault } from "../models/resolve";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
|
-
import path from "path";
|
|
12
11
|
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
13
12
|
import { db, type Session } from "../db";
|
|
14
13
|
import { getWorkspace } from "../workspaces";
|
|
@@ -16,9 +15,7 @@ import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
|
16
15
|
|
|
17
16
|
// It works through tools, not in a repo: a scratch folder of its own.
|
|
18
17
|
export const orchestratorDir = (workspaceId: string) =>
|
|
19
|
-
refuseRealStateUnderTest(
|
|
20
|
-
path.join(os.homedir(), ".agent-os", "orchestrators", workspaceId)
|
|
21
|
-
);
|
|
18
|
+
refuseRealStateUnderTest(agentosHome("orchestrators", workspaceId));
|
|
22
19
|
|
|
23
20
|
export function getOrchestrator(workspaceId: string): Session | null {
|
|
24
21
|
return (
|
|
@@ -229,6 +229,8 @@ function aosRefusal(args: string[]): string | null {
|
|
|
229
229
|
resurrect: T.resurrect,
|
|
230
230
|
stop: T.stop,
|
|
231
231
|
};
|
|
232
|
+
if (sub === "sweep" && args.includes("--arm"))
|
|
233
|
+
return "Refused: `aos sweep --arm` turns on removing files and databases, which a person decides: Health, Clean-up, Turn on. `--dry-run` and `--disarm` are fine.";
|
|
232
234
|
if (sub && tool[sub])
|
|
233
235
|
return `Refused: \`aos ${sub}\` isn't for you; use ${tool[sub]}, which is scoped to this workspace and held by Pause.`;
|
|
234
236
|
return null;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* `..` resolved the way the system will resolve it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { agentosHome } from "../agentos-home";
|
|
15
16
|
import { execFile } from "child_process";
|
|
16
17
|
import fs from "fs";
|
|
17
18
|
import os from "os";
|
|
@@ -296,9 +297,9 @@ export async function orchestratorWriteBounds(
|
|
|
296
297
|
memory: [...new Set([cwd, await physicalPath(cwd)])].map((c) =>
|
|
297
298
|
path.join(os.homedir(), ".claude", "projects", mangleCwd(c), "memory")
|
|
298
299
|
),
|
|
299
|
-
audits:
|
|
300
|
+
audits: agentosHome("audits"),
|
|
300
301
|
quick: quickWorktrees(session.id),
|
|
301
|
-
state:
|
|
302
|
+
state: agentosHome(),
|
|
302
303
|
repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
|
|
303
304
|
};
|
|
304
305
|
}
|
package/lib/plans/features.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// A plan that split into pieces: the feature, its pieces, and the one-time
|
|
2
2
|
// checks that turn a plan file into rows. A one-piece plan makes none.
|
|
3
3
|
|
|
4
|
+
import { agentosHome } from "../agentos-home";
|
|
4
5
|
import fs from "fs";
|
|
5
6
|
import os from "os";
|
|
6
7
|
import path from "path";
|
|
@@ -61,8 +62,7 @@ export interface FeaturePiece {
|
|
|
61
62
|
const expandHome = (p: string) => p.replace(/^~/, os.homedir());
|
|
62
63
|
|
|
63
64
|
export const plansDir = () =>
|
|
64
|
-
process.env.AGENTOS_PLANS_DIR ||
|
|
65
|
-
path.join(os.homedir(), ".agent-os", "plans");
|
|
65
|
+
process.env.AGENTOS_PLANS_DIR || agentosHome("plans");
|
|
66
66
|
export const planPathOf = (featureId: string) =>
|
|
67
67
|
path.join(plansDir(), `${featureId}.md`);
|
|
68
68
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { agentosHome } from "../agentos-home";
|
|
1
2
|
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { NO_ATTRIBUTION } from "../agents/attribution";
|
|
5
5
|
|
|
6
|
-
const DIR =
|
|
6
|
+
const DIR = agentosHome();
|
|
7
7
|
const HOOK = path.join(DIR, "bin", "agentos-status");
|
|
8
8
|
const SETTINGS = path.join(DIR, "claude-status-hooks.json");
|
|
9
9
|
const SUBAGENT_HOOK = path.join(DIR, "bin", "agentos-subagent-model.mjs");
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* Remote-host sessions are not tapped: their output never passes through
|
|
10
10
|
* this machine except while attached. They keep reading the screen.
|
|
11
11
|
*/
|
|
12
|
+
import { agentosHome } from "../agentos-home";
|
|
12
13
|
import fs from "fs";
|
|
13
14
|
import net from "net";
|
|
14
|
-
import os from "os";
|
|
15
15
|
import path from "path";
|
|
16
16
|
import { execFile } from "child_process";
|
|
17
17
|
import { promisify } from "util";
|
|
@@ -50,7 +50,7 @@ const replied = new Map<string, number>();
|
|
|
50
50
|
|
|
51
51
|
// In a directory only this user can enter: only they can write reports.
|
|
52
52
|
export function tapSocketPath(port: number): string {
|
|
53
|
-
return
|
|
53
|
+
return agentosHome("run", `osc-${port}.sock`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function foreground(name: string): Promise<string | undefined> {
|
|
@@ -41,7 +41,7 @@ export type PgRun = (
|
|
|
41
41
|
env: NodeJS.ProcessEnv
|
|
42
42
|
) => Promise<{ code: number; out: string }>;
|
|
43
43
|
|
|
44
|
-
const pgRun: PgRun = (cmd, args, env) =>
|
|
44
|
+
export const pgRun: PgRun = (cmd, args, env) =>
|
|
45
45
|
new Promise((resolve) => {
|
|
46
46
|
execFile(
|
|
47
47
|
cmd,
|
|
@@ -66,10 +66,10 @@ const pgRun: PgRun = (cmd, args, env) =>
|
|
|
66
66
|
// A plain database name: never a connection string (`host=…`,
|
|
67
67
|
// `postgres://…`) or an option, which pg_dump would take as one.
|
|
68
68
|
const DB_NAME = /^[A-Za-z0-9_][A-Za-z0-9_$.-]{0,62}$/;
|
|
69
|
-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
69
|
+
export const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
70
70
|
// One socket folder: libpq reads a comma as a list of hosts to try in turn,
|
|
71
71
|
// so `/nope,db.example.com` would reach the second.
|
|
72
|
-
const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
|
|
72
|
+
export const SOCKET_DIR = /^\/[^,=\s'"\\]*$/;
|
|
73
73
|
|
|
74
74
|
// Why `declared` can't be used, or null. A socket folder is local too.
|
|
75
75
|
export function refusal(declared: DatabaseDecl): string | null {
|
|
@@ -86,13 +86,22 @@ export function refusal(declared: DatabaseDecl): string | null {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
const MARK = (sessionId: string) => `agentos session ${sessionId}`;
|
|
89
|
+
export const MARK = (sessionId: string) => `agentos session ${sessionId}`;
|
|
90
90
|
const PENDING = (sessionId: string) => `${MARK(sessionId)} (copying)`;
|
|
91
91
|
|
|
92
|
+
export const sessionHash = (sessionId: string) =>
|
|
93
|
+
createHash("sha256").update(sessionId).digest("hex").slice(0, 12);
|
|
94
|
+
|
|
95
|
+
// The token a session's copy carries, and so does anything a project derives
|
|
96
|
+
// from it (`<copy>_test`, `<from>_test_aos_<hash>`): the one convention that
|
|
97
|
+
// says AgentOS's session made a database.
|
|
98
|
+
const TASK_DB = /_aos_([0-9a-f]{12})(?:_[A-Za-z0-9]+)*$/;
|
|
99
|
+
export const taskHashOf = (name: string): string | null =>
|
|
100
|
+
TASK_DB.exec(name)?.[1] ?? null;
|
|
101
|
+
|
|
92
102
|
// `<from>_aos_<12 hex of the session id>`, within Postgres's 63 bytes.
|
|
93
103
|
export function databaseName(from: string, sessionId: string): string {
|
|
94
|
-
const
|
|
95
|
-
const suffix = `_aos_${hash.slice(0, 12)}`;
|
|
104
|
+
const suffix = `_aos_${sessionHash(sessionId)}`;
|
|
96
105
|
return `${from.slice(0, 63 - suffix.length)}${suffix}`;
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -122,7 +131,7 @@ const oneLine = (s: string, n = 200) => {
|
|
|
122
131
|
};
|
|
123
132
|
|
|
124
133
|
// Every database on the server with its comment, or why there's no answer.
|
|
125
|
-
async function listDatabases(
|
|
134
|
+
export async function listDatabases(
|
|
126
135
|
run: PgRun,
|
|
127
136
|
env: NodeJS.ProcessEnv
|
|
128
137
|
): Promise<Map<string, string> | string> {
|
|
@@ -165,7 +174,7 @@ async function copy(
|
|
|
165
174
|
// recognised and remade rather than left or reused half-filled.
|
|
166
175
|
const created = await run(
|
|
167
176
|
"createdb",
|
|
168
|
-
["-T", "template0", target, PENDING(sessionId)],
|
|
177
|
+
["-T", "template0", "--", target, PENDING(sessionId)],
|
|
169
178
|
env
|
|
170
179
|
);
|
|
171
180
|
if (created.code !== 0)
|
|
@@ -175,7 +184,7 @@ async function copy(
|
|
|
175
184
|
try {
|
|
176
185
|
let step = await run(
|
|
177
186
|
"pg_dump",
|
|
178
|
-
["--no-owner", "--no-privileges", "-f", dump, from],
|
|
187
|
+
["--no-owner", "--no-privileges", "-f", dump, "--", from],
|
|
179
188
|
env
|
|
180
189
|
);
|
|
181
190
|
if (step.code === 0) {
|
|
@@ -191,7 +200,7 @@ async function copy(
|
|
|
191
200
|
);
|
|
192
201
|
}
|
|
193
202
|
if (step.code !== 0) {
|
|
194
|
-
await run("dropdb", ["--if-exists", target], env);
|
|
203
|
+
await run("dropdb", ["--if-exists", "--", target], env);
|
|
195
204
|
return `could not copy ${from} into ${target} (${oneLine(step.out, 300)}); ${target} was dropped again`;
|
|
196
205
|
}
|
|
197
206
|
return null;
|
|
@@ -243,7 +252,7 @@ async function makeDatabase(
|
|
|
243
252
|
// An interrupted copy of ours: made again from the start.
|
|
244
253
|
if (!record({ ...state, name: target, pending: true }))
|
|
245
254
|
return { ...state, error: ENDED };
|
|
246
|
-
const dropped = await run("dropdb", ["--if-exists", target], env);
|
|
255
|
+
const dropped = await run("dropdb", ["--if-exists", "--", target], env);
|
|
247
256
|
if (dropped.code !== 0)
|
|
248
257
|
return {
|
|
249
258
|
...state,
|
|
@@ -399,7 +408,10 @@ async function dropDatabase(
|
|
|
399
408
|
console.warn(`[database] ${name} left: ${listed}`);
|
|
400
409
|
return;
|
|
401
410
|
}
|
|
402
|
-
if (!listed.has(name))
|
|
411
|
+
if (!listed.has(name)) {
|
|
412
|
+
await dropDerived(sessionId, state, listed, run, env);
|
|
413
|
+
return void forget();
|
|
414
|
+
}
|
|
403
415
|
const comment = listed.get(name);
|
|
404
416
|
if (comment !== MARK(sessionId) && comment !== PENDING(sessionId)) {
|
|
405
417
|
console.warn(`[database] ${name} left: AgentOS didn't make it`);
|
|
@@ -408,9 +420,52 @@ async function dropDatabase(
|
|
|
408
420
|
// The session is over: a dev server left running in its worktree and
|
|
409
421
|
// still connected is a straggler, ended by --force (Postgres 13+; a plain
|
|
410
422
|
// drop for an older server).
|
|
411
|
-
let dropped = await run(
|
|
423
|
+
let dropped = await run(
|
|
424
|
+
"dropdb",
|
|
425
|
+
["--if-exists", "--force", "--", name],
|
|
426
|
+
env
|
|
427
|
+
);
|
|
412
428
|
if (dropped.code !== 0)
|
|
413
|
-
dropped = await run("dropdb", ["--if-exists", name], env);
|
|
414
|
-
if (dropped.code === 0)
|
|
415
|
-
|
|
429
|
+
dropped = await run("dropdb", ["--if-exists", "--", name], env);
|
|
430
|
+
if (dropped.code === 0) {
|
|
431
|
+
await dropDerived(sessionId, state, listed, run, env);
|
|
432
|
+
forget();
|
|
433
|
+
} else console.warn(`[database] ${name} left: ${oneLine(dropped.out)}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// What the project's own tooling made from the copy (a Rails `_test`
|
|
437
|
+
// database): same session token, never the declared source.
|
|
438
|
+
async function dropDerived(
|
|
439
|
+
sessionId: string,
|
|
440
|
+
state: SessionDatabase,
|
|
441
|
+
listed: Map<string, string>,
|
|
442
|
+
run: PgRun,
|
|
443
|
+
env: NodeJS.ProcessEnv
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
const hash = sessionHash(sessionId);
|
|
446
|
+
for (const other of listed.keys()) {
|
|
447
|
+
if (other === state.from || other === state.name) continue;
|
|
448
|
+
if (taskHashOf(other) !== hash) continue;
|
|
449
|
+
// A plain drop, and not while anyone is connected: it is another
|
|
450
|
+
// task's or a person's session if the token matches by accident.
|
|
451
|
+
const busy = await run(
|
|
452
|
+
"psql",
|
|
453
|
+
[
|
|
454
|
+
"-X",
|
|
455
|
+
"-d",
|
|
456
|
+
"postgres",
|
|
457
|
+
"-tAq",
|
|
458
|
+
"-c",
|
|
459
|
+
`select count(*) from pg_stat_activity where datname = ${quoteLiteral(other)}`,
|
|
460
|
+
],
|
|
461
|
+
env
|
|
462
|
+
);
|
|
463
|
+
if (busy.code !== 0 || Number(busy.out.trim()) > 0) {
|
|
464
|
+
console.warn(`[database] ${other} left: connected or unreadable`);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const done = await run("dropdb", ["--if-exists", "--", other], env);
|
|
468
|
+
if (done.code !== 0)
|
|
469
|
+
console.warn(`[database] ${other} left: ${oneLine(done.out)}`);
|
|
470
|
+
}
|
|
416
471
|
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* an agent editing agentos.json on its branch can't change its own launch.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import fs from "fs";
|
|
7
8
|
import os from "os";
|
|
9
|
+
import { sessionTmpDir } from "../sessions/leftovers";
|
|
8
10
|
import { db } from "../db";
|
|
9
11
|
import { getProject } from "../projects";
|
|
10
12
|
import { sessionPorts } from "../ports";
|
|
@@ -35,6 +37,21 @@ export function sessionConfig(
|
|
|
35
37
|
return { ...loaded, ports: sessionPorts(sessionId) };
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
// A task's own temp folder, so what it leaves there is removed with it.
|
|
41
|
+
// Only a session in a worktree: an orchestrator or a terminal in a checkout
|
|
42
|
+
// is long-lived and keeps the system's.
|
|
43
|
+
function taskTmpEnv(sessionId: string): Record<string, string> {
|
|
44
|
+
const row = db
|
|
45
|
+
.prepare(`SELECT worktree_path FROM sessions WHERE id = ?`)
|
|
46
|
+
.get(sessionId) as { worktree_path: string | null } | undefined;
|
|
47
|
+
if (!row?.worktree_path) return {};
|
|
48
|
+
const dir = sessionTmpDir(sessionId);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
// A folder left by an earlier run keeps its old mode otherwise.
|
|
51
|
+
fs.chmodSync(dir, 0o700);
|
|
52
|
+
return { TMPDIR: dir };
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
// The project's env and the session's ports. Never throws: a terminal or a
|
|
39
56
|
// chat must still open when the config can't be read.
|
|
40
57
|
export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
@@ -43,6 +60,7 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
|
43
60
|
return {
|
|
44
61
|
...projectEnv(config, ports),
|
|
45
62
|
...(config.database ? databaseEnv(sessionDatabase(sessionId)) : {}),
|
|
63
|
+
...taskTmpEnv(sessionId),
|
|
46
64
|
};
|
|
47
65
|
} catch (error) {
|
|
48
66
|
console.error(`[project-config] env for ${sessionId}:`, error);
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* address. AgentOS never runs `tailscale serve` or funnel.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { agentosHome } from "../agentos-home";
|
|
13
14
|
import { execFile } from "child_process";
|
|
14
15
|
import fs from "fs";
|
|
15
16
|
import https from "https";
|
|
16
|
-
import os from "os";
|
|
17
17
|
import path from "path";
|
|
18
18
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
19
19
|
import type { Socket } from "net";
|
|
@@ -76,7 +76,7 @@ function runCert(
|
|
|
76
76
|
/** Gets (or refreshes) the certificate; null when the tailnet can't issue one. */
|
|
77
77
|
export async function tailnetCert(
|
|
78
78
|
log: (line: string) => void = console.log,
|
|
79
|
-
dir =
|
|
79
|
+
dir = agentosHome("tls")
|
|
80
80
|
) {
|
|
81
81
|
const ts = await tailscaleStatus();
|
|
82
82
|
const bin = tailscaleBinary();
|
|
@@ -14,6 +14,7 @@ import { git } from "../git-exec";
|
|
|
14
14
|
import { PLAN_FILE } from "../models/plan-step";
|
|
15
15
|
import { listArtifacts } from "../artifacts/store";
|
|
16
16
|
import { allowedRoots, within } from "./access";
|
|
17
|
+
import { SCREENSHOT_DIR } from "../tasks/ui-brief";
|
|
17
18
|
import {
|
|
18
19
|
fromItems,
|
|
19
20
|
fromPr,
|
|
@@ -293,6 +294,20 @@ export async function collectArtifacts(
|
|
|
293
294
|
|
|
294
295
|
export const cachedCount = () => cache.size;
|
|
295
296
|
|
|
297
|
+
// What the UI brief has a terminal session save, whether or not a chat tool
|
|
298
|
+
// call named it: the PR names these files and the worktree is about to go.
|
|
299
|
+
export function screenshotsIn(worktree: string): string[] {
|
|
300
|
+
const dir = path.join(worktree, SCREENSHOT_DIR);
|
|
301
|
+
try {
|
|
302
|
+
return fs
|
|
303
|
+
.readdirSync(dir)
|
|
304
|
+
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
|
|
305
|
+
.map((f) => path.join(dir, f));
|
|
306
|
+
} catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
296
311
|
/**
|
|
297
312
|
* Before a session's worktree is removed: copy what it made there that git
|
|
298
313
|
* doesn't hold. Never throws, and never holds up the removal for long.
|
|
@@ -309,7 +324,9 @@ export async function keepSessionFiles(sessionId: string): Promise<void> {
|
|
|
309
324
|
const files = entries
|
|
310
325
|
.map((e) => e.read)
|
|
311
326
|
.filter((f): f is string => !!f && !!relInside(worktree, f));
|
|
312
|
-
await keepFiles(sessionId, worktree,
|
|
327
|
+
await keepFiles(sessionId, worktree, [
|
|
328
|
+
...new Set([...files, ...screenshotsIn(worktree)]),
|
|
329
|
+
]);
|
|
313
330
|
} catch (error) {
|
|
314
331
|
console.error(`[session-artifacts] keeping ${sessionId}:`, error);
|
|
315
332
|
} finally {
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* copy; the manifest records them so the view can say "in the PR".
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { agentosHome } from "../agentos-home";
|
|
8
9
|
import fs from "fs";
|
|
9
|
-
import os from "os";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { git } from "../git-exec";
|
|
12
12
|
import { isSecretPath } from "../secret-paths";
|
|
@@ -23,9 +23,7 @@ export interface Manifest {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function keptDir(): string {
|
|
26
|
-
return (
|
|
27
|
-
process.env.AGENTOS_KEPT_DIR || path.join(os.homedir(), ".agent-os", "kept")
|
|
28
|
-
);
|
|
26
|
+
return process.env.AGENTOS_KEPT_DIR || agentosHome("kept");
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
const safeId = (sessionId: string) => sessionId.replace(/[^\w-]/g, "_");
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* one before.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
11
12
|
import fs from "fs";
|
|
12
|
-
import os from "os";
|
|
13
13
|
import path from "path";
|
|
14
14
|
import type { Summary } from "./aggregate";
|
|
15
15
|
|
|
@@ -19,10 +19,7 @@ export const AUDIT_NAME = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
|
19
19
|
export const SESSIONS_KIND = "sessions";
|
|
20
20
|
|
|
21
21
|
export function auditsRoot(): string {
|
|
22
|
-
return (
|
|
23
|
-
process.env.AGENTOS_AUDITS_DIR ||
|
|
24
|
-
path.join(os.homedir(), ".agent-os", "audits")
|
|
25
|
-
);
|
|
22
|
+
return process.env.AGENTOS_AUDITS_DIR || agentosHome("audits");
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
export function auditsDir(): string {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* can be public, and a log holds briefs and reviews.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { agentosHome } from "../agentos-home";
|
|
9
10
|
import fs from "fs";
|
|
10
|
-
import os from "os";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { db } from "../db";
|
|
13
13
|
import { slugify } from "../git";
|
|
@@ -17,8 +17,7 @@ import { expand, repoPaths, resolved, within } from "./paths";
|
|
|
17
17
|
|
|
18
18
|
// AGENTOS_SESSION_LOGS_DIR moves the default (tests keep it off the real one).
|
|
19
19
|
export const DEFAULT_ROOT = () =>
|
|
20
|
-
process.env.AGENTOS_SESSION_LOGS_DIR ||
|
|
21
|
-
path.join(os.homedir(), ".agent-os", "session-logs");
|
|
20
|
+
process.env.AGENTOS_SESSION_LOGS_DIR || agentosHome("session-logs");
|
|
22
21
|
|
|
23
22
|
const key = (projectId: string) => `session-logs.folder.${projectId}`;
|
|
24
23
|
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* then (for a worktree) the setup in the background, then the first message.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { agentosHome } from "../agentos-home";
|
|
6
7
|
import type { StartVia } from "../tasks/starter";
|
|
7
8
|
import fs from "fs";
|
|
8
9
|
import os from "os";
|
|
9
|
-
import path from "path";
|
|
10
10
|
import { randomUUID } from "crypto";
|
|
11
11
|
import { db, queries, type Session } from "../db";
|
|
12
12
|
import type { AgentType } from "../providers";
|
|
@@ -45,7 +45,7 @@ export const DEFAULT_START_VIEW: StartView = "chat";
|
|
|
45
45
|
export const startView = (v: unknown): StartView | undefined =>
|
|
46
46
|
v === "chat" || v === "terminal" ? v : undefined;
|
|
47
47
|
|
|
48
|
-
export const SCRATCH_DIR =
|
|
48
|
+
export const SCRATCH_DIR = agentosHome("scratch");
|
|
49
49
|
|
|
50
50
|
export interface LaunchInput {
|
|
51
51
|
// The client's key for this start (a draft's id): it becomes the
|