@saadnvd1/agent-os 0.3.637 → 0.3.644
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/bus/branches/route.ts +25 -0
- package/app/api/bus/resurrect/route.test.ts +3 -6
- package/app/api/sessions/[id]/pr/route.ts +3 -3
- package/app/api/sessions/[id]/setup/route.ts +3 -3
- package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
- package/app/api/tasks/[id]/arm/route.ts +6 -5
- package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
- package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
- package/app/api/tasks/[id]/checks/route.ts +1 -1
- package/app/api/tasks/[id]/diff/route.ts +1 -1
- package/app/api/tasks/[id]/done/route.ts +2 -2
- package/app/api/tasks/[id]/drop/route.ts +6 -2
- package/app/api/tasks/[id]/export/route.ts +3 -5
- package/app/api/tasks/[id]/merge/route.ts +3 -3
- package/app/api/tasks/[id]/message/route.ts +2 -2
- package/app/api/tasks/[id]/model/route.ts +5 -1
- package/app/api/tasks/[id]/move/route.ts +5 -1
- package/app/api/tasks/[id]/moved/route.ts +6 -8
- package/app/api/tasks/[id]/plan/route.ts +4 -6
- package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
- package/app/api/tasks/[id]/priority/route.ts +4 -1
- package/app/api/tasks/[id]/resume/route.ts +3 -4
- package/app/api/tasks/[id]/stop/route.ts +2 -2
- package/app/api/tasks/route.ts +1 -1
- package/app/api/worktree-sweep/route.test.ts +40 -0
- package/app/api/worktree-sweep/route.ts +31 -0
- package/app/api/worktree-sweep/run/route.ts +26 -0
- package/components/Health/HealthPanel.tsx +4 -1
- package/components/Health/SweepRuns.tsx +125 -0
- package/components/Merge/GlobalMergeSection.tsx +2 -0
- package/components/Merge/ProjectMergeSection.tsx +2 -0
- package/components/Merge/WorktreeSweepSection.tsx +125 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/data/worktree-sweep/index.ts +56 -0
- package/lib/approvals/enable.ts +1 -1
- package/lib/away/report.ts +5 -5
- package/lib/away/run.ts +2 -2
- package/lib/bookmarks/store.ts +3 -3
- package/lib/bus/held-reply.ts +2 -2
- package/lib/bus/index.ts +3 -3
- package/lib/chat/compact-context.ts +1 -1
- package/lib/chat/hold.ts +3 -3
- package/lib/chat/refresh.ts +1 -1
- package/lib/chat/registry.ts +3 -3
- package/lib/chat/runner.ts +1 -1
- package/lib/chat/worker/main.ts +1 -1
- package/lib/db/backup.ts +49 -0
- package/lib/db/index.ts +5 -1
- package/lib/db/lumifyhub.ts +8 -9
- package/lib/db/machine-scope.ts +12 -0
- package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
- package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
- package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
- package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
- package/lib/db/migrations/README.md +11 -0
- package/lib/db/queries.ts +16 -31
- package/lib/db/session-parts/columns.ts +186 -0
- package/lib/db/session-parts/fill.ts +33 -0
- package/lib/db/session-parts/index.ts +10 -0
- package/lib/db/session-parts/pauses.ts +11 -0
- package/lib/db/session-parts/placement.ts +11 -0
- package/lib/db/session-parts/plans.ts +31 -0
- package/lib/db/session-parts/prs.ts +31 -0
- package/lib/db/session-parts/rows.ts +69 -0
- package/lib/db/session-parts/tasks.ts +27 -0
- package/lib/db/session-parts/view.ts +50 -0
- package/lib/db/session-parts/write.ts +61 -0
- package/lib/done/archive.ts +2 -2
- package/lib/done/auto-archive.ts +1 -1
- package/lib/done/bulk.ts +1 -1
- package/lib/done/index.ts +8 -14
- package/lib/done/remote.ts +9 -20
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +1 -1
- package/lib/health/pieces.ts +1 -1
- package/lib/hosts/peer-actions.ts +2 -2
- package/lib/hosts/peer-mirror.ts +2 -2
- package/lib/hosts/peer-sync.ts +69 -32
- package/lib/learning-cards/input.ts +1 -1
- package/lib/learning-cards/store.ts +1 -1
- package/lib/models/plan-step.ts +10 -24
- package/lib/models/resume.ts +5 -7
- package/lib/notify/index.ts +1 -1
- package/lib/notify/push/events.ts +1 -1
- package/lib/notify/push/index.ts +1 -1
- package/lib/orchestrator/act.ts +1 -1
- package/lib/orchestrator/armed.test-helpers.ts +228 -0
- package/lib/orchestrator/armed.ts +18 -16
- package/lib/orchestrator/ask-approvals.ts +1 -1
- package/lib/orchestrator/ask-heads.ts +1 -1
- package/lib/orchestrator/ask-settle.ts +1 -1
- package/lib/orchestrator/ask-verdict.ts +111 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/brakes.ts +1 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +2 -2
- package/lib/orchestrator/escalate.ts +25 -12
- package/lib/orchestrator/external-gates.ts +1 -0
- package/lib/orchestrator/external-pr.ts +2 -2
- package/lib/orchestrator/facts.ts +4 -28
- package/lib/orchestrator/gates.ts +94 -25
- package/lib/orchestrator/held-asks.ts +8 -11
- package/lib/orchestrator/held-review.ts +8 -4
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/loop-guard.ts +145 -0
- package/lib/orchestrator/merge-approvals.ts +2 -2
- package/lib/orchestrator/overview.ts +1 -1
- package/lib/orchestrator/pr-wake.ts +3 -5
- package/lib/orchestrator/questions.ts +1 -1
- package/lib/orchestrator/quick-store.ts +1 -1
- package/lib/orchestrator/quick.ts +11 -8
- package/lib/orchestrator/read.ts +1 -1
- package/lib/orchestrator/rebase.ts +8 -11
- package/lib/orchestrator/reconcile.ts +2 -2
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/orchestrator/review.ts +14 -4
- package/lib/orchestrator/signoff.test-helpers.ts +322 -0
- package/lib/orchestrator/signoff.ts +49 -11
- package/lib/orchestrator/targets.ts +1 -1
- package/lib/orchestrator/task-state.ts +4 -2
- package/lib/orchestrator/testing.ts +11 -14
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/plans/features.ts +1 -1
- package/lib/plans/release.ts +1 -1
- package/lib/ports.ts +1 -1
- package/lib/recipes/test-fixtures.ts +2 -3
- package/lib/resurrect/find.ts +1 -1
- package/lib/resurrect/index.ts +44 -20
- package/lib/resurrect/swept.ts +61 -0
- package/lib/schedules/store.ts +1 -1
- package/lib/session-audit/sources.ts +3 -3
- package/lib/session-logs/index.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/setup-progress.ts +1 -1
- package/lib/sessions/stop.ts +1 -1
- package/lib/sessions/worktree-setup.ts +2 -1
- package/lib/simulator/index.ts +1 -1
- package/lib/simulator/task-devices.ts +1 -1
- package/lib/stacks/board.ts +1 -1
- package/lib/stacks/land.ts +1 -1
- package/lib/stacks/restack.ts +1 -1
- package/lib/stacks/start.ts +2 -2
- package/lib/stacks/testing.ts +17 -16
- package/lib/stacks/tick.ts +1 -1
- package/lib/stacks/view.ts +1 -1
- package/lib/status/incremental.ts +1 -1
- package/lib/tasks/contract/actions.ts +35 -0
- package/lib/tasks/contract/authorize.ts +82 -0
- package/lib/tasks/contract/client.ts +296 -0
- package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
- package/lib/tasks/contract/index.ts +28 -0
- package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
- package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
- package/lib/tasks/contract/routes.ts +26 -0
- package/lib/tasks/contract/start.ts +122 -0
- package/lib/tasks/contract/sync.ts +126 -0
- package/lib/tasks/contract/types.ts +41 -0
- package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
- package/lib/tasks/finish.ts +30 -23
- package/lib/tasks/held-replies.ts +7 -13
- package/lib/tasks/held.ts +14 -18
- package/lib/tasks/import.ts +19 -15
- package/lib/tasks/index.ts +34 -22
- package/lib/tasks/launch-gate.ts +1 -1
- package/lib/tasks/move-carry.ts +15 -7
- package/lib/tasks/move-flow.ts +29 -36
- package/lib/tasks/move-guard.ts +1 -1
- package/lib/tasks/move-recover.ts +6 -25
- package/lib/tasks/move-testing.ts +16 -7
- package/lib/tasks/move.ts +23 -23
- package/lib/tasks/moved-away.ts +1 -1
- package/lib/tasks/pause-hold.ts +13 -5
- package/lib/tasks/pause.ts +19 -23
- package/lib/tasks/plan-view.ts +1 -1
- package/lib/tasks/pr-activity.ts +11 -2
- package/lib/tasks/prune-branches.ts +193 -0
- package/lib/tasks/prune-scope.ts +22 -0
- package/lib/tasks/queue-placement.ts +1 -1
- package/lib/tasks/queue-rows.ts +4 -4
- package/lib/tasks/queue-start.ts +5 -3
- package/lib/tasks/queue-tick.ts +2 -2
- package/lib/tasks/queue.ts +14 -6
- package/lib/tasks/remote-branch.ts +170 -0
- package/lib/tasks/session.ts +7 -4
- package/lib/tasks/start.ts +4 -4
- package/lib/tasks/starter.ts +8 -11
- package/lib/tasks/testing.ts +13 -7
- package/lib/usage/calls.ts +1 -1
- package/lib/usage/compare.ts +1 -1
- package/lib/usage/quality.ts +1 -1
- package/lib/usage/turns.ts +1 -1
- package/lib/voice/vocab.ts +1 -1
- package/lib/worktree-sweep/commits.ts +65 -0
- package/lib/worktree-sweep/findOrphans.ts +139 -0
- package/lib/worktree-sweep/findSettled.ts +112 -0
- package/lib/worktree-sweep/index.ts +10 -0
- package/lib/worktree-sweep/judge.ts +31 -0
- package/lib/worktree-sweep/panes.ts +18 -0
- package/lib/worktree-sweep/runSweep.ts +243 -0
- package/lib/worktree-sweep/settings.ts +83 -0
- package/lib/worktree-sweep/start.ts +64 -0
- package/lib/worktree-sweep/state.ts +36 -0
- package/lib/worktree-sweep/store.ts +96 -0
- package/lib/worktree-sweep/types.ts +53 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +1 -1
- package/scripts/screenshots/seed.ts +19 -18
- package/server.ts +3 -0
- package/lib/tasks/held-routes.ts +0 -33
- package/lib/tasks/remote.ts +0 -255
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sessions whose worktree the sweep may consider: settled, quiet, not
|
|
3
|
+
* waiting on anyone, no open PR, and old enough for their project's setting.
|
|
4
|
+
* Everything else is skipped with the reason.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { fromSqliteTime } from "../session-meta";
|
|
10
|
+
import { statusOf } from "../orchestrator/facts";
|
|
11
|
+
import { isAgentOSWorktree } from "../worktrees";
|
|
12
|
+
import { restoring } from "../resurrect/index";
|
|
13
|
+
import { inFolder } from "./panes";
|
|
14
|
+
import { resolveSweepDays } from "./settings";
|
|
15
|
+
import type { Candidate, SweepSkipped } from "./types";
|
|
16
|
+
|
|
17
|
+
const DAY_MS = 24 * 3600_000;
|
|
18
|
+
const SETTLED_TASKS = new Set(["merged", "dropped", "done"]);
|
|
19
|
+
|
|
20
|
+
export interface SettledDeps {
|
|
21
|
+
now: number;
|
|
22
|
+
panes: string[];
|
|
23
|
+
status: (s: Session) => ReturnType<typeof statusOf>;
|
|
24
|
+
isRestoring: (id: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ms = (t: string | null | undefined) =>
|
|
28
|
+
t ? fromSqliteTime(t).getTime() : 0;
|
|
29
|
+
|
|
30
|
+
// When it settled: the earliest of the marks it carries, else its last update;
|
|
31
|
+
// anything it did afterwards starts the wait again.
|
|
32
|
+
export function settledSinceMs(s: Session): number {
|
|
33
|
+
const marks = [s.merged_at, s.task_ended_at, s.settled_at, s.archived_at]
|
|
34
|
+
.map(ms)
|
|
35
|
+
.filter((t) => t > 0);
|
|
36
|
+
const settled = marks.length ? Math.min(...marks) : ms(s.updated_at);
|
|
37
|
+
return Math.max(settled, ms(s.active_at));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const isSettled = (s: Session) =>
|
|
41
|
+
s.task_status
|
|
42
|
+
? SETTLED_TASKS.has(s.task_status)
|
|
43
|
+
: !!(s.archived_at || s.settled_at);
|
|
44
|
+
|
|
45
|
+
function openAsks(): Set<string> {
|
|
46
|
+
const rows = db
|
|
47
|
+
.prepare(`SELECT subject FROM orchestrator_asks WHERE status = 'open'`)
|
|
48
|
+
.all() as Array<{ subject: string }>;
|
|
49
|
+
return new Set(rows.map((r) => r.subject));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function findSettled(
|
|
53
|
+
deps: SettledDeps
|
|
54
|
+
): Promise<{ candidates: Candidate[]; skipped: SweepSkipped[] }> {
|
|
55
|
+
const rows = db
|
|
56
|
+
.prepare(
|
|
57
|
+
`SELECT * FROM session_rows
|
|
58
|
+
WHERE worktree_path IS NOT NULL AND worktree_path != ''
|
|
59
|
+
AND (host_id IS NULL OR host_id = 'local') AND peer_mirror = 0`
|
|
60
|
+
)
|
|
61
|
+
.all() as Session[];
|
|
62
|
+
const asks = openAsks();
|
|
63
|
+
const candidates: Candidate[] = [];
|
|
64
|
+
const skipped: SweepSkipped[] = [];
|
|
65
|
+
for (const s of rows) {
|
|
66
|
+
const dir = s.worktree_path!;
|
|
67
|
+
if (!isSettled(s) || !isAgentOSWorktree(dir) || !fs.existsSync(dir))
|
|
68
|
+
continue;
|
|
69
|
+
const skip = async (): Promise<string | null> => {
|
|
70
|
+
if (s.role) return "an orchestrator's";
|
|
71
|
+
if (s.pinned) return "pinned";
|
|
72
|
+
if (s.moved_to)
|
|
73
|
+
return "its task moved to another machine and may come back";
|
|
74
|
+
if (s.pr_status === "open") return "its PR is open";
|
|
75
|
+
const { days } = resolveSweepDays(s.project_id);
|
|
76
|
+
if (days === 0) return "clean-up is off for its project";
|
|
77
|
+
if (deps.now - settledSinceMs(s) < days * DAY_MS)
|
|
78
|
+
return `settled less than ${days} day${days === 1 ? "" : "s"} ago`;
|
|
79
|
+
if (deps.isRestoring(s.id)) return "it is being restored";
|
|
80
|
+
if (asks.has(`task:${s.id}`)) return "an ask about it is waiting on you";
|
|
81
|
+
const real = fs.realpathSync(dir);
|
|
82
|
+
if (deps.panes.some((p) => inFolder(p, real) || inFolder(p, dir)))
|
|
83
|
+
return "a terminal is open in it";
|
|
84
|
+
const { status, needsInput } = await deps.status(s);
|
|
85
|
+
if (needsInput) return "it is waiting on input";
|
|
86
|
+
if (status !== "idle" && status !== "dead") return `it is ${status}`;
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
const reason = await skip();
|
|
90
|
+
if (reason)
|
|
91
|
+
skipped.push({ path: dir, kind: "settled", reason, sessionId: s.id });
|
|
92
|
+
else
|
|
93
|
+
candidates.push({
|
|
94
|
+
path: dir,
|
|
95
|
+
kind: "settled",
|
|
96
|
+
sessionId: s.id,
|
|
97
|
+
prNumber: s.pr_number,
|
|
98
|
+
branch: s.branch_name,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { candidates, skipped };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const defaultSettledDeps = (
|
|
105
|
+
now = Date.now(),
|
|
106
|
+
panes: string[] = []
|
|
107
|
+
): SettledDeps => ({
|
|
108
|
+
now,
|
|
109
|
+
panes,
|
|
110
|
+
status: statusOf,
|
|
111
|
+
isRestoring: restoring,
|
|
112
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describeChanges, unsavedChanges } from "../worktree-placed";
|
|
2
|
+
import { unpushedCommits } from "./commits";
|
|
3
|
+
|
|
4
|
+
export type Verdict = { remove: true } | { remove: false; reason: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether removing the worktree would lose anything: uncommitted changes
|
|
8
|
+
* (except what AgentOS placed and nobody touched), or commits that no remote
|
|
9
|
+
* branch, merged PR or upstream patch holds. Anything that can't be read
|
|
10
|
+
* keeps it.
|
|
11
|
+
*/
|
|
12
|
+
export async function judgeWorktree(
|
|
13
|
+
path: string,
|
|
14
|
+
repo: string,
|
|
15
|
+
opts: { prHead?: string | null; branch?: string | null } = {}
|
|
16
|
+
): Promise<Verdict> {
|
|
17
|
+
const unsaved = await unsavedChanges(path).catch(() => null);
|
|
18
|
+
if (!unsaved)
|
|
19
|
+
return { remove: false, reason: "its changes can't be checked" };
|
|
20
|
+
const stray = await unpushedCommits(path, repo, opts).catch(() => null);
|
|
21
|
+
if (!stray) return { remove: false, reason: "its commits can't be checked" };
|
|
22
|
+
const why = [
|
|
23
|
+
unsaved.length ? `uncommitted changes (${describeChanges(unsaved)})` : null,
|
|
24
|
+
stray.length
|
|
25
|
+
? `${stray.length} commit${stray.length === 1 ? "" : "s"} no remote branch or merged PR has`
|
|
26
|
+
: null,
|
|
27
|
+
].filter((w) => w !== null);
|
|
28
|
+
return why.length
|
|
29
|
+
? { remove: false, reason: why.join(", and ") }
|
|
30
|
+
: { remove: true };
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
|
|
3
|
+
// The folders open in tmux panes, so a worktree someone has a terminal in is
|
|
4
|
+
// left alone. Empty when tmux isn't running or can't be read.
|
|
5
|
+
export function openPaneFolders(timeoutMs = 3000): Promise<string[]> {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
execFile(
|
|
8
|
+
"tmux",
|
|
9
|
+
["list-panes", "-a", "-F", "#{pane_current_path}"],
|
|
10
|
+
{ timeout: timeoutMs },
|
|
11
|
+
(error, stdout) =>
|
|
12
|
+
resolve(error ? [] : String(stdout).split("\n").filter(Boolean))
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const inFolder = (folder: string, root: string) =>
|
|
18
|
+
folder === root || folder.startsWith(root + "/");
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One clean-up run: settled sessions' worktrees and orphan folders, each
|
|
3
|
+
* judged, removed when nothing would be lost, skipped with a reason when not.
|
|
4
|
+
* The branch and the conversation stay; a restore rebuilds the worktree.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { execFile } from "child_process";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { git as runGit } from "../git-exec";
|
|
12
|
+
import { run as runCmd } from "../tasks/gh";
|
|
13
|
+
import { deleteWorktree, WORKTREES_DIR } from "../worktrees";
|
|
14
|
+
import { keepSessionFiles } from "../session-artifacts/collect";
|
|
15
|
+
import { currentLevel } from "../load/monitor";
|
|
16
|
+
import {
|
|
17
|
+
findSettled,
|
|
18
|
+
defaultSettledDeps,
|
|
19
|
+
type SettledDeps,
|
|
20
|
+
} from "./findSettled";
|
|
21
|
+
import { findOrphans, holdsNothing } from "./findOrphans";
|
|
22
|
+
import { judgeWorktree } from "./judge";
|
|
23
|
+
import { openPaneFolders } from "./panes";
|
|
24
|
+
import { finishRun, recordSwept, startRun } from "./store";
|
|
25
|
+
import type { Candidate, SweepSummary } from "./types";
|
|
26
|
+
|
|
27
|
+
export interface SweepDeps {
|
|
28
|
+
now: number;
|
|
29
|
+
root: string;
|
|
30
|
+
panes: () => Promise<string[]>;
|
|
31
|
+
settled: Omit<SettledDeps, "now" | "panes">;
|
|
32
|
+
size: (dir: string) => Promise<number>;
|
|
33
|
+
prHead: (repo: string, number: number) => Promise<string | null>;
|
|
34
|
+
// Stop early when the machine is busy.
|
|
35
|
+
busy: () => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
39
|
+
runGit(cwd, args, { timeout: 30_000 }).then((o) => o.trim());
|
|
40
|
+
|
|
41
|
+
function duBytes(dir: string): Promise<number> {
|
|
42
|
+
return new Promise((resolve) =>
|
|
43
|
+
execFile("du", ["-sk", dir], { timeout: 120_000 }, (error, stdout) =>
|
|
44
|
+
resolve(error ? 0 : (parseInt(String(stdout), 10) || 0) * 1024)
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function prHeadOf(repo: string, number: number): Promise<string | null> {
|
|
50
|
+
try {
|
|
51
|
+
const out = await runCmd(
|
|
52
|
+
"gh",
|
|
53
|
+
[
|
|
54
|
+
"pr",
|
|
55
|
+
"view",
|
|
56
|
+
String(number),
|
|
57
|
+
"--json",
|
|
58
|
+
"headRefOid",
|
|
59
|
+
"-q",
|
|
60
|
+
".headRefOid",
|
|
61
|
+
],
|
|
62
|
+
repo,
|
|
63
|
+
30_000
|
|
64
|
+
);
|
|
65
|
+
return out.trim() || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const defaultSweepDeps = (): SweepDeps => ({
|
|
72
|
+
now: Date.now(),
|
|
73
|
+
root: WORKTREES_DIR,
|
|
74
|
+
panes: openPaneFolders,
|
|
75
|
+
settled: defaultSettledDeps(),
|
|
76
|
+
size: duBytes,
|
|
77
|
+
prHead: prHeadOf,
|
|
78
|
+
busy: () => currentLevel() === "red",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// A path under the root, never the root itself or something outside it.
|
|
82
|
+
const inRoot = (root: string, p: string) => {
|
|
83
|
+
const rel = path.relative(path.resolve(root), path.resolve(p));
|
|
84
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
async function repoOfSession(c: Candidate): Promise<string | null> {
|
|
88
|
+
if (c.repo) return c.repo;
|
|
89
|
+
try {
|
|
90
|
+
return (
|
|
91
|
+
await git(
|
|
92
|
+
c.path,
|
|
93
|
+
"rev-parse",
|
|
94
|
+
"--path-format=absolute",
|
|
95
|
+
"--git-common-dir"
|
|
96
|
+
)
|
|
97
|
+
).replace(/\/\.git\/?$/, "");
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function removeOne(
|
|
104
|
+
c: Candidate,
|
|
105
|
+
repo: string | null,
|
|
106
|
+
runId: number
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
if (c.sessionId) {
|
|
109
|
+
await keepSessionFiles(c.sessionId).catch(() => {});
|
|
110
|
+
recordSwept(c.sessionId, c.path, c.branch ?? null, runId);
|
|
111
|
+
}
|
|
112
|
+
if (repo && fs.existsSync(repo) && c.kind !== "test-leak")
|
|
113
|
+
await deleteWorktree(c.path, repo, false).catch(() => {});
|
|
114
|
+
if (fs.existsSync(c.path))
|
|
115
|
+
await fs.promises.rm(c.path, { recursive: true, force: true });
|
|
116
|
+
if (repo && fs.existsSync(repo))
|
|
117
|
+
await git(repo, "worktree", "prune").catch(() => "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function sweep(
|
|
121
|
+
opts: { dryRun?: boolean } = {},
|
|
122
|
+
deps: SweepDeps = defaultSweepDeps()
|
|
123
|
+
): Promise<{ runId: number; summary: SweepSummary }> {
|
|
124
|
+
const dryRun = !!opts.dryRun;
|
|
125
|
+
const runId = startRun(dryRun);
|
|
126
|
+
const summary: SweepSummary = { removed: [], skipped: [], freedBytes: 0 };
|
|
127
|
+
let error: string | null = null;
|
|
128
|
+
try {
|
|
129
|
+
const panes = await deps.panes();
|
|
130
|
+
const s = await findSettled({ ...deps.settled, now: deps.now, panes });
|
|
131
|
+
const o = await findOrphans({ now: deps.now, root: deps.root, panes });
|
|
132
|
+
// Reasons that need a look come before the ones that only need time.
|
|
133
|
+
const young = (k: { reason: string }) => /less than \d+ day/.test(k.reason);
|
|
134
|
+
summary.skipped.push(
|
|
135
|
+
...[...s.skipped, ...o.skipped].sort(
|
|
136
|
+
(a, b) => Number(young(a)) - Number(young(b))
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
const pruned = new Set<string>();
|
|
140
|
+
for (const c of [...s.candidates, ...o.candidates]) {
|
|
141
|
+
if (deps.busy()) {
|
|
142
|
+
summary.skipped.push({
|
|
143
|
+
path: c.path,
|
|
144
|
+
kind: c.kind,
|
|
145
|
+
reason: "the machine is busy; left for the next run",
|
|
146
|
+
sessionId: c.sessionId,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!inRoot(deps.root, c.path)) continue;
|
|
151
|
+
const skip = (reason: string) =>
|
|
152
|
+
summary.skipped.push({
|
|
153
|
+
path: c.path,
|
|
154
|
+
kind: c.kind,
|
|
155
|
+
reason,
|
|
156
|
+
sessionId: c.sessionId,
|
|
157
|
+
});
|
|
158
|
+
const repo = await repoOfSession(c);
|
|
159
|
+
const why =
|
|
160
|
+
c.kind === "test-leak"
|
|
161
|
+
? "a test's throwaway repository"
|
|
162
|
+
: "clean and nothing would be lost";
|
|
163
|
+
if (c.kind !== "test-leak" && (repo || c.kind === "settled")) {
|
|
164
|
+
if (!repo) {
|
|
165
|
+
// Git can't read it: it goes only when it holds nothing of anyone's.
|
|
166
|
+
if (!holdsNothing(c.path)) {
|
|
167
|
+
skip("it isn't a git worktree any more");
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
let verdict = await judgeWorktree(c.path, repo, { branch: c.branch });
|
|
172
|
+
// A squash merge leaves the branch's commits only in the PR's head.
|
|
173
|
+
if (!verdict.remove && c.prNumber) {
|
|
174
|
+
const prHead = await deps.prHead(repo, c.prNumber);
|
|
175
|
+
if (prHead)
|
|
176
|
+
verdict = await judgeWorktree(c.path, repo, {
|
|
177
|
+
branch: c.branch,
|
|
178
|
+
prHead,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (!verdict.remove) {
|
|
182
|
+
skip(verdict.reason);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const bytes = await deps.size(c.path);
|
|
188
|
+
if (!dryRun) {
|
|
189
|
+
await removeOne(c, repo, runId);
|
|
190
|
+
if (fs.existsSync(c.path)) {
|
|
191
|
+
skip("removing it failed");
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (repo) pruned.add(repo);
|
|
195
|
+
}
|
|
196
|
+
summary.removed.push({
|
|
197
|
+
path: c.path,
|
|
198
|
+
kind: c.kind,
|
|
199
|
+
why,
|
|
200
|
+
bytes,
|
|
201
|
+
sessionId: c.sessionId,
|
|
202
|
+
});
|
|
203
|
+
summary.freedBytes += bytes;
|
|
204
|
+
}
|
|
205
|
+
if (!dryRun) await pruneProjects([...pruned]);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
error = e instanceof Error ? e.message : String(e);
|
|
208
|
+
}
|
|
209
|
+
finishRun(runId, summary, error);
|
|
210
|
+
console.log(describeRun(summary, dryRun, error));
|
|
211
|
+
return { runId, summary };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// `git worktree prune` in every repository AgentOS knows, so entries for
|
|
215
|
+
// folders that are gone stop piling up.
|
|
216
|
+
async function pruneProjects(extra: string[]): Promise<void> {
|
|
217
|
+
const rows = db
|
|
218
|
+
.prepare(
|
|
219
|
+
`SELECT working_directory FROM projects WHERE working_directory IS NOT NULL`
|
|
220
|
+
)
|
|
221
|
+
.all() as Array<Pick<Session, "working_directory">>;
|
|
222
|
+
const repos = new Set([...extra, ...rows.map((r) => r.working_directory)]);
|
|
223
|
+
for (const repo of repos)
|
|
224
|
+
if (repo && fs.existsSync(repo))
|
|
225
|
+
await git(repo, "worktree", "prune").catch(() => "");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const gb = (b: number) => `${(b / 1024 ** 3).toFixed(2)} GB`;
|
|
229
|
+
|
|
230
|
+
export function describeRun(
|
|
231
|
+
s: SweepSummary,
|
|
232
|
+
dryRun = false,
|
|
233
|
+
error: string | null = null
|
|
234
|
+
): string {
|
|
235
|
+
const reasons = new Map<string, number>();
|
|
236
|
+
for (const k of s.skipped) {
|
|
237
|
+
const r = k.reason.replace(/\(.*\)/, "").trim();
|
|
238
|
+
reasons.set(r, (reasons.get(r) ?? 0) + 1);
|
|
239
|
+
}
|
|
240
|
+
const why = [...reasons].map(([r, n]) => `${n} ${r}`).join("; ");
|
|
241
|
+
const leaks = s.removed.filter((r) => r.kind === "test-leak").length;
|
|
242
|
+
return `[worktree-sweep] ${dryRun ? "would remove" : "removed"} ${s.removed.length} (${gb(s.freedBytes)}${leaks ? `, ${leaks} test leftovers` : ""}), skipped ${s.skipped.length}${why ? `: ${why}` : ""}${error ? `; failed: ${error}` : ""}`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many days a settled worktree stays before the sweep removes it.
|
|
3
|
+
* Global, with a per-project override; 0 turns it off. Default 7.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { db } from "../db";
|
|
8
|
+
import type { ResolvedDays } from "./types";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SWEEP_DAYS = 7;
|
|
11
|
+
export const MAX_SWEEP_DAYS = 365;
|
|
12
|
+
|
|
13
|
+
const GLOBAL_KEY = "worktree_sweep";
|
|
14
|
+
const projectKey = (projectId: string) => `worktree_sweep.project.${projectId}`;
|
|
15
|
+
|
|
16
|
+
export const sweepDays = z.number().int().min(0).max(MAX_SWEEP_DAYS);
|
|
17
|
+
const stored = z.object({ days: sweepDays });
|
|
18
|
+
|
|
19
|
+
function read(key: string): number | null {
|
|
20
|
+
const row = db
|
|
21
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
22
|
+
.get(key) as { value: string } | undefined;
|
|
23
|
+
if (!row) return null;
|
|
24
|
+
try {
|
|
25
|
+
const got = stored.safeParse(JSON.parse(row.value));
|
|
26
|
+
return got.success ? got.data.days : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function write(key: string, days: number | null): number | null {
|
|
33
|
+
if (days === null) {
|
|
34
|
+
db.prepare(`DELETE FROM settings WHERE key = ?`).run(key);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const got = sweepDays.safeParse(days);
|
|
38
|
+
if (!got.success)
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Days must be a whole number from 0 to ${MAX_SWEEP_DAYS} (0 turns the clean-up off)`
|
|
41
|
+
);
|
|
42
|
+
db.prepare(
|
|
43
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
44
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
45
|
+
).run(key, JSON.stringify({ days: got.data }));
|
|
46
|
+
return got.data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const globalSweepDays = () => read(GLOBAL_KEY);
|
|
50
|
+
export const setGlobalSweepDays = (days: number | null) =>
|
|
51
|
+
write(GLOBAL_KEY, days);
|
|
52
|
+
export const projectSweepDays = (projectId: string) =>
|
|
53
|
+
read(projectKey(projectId));
|
|
54
|
+
export const setProjectSweepDays = (projectId: string, days: number | null) =>
|
|
55
|
+
write(projectKey(projectId), days);
|
|
56
|
+
|
|
57
|
+
// The project's own setting, else the global one, else the default.
|
|
58
|
+
export function resolveSweepDays(
|
|
59
|
+
projectId: string | null | undefined
|
|
60
|
+
): ResolvedDays {
|
|
61
|
+
const own = projectId ? projectSweepDays(projectId) : null;
|
|
62
|
+
if (own !== null) return { days: own, from: "project" };
|
|
63
|
+
const global = globalSweepDays();
|
|
64
|
+
if (global !== null) return { days: global, from: "global" };
|
|
65
|
+
return { days: DEFAULT_SWEEP_DAYS, from: "default" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Projects with their own number, for the settings page.
|
|
69
|
+
export function projectSweepOverrides(): Array<{
|
|
70
|
+
projectId: string;
|
|
71
|
+
days: number;
|
|
72
|
+
}> {
|
|
73
|
+
const rows = db
|
|
74
|
+
.prepare(
|
|
75
|
+
`SELECT key FROM settings WHERE key LIKE 'worktree_sweep.project.%'`
|
|
76
|
+
)
|
|
77
|
+
.all() as Array<{ key: string }>;
|
|
78
|
+
return rows.flatMap(({ key }) => {
|
|
79
|
+
const projectId = key.slice("worktree_sweep.project.".length);
|
|
80
|
+
const days = read(key);
|
|
81
|
+
return days === null ? [] : [{ projectId, days }];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { dbPath } from "../db/path";
|
|
4
|
+
import { WORKTREES_DIR } from "../worktrees";
|
|
5
|
+
import { sweep } from "./runSweep";
|
|
6
|
+
|
|
7
|
+
export const SWEEP_EVERY_MS = 6 * 3600_000;
|
|
8
|
+
export const SWEEP_FIRST_AFTER_MS = 10 * 60_000;
|
|
9
|
+
|
|
10
|
+
const OWNER_FILE = ".sweep-owner";
|
|
11
|
+
|
|
12
|
+
// A development or test instance has its own database but sees the real
|
|
13
|
+
// worktrees folder: every live worktree would look like an orphan to it. Only
|
|
14
|
+
// a production server, or one asked to, sweeps, and only a folder it owns.
|
|
15
|
+
export const sweepEnabled = (env: NodeJS.ProcessEnv = process.env) =>
|
|
16
|
+
!env.VITEST &&
|
|
17
|
+
(env.AGENTOS_WORKTREE_SWEEP === "1" ||
|
|
18
|
+
(env.NODE_ENV === "production" && env.AGENTOS_WORKTREE_SWEEP !== "0"));
|
|
19
|
+
|
|
20
|
+
// First sweep claims the folder for this database; another one's claim stands.
|
|
21
|
+
export function ownsWorktrees(
|
|
22
|
+
claim: boolean,
|
|
23
|
+
root = WORKTREES_DIR,
|
|
24
|
+
db = path.resolve(dbPath())
|
|
25
|
+
): boolean {
|
|
26
|
+
const file = path.join(root, OWNER_FILE);
|
|
27
|
+
try {
|
|
28
|
+
return fs.readFileSync(file, "utf8").trim() === db;
|
|
29
|
+
} catch {
|
|
30
|
+
// unclaimed
|
|
31
|
+
}
|
|
32
|
+
if (!claim) return false;
|
|
33
|
+
fs.mkdirSync(root, { recursive: true });
|
|
34
|
+
fs.writeFileSync(file, db);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const g = globalThis as unknown as { __agentosSweeping?: boolean };
|
|
39
|
+
|
|
40
|
+
// One run at a time, however it was asked for.
|
|
41
|
+
export async function sweepNow(dryRun = false) {
|
|
42
|
+
if (g.__agentosSweeping) throw new Error("A clean-up is already running");
|
|
43
|
+
if (!ownsWorktrees(sweepEnabled()))
|
|
44
|
+
throw new Error("Another AgentOS owns the worktrees folder");
|
|
45
|
+
g.__agentosSweeping = true;
|
|
46
|
+
try {
|
|
47
|
+
return await sweep({ dryRun });
|
|
48
|
+
} finally {
|
|
49
|
+
g.__agentosSweeping = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function startWorktreeSweep(): void {
|
|
54
|
+
if (!sweepEnabled()) return;
|
|
55
|
+
const run = () => {
|
|
56
|
+
sweepNow().catch((error) =>
|
|
57
|
+
console.error("[worktree-sweep] not run:", error.message)
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
run();
|
|
62
|
+
setInterval(run, SWEEP_EVERY_MS);
|
|
63
|
+
}, SWEEP_FIRST_AFTER_MS);
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getAllProjects } from "../projects";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SWEEP_DAYS,
|
|
4
|
+
globalSweepDays,
|
|
5
|
+
projectSweepOverrides,
|
|
6
|
+
} from "./settings";
|
|
7
|
+
import { recentRuns } from "./store";
|
|
8
|
+
import type { SweepRun } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface SweepState {
|
|
11
|
+
// Null when no number is set globally: the default applies.
|
|
12
|
+
days: number | null;
|
|
13
|
+
defaultDays: number;
|
|
14
|
+
overrides: Array<{ projectId: string; name: string; days: number }>;
|
|
15
|
+
runs: SweepRun[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sweepState(): SweepState {
|
|
19
|
+
const names = new Map(getAllProjects().map((p) => [p.id, p.name]));
|
|
20
|
+
return {
|
|
21
|
+
days: globalSweepDays(),
|
|
22
|
+
defaultDays: DEFAULT_SWEEP_DAYS,
|
|
23
|
+
overrides: projectSweepOverrides().flatMap((o) =>
|
|
24
|
+
names.has(o.projectId)
|
|
25
|
+
? [
|
|
26
|
+
{
|
|
27
|
+
projectId: o.projectId,
|
|
28
|
+
name: names.get(o.projectId)!,
|
|
29
|
+
days: o.days,
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
: []
|
|
33
|
+
),
|
|
34
|
+
runs: recentRuns(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import type { SweepRun, SweepSummary } from "./types";
|
|
3
|
+
|
|
4
|
+
interface RunRow {
|
|
5
|
+
id: number;
|
|
6
|
+
started_at: string;
|
|
7
|
+
finished_at: string | null;
|
|
8
|
+
dry_run: number;
|
|
9
|
+
removed: number;
|
|
10
|
+
freed_bytes: number;
|
|
11
|
+
skipped: number;
|
|
12
|
+
details: string | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The counts are exact; the lists kept for a run stop here (a first run can
|
|
17
|
+
// skip thousands of folders that are simply too young).
|
|
18
|
+
const DETAIL_CAP = 300;
|
|
19
|
+
|
|
20
|
+
const view = (r: RunRow): SweepRun => ({
|
|
21
|
+
id: r.id,
|
|
22
|
+
startedAt: r.started_at,
|
|
23
|
+
finishedAt: r.finished_at,
|
|
24
|
+
dryRun: !!r.dry_run,
|
|
25
|
+
removed: r.removed,
|
|
26
|
+
freedBytes: r.freed_bytes,
|
|
27
|
+
skipped: r.skipped,
|
|
28
|
+
details: r.details ? (JSON.parse(r.details) as SweepSummary) : null,
|
|
29
|
+
error: r.error,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function startRun(dryRun: boolean): number {
|
|
33
|
+
return Number(
|
|
34
|
+
db
|
|
35
|
+
.prepare(`INSERT INTO worktree_sweep_runs (dry_run) VALUES (?)`)
|
|
36
|
+
.run(dryRun ? 1 : 0).lastInsertRowid
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function finishRun(
|
|
41
|
+
id: number,
|
|
42
|
+
summary: SweepSummary,
|
|
43
|
+
error: string | null
|
|
44
|
+
): void {
|
|
45
|
+
db.prepare(
|
|
46
|
+
`UPDATE worktree_sweep_runs SET finished_at = datetime('now'), removed = ?,
|
|
47
|
+
freed_bytes = ?, skipped = ?, details = ?, error = ? WHERE id = ?`
|
|
48
|
+
).run(
|
|
49
|
+
summary.removed.length,
|
|
50
|
+
summary.freedBytes,
|
|
51
|
+
summary.skipped.length,
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
...summary,
|
|
54
|
+
skipped: summary.skipped.slice(0, DETAIL_CAP),
|
|
55
|
+
}),
|
|
56
|
+
error,
|
|
57
|
+
id
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function recentRuns(limit = 30): SweepRun[] {
|
|
62
|
+
return (
|
|
63
|
+
db
|
|
64
|
+
.prepare(`SELECT * FROM worktree_sweep_runs ORDER BY id DESC LIMIT ?`)
|
|
65
|
+
.all(limit) as RunRow[]
|
|
66
|
+
).map(view);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function recordSwept(
|
|
70
|
+
sessionId: string,
|
|
71
|
+
path: string,
|
|
72
|
+
branch: string | null,
|
|
73
|
+
runId: number
|
|
74
|
+
): void {
|
|
75
|
+
db.prepare(
|
|
76
|
+
`INSERT INTO session_worktree_sweeps (session_id, path, branch, run_id)
|
|
77
|
+
VALUES (?, ?, ?, ?)
|
|
78
|
+
ON CONFLICT(session_id) DO UPDATE SET path = excluded.path,
|
|
79
|
+
branch = excluded.branch, swept_at = datetime('now'), run_id = excluded.run_id`
|
|
80
|
+
).run(sessionId, path, branch, runId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sweptAt(sessionId: string): string | null {
|
|
84
|
+
const row = db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT swept_at FROM session_worktree_sweeps WHERE session_id = ?`
|
|
87
|
+
)
|
|
88
|
+
.get(sessionId) as { swept_at: string } | undefined;
|
|
89
|
+
return row?.swept_at ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function forgetSwept(sessionId: string): void {
|
|
93
|
+
db.prepare(`DELETE FROM session_worktree_sweeps WHERE session_id = ?`).run(
|
|
94
|
+
sessionId
|
|
95
|
+
);
|
|
96
|
+
}
|