zigmini 1.0.0-beta.3 → 1.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +29 -2
- package/README.md +29 -2
- package/assets/agent/gateway-request-rules.md +9 -2
- package/assets/agent/gateway-rules.md +13 -6
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
package/src/gc.ts
CHANGED
|
@@ -1,223 +1,44 @@
|
|
|
1
|
-
import { lstatSync, readdirSync, readFileSync, readlinkSync
|
|
2
|
-
import {
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
3
|
import type { InstallKind } from "./runtime/install-kind.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { GC_COLLECTORS } from "./housekeeping/registry.ts";
|
|
5
|
+
import { parseRpcUnits } from "./housekeeping/collectors/rpc-daemon.ts";
|
|
6
|
+
import { GC_KINDS, type GcCandidate, type GcDeps, type GcEntry, type GcKind, type GcRoots, sized, toCandidate } from "./housekeeping/collectors/shared.ts";
|
|
7
|
+
import { moveToTrash, type Trash } from "./housekeeping/trash.ts";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Improvement 66 (plan Todo 24): the gateway's resource cleanup (GC). `findCandidates` only reads and lists; `clean`
|
|
9
11
|
* acts only on the candidates the user picked (by id or by kind) and finds them again right before it acts.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* update-file `*.log` or `request*.json` file in <home>/update/, older than UPDATE_MIN_AGE_MS.
|
|
15
|
-
* temp-dir `zgw-*` or `zigmini-*` folder in $TMPDIR, older than TEMP_MIN_AGE_MS, named by no process, opened by none.
|
|
16
|
-
* rpc-daemon old-version RPC host daemon that `rpc-host-gc.ts list --json` judges "stop" (rule of bug 62).
|
|
17
|
-
* agent-trash agent folders in agents/.trash, older than AGENT_TRASH_MIN_AGE_MS by name timestamp or mtime.
|
|
13
|
+
* Since housekeeping todo 3 each kind is a collector in src/housekeeping/collectors/ (rule, root check, act); this module
|
|
14
|
+
* keeps the old /api/gc surface on top of those collectors and re-exports their rules. `report` never acts; automatic
|
|
15
|
+
* SIGTERM of old rpc daemons happens only in the housekeeping engine's scan.
|
|
18
16
|
* A checkout (this Mac's Docker gateway) cannot see host processes and folders, so it lists DB backups only.
|
|
19
17
|
* A rule input that cannot be read (current link, process list, lsof) makes its kind list nothing. Daemons get SIGTERM only.
|
|
20
|
-
*
|
|
18
|
+
* Files and folders go to the macOS Trash (src/housekeeping/trash.ts), never deleted for good outside its checkout db-backup case.
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
|
-
export
|
|
24
|
-
export type GcKind
|
|
25
|
-
export
|
|
26
|
-
export
|
|
27
|
-
export
|
|
28
|
-
export
|
|
29
|
-
export
|
|
30
|
-
export
|
|
21
|
+
export { AGENT_TRASH_MIN_AGE_MS, BACKUP_KEEP_MS, DAY_MS, GC_KINDS, inside, KEEP_BACKUPS, linkTarget, TEMP_MIN_AGE_MS, UPDATE_MIN_AGE_MS } from "./housekeeping/collectors/shared.ts";
|
|
22
|
+
export type { GcCandidate, GcDeps, GcEntry, GcKind, GcRoots, RpcUnit } from "./housekeeping/collectors/shared.ts";
|
|
23
|
+
export { backupCandidates } from "./housekeeping/collectors/db-backup.ts";
|
|
24
|
+
export { omoCandidates } from "./housekeeping/collectors/omo-version.ts";
|
|
25
|
+
export { updateCandidates } from "./housekeeping/collectors/update-file.ts";
|
|
26
|
+
export { tempCandidates } from "./housekeeping/collectors/temp-dir.ts";
|
|
27
|
+
export { agentTrashCandidates, REASON_AGENT_TRASH } from "./housekeeping/collectors/agent-trash.ts";
|
|
28
|
+
export { daemonCandidates, parseRpcUnits } from "./housekeeping/collectors/rpc-daemon.ts";
|
|
29
|
+
|
|
31
30
|
export const GC_PATH = "/api/gc";
|
|
32
31
|
export const GC_CLEAN_PATH = "/api/gc/clean";
|
|
33
32
|
|
|
34
|
-
const REASON_BACKUP: Text = { en: "Outside the newest {keep} and older than {days} days", ko: "최근 {keep}개 밖이고 {days}일 지남" };
|
|
35
|
-
const REASON_OMO: Text = { en: "Not the current ({current}) or previous version, and no process is running from it", ko: "현재({current})·직전 버전이 아니고 실행 중인 프로세스 없음" };
|
|
36
|
-
const REASON_UPDATE: Text = { en: "Update log or request file older than {days} days", ko: "{days}일 지난 업데이트 로그·요청 파일" };
|
|
37
|
-
const REASON_TEMP: Text = { en: "Older than {days} days and no process uses it", ko: "{days}일 지났고 쓰는 프로세스 없음" };
|
|
38
|
-
const REASON_DAEMON: Text = { en: "RPC host daemon of an earlier version, no parent, no connected client", ko: "이전 버전 RPC 호스트 데몬, 부모 없음, 연결된 클라이언트 없음" };
|
|
39
|
-
export const REASON_AGENT_TRASH: Text = { en: "Archived agent folder older than {days} days", ko: "{days}일 지난 에이전트 보관 폴더" };
|
|
40
|
-
|
|
41
|
-
const TEMP_NAME = /^(?:zgw-|zigmini-)/;
|
|
42
|
-
const UPDATE_NAME = /^(?:.+\.log|request.*\.json)$/;
|
|
43
|
-
/** Never a candidate, whatever folder it is found in. */
|
|
44
|
-
const PROTECTED_SEGMENTS = [`${sep}.omo${sep}evidence`, `${sep}secrets`];
|
|
45
|
-
|
|
46
|
-
export interface GcCandidate {
|
|
47
|
-
readonly id: string;
|
|
48
|
-
readonly kind: GcKind;
|
|
49
|
-
readonly target: string;
|
|
50
|
-
readonly reason: string;
|
|
51
|
-
readonly ageMs: number;
|
|
52
|
-
readonly bytes: number | null;
|
|
53
|
-
readonly pids?: readonly number[];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface GcEntry { readonly name: string; readonly type: "file" | "dir" | "link" | "other"; readonly mtimeMs: number }
|
|
57
|
-
export interface RpcUnit { readonly version: string; readonly verdict: string; readonly pids: readonly number[]; readonly ageMs: number; readonly rssKb: number }
|
|
58
|
-
|
|
59
|
-
export interface GcRoots {
|
|
60
|
-
readonly kind: InstallKind;
|
|
61
|
-
readonly home: string;
|
|
62
|
-
readonly backups: string;
|
|
63
|
-
readonly omo: string;
|
|
64
|
-
readonly update: string;
|
|
65
|
-
readonly tmp: string;
|
|
66
|
-
readonly agents: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type MaybeAsync<T> = T | Promise<T>;
|
|
70
|
-
export interface GcDeps {
|
|
71
|
-
readonly now: () => number;
|
|
72
|
-
/** Entries of a folder, or null when it cannot be read. */
|
|
73
|
-
readonly list: (dir: string) => GcEntry[] | null;
|
|
74
|
-
readonly size: (path: string) => MaybeAsync<number | null>;
|
|
75
|
-
readonly readlink: (path: string) => string | null;
|
|
76
|
-
readonly readText: (path: string) => string | null;
|
|
77
|
-
/** Command lines of every process, or null when ps failed. */
|
|
78
|
-
readonly commands: () => MaybeAsync<readonly string[] | null>;
|
|
79
|
-
/** Whether a process holds a file under `path`; null when lsof failed. */
|
|
80
|
-
readonly openBy: (path: string) => MaybeAsync<boolean | null>;
|
|
81
|
-
readonly rpcUnits: () => MaybeAsync<readonly RpcUnit[] | null>;
|
|
82
|
-
readonly remove: (path: string) => void;
|
|
83
|
-
readonly terminate: (pid: number) => void;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
33
|
export interface GcReport { readonly kind: InstallKind; readonly total: number; readonly bytes: number; readonly counts: Readonly<Record<GcKind, number>>; readonly candidates: readonly GcCandidate[] }
|
|
87
34
|
export interface GcSelection { readonly ids: readonly string[]; readonly kinds: readonly GcKind[] }
|
|
88
35
|
export interface GcCleanResult { readonly cleaned: readonly GcCandidate[]; readonly failed: readonly { readonly id: string; readonly error: string }[]; readonly missing: readonly string[] }
|
|
89
36
|
|
|
90
|
-
export function inside(root: string, path: string): boolean {
|
|
91
|
-
const rel = relative(resolve(root), resolve(path));
|
|
92
|
-
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function guarded(root: string, path: string): boolean {
|
|
96
|
-
const full = resolve(path);
|
|
97
|
-
return inside(root, full) && !PROTECTED_SEGMENTS.some((segment) => full.includes(segment));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function linkTarget(root: string, link: string, deps: Pick<GcDeps, "readlink">): string | null {
|
|
101
|
-
const target = deps.readlink(link);
|
|
102
|
-
if (target === null) return null;
|
|
103
|
-
const name = relative(root, resolve(root, target));
|
|
104
|
-
if (name === "" || name.startsWith("..") || name.includes(sep)) return null;
|
|
105
|
-
return name;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function backupCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
109
|
-
const entries = deps.list(roots.backups);
|
|
110
|
-
if (entries === null) return [];
|
|
111
|
-
const backups = entries.filter((entry) => entry.type === "file" && entry.name.endsWith(".sqlite")).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
112
|
-
const now = deps.now();
|
|
113
|
-
const out: GcCandidate[] = [];
|
|
114
|
-
for (const [index, entry] of backups.entries()) {
|
|
115
|
-
const ageMs = now - entry.mtimeMs;
|
|
116
|
-
if (index < KEEP_BACKUPS) continue;
|
|
117
|
-
if (ageMs <= BACKUP_KEEP_MS) continue;
|
|
118
|
-
const target = join(roots.backups, entry.name);
|
|
119
|
-
out.push({ id: `db-backup:${target}`, kind: "db-backup", target, reason: say(REASON_BACKUP, { keep: KEEP_BACKUPS, days: BACKUP_KEEP_MS / DAY_MS }), ageMs, bytes: null });
|
|
120
|
-
}
|
|
121
|
-
return out;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export async function omoCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
|
|
125
|
-
const current = linkTarget(roots.omo, join(roots.omo, "current"), deps);
|
|
126
|
-
if (current === null) return [];
|
|
127
|
-
const entries = deps.list(roots.omo);
|
|
128
|
-
if (entries === null) return [];
|
|
129
|
-
const folders = entries.filter((entry) => entry.type === "dir" && !entry.name.startsWith(".") && entry.name !== "current");
|
|
130
|
-
const recorded = deps.readText(join(roots.update, "prev-engine"))?.trim() ?? "";
|
|
131
|
-
// The previous engine is what an update rollback relinks (Todo 23). Without a record, the newest other folder stands in.
|
|
132
|
-
const others = folders.filter((entry) => entry.name !== current).sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
133
|
-
const previous = recorded !== "" ? recorded : others[0]?.name ?? null;
|
|
134
|
-
const commands = await deps.commands();
|
|
135
|
-
if (commands === null) return [];
|
|
136
|
-
const now = deps.now();
|
|
137
|
-
const out: GcCandidate[] = [];
|
|
138
|
-
for (const entry of others) {
|
|
139
|
-
if (entry.name === previous) continue;
|
|
140
|
-
const target = join(roots.omo, entry.name);
|
|
141
|
-
if (commands.some((command) => command.includes(`${target}${sep}`))) continue;
|
|
142
|
-
out.push({ id: `omo-version:${target}`, kind: "omo-version", target, reason: say(REASON_OMO, { current }), ageMs: now - entry.mtimeMs, bytes: null });
|
|
143
|
-
}
|
|
144
|
-
return out;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function updateCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
148
|
-
const entries = deps.list(roots.update);
|
|
149
|
-
if (entries === null) return [];
|
|
150
|
-
const now = deps.now();
|
|
151
|
-
const out: GcCandidate[] = [];
|
|
152
|
-
for (const entry of entries) {
|
|
153
|
-
if (entry.type !== "file" || !UPDATE_NAME.test(entry.name)) continue;
|
|
154
|
-
const ageMs = now - entry.mtimeMs;
|
|
155
|
-
if (ageMs <= UPDATE_MIN_AGE_MS) continue;
|
|
156
|
-
const target = join(roots.update, entry.name);
|
|
157
|
-
out.push({ id: `update-file:${target}`, kind: "update-file", target, reason: say(REASON_UPDATE, { days: UPDATE_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
|
|
158
|
-
}
|
|
159
|
-
return out;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export async function tempCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
|
|
163
|
-
const entries = deps.list(roots.tmp);
|
|
164
|
-
if (entries === null) return [];
|
|
165
|
-
const now = deps.now();
|
|
166
|
-
const old = entries.filter((entry) => entry.type === "dir" && TEMP_NAME.test(entry.name) && now - entry.mtimeMs > TEMP_MIN_AGE_MS);
|
|
167
|
-
if (old.length === 0) return [];
|
|
168
|
-
const commands = await deps.commands();
|
|
169
|
-
if (commands === null) return [];
|
|
170
|
-
const out: GcCandidate[] = [];
|
|
171
|
-
for (const entry of old) {
|
|
172
|
-
const target = join(roots.tmp, entry.name);
|
|
173
|
-
if (commands.some((command) => command.includes(target))) continue;
|
|
174
|
-
if (await deps.openBy(target) !== false) continue;
|
|
175
|
-
out.push({ id: `temp-dir:${target}`, kind: "temp-dir", target, reason: say(REASON_TEMP, { days: TEMP_MIN_AGE_MS / DAY_MS }), ageMs: now - entry.mtimeMs, bytes: null });
|
|
176
|
-
}
|
|
177
|
-
return out;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function daemonCandidates(deps: GcDeps): Promise<GcCandidate[]> {
|
|
181
|
-
const units = await deps.rpcUnits();
|
|
182
|
-
if (units === null) return [];
|
|
183
|
-
return units.filter((unit) => unit.verdict === "stop" && unit.pids.length > 0).map((unit) => ({
|
|
184
|
-
id: `rpc-daemon:${unit.version}:${unit.pids[0]}`, kind: "rpc-daemon" as const, target: `${unit.version} pid ${unit.pids.join(",")}`,
|
|
185
|
-
reason: say(REASON_DAEMON), ageMs: unit.ageMs, bytes: unit.rssKb * 1024, pids: unit.pids,
|
|
186
|
-
}));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function agentTrashCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
190
|
-
const trash = join(roots.agents, ".trash");
|
|
191
|
-
const entries = deps.list(trash);
|
|
192
|
-
if (entries === null) return [];
|
|
193
|
-
const now = deps.now();
|
|
194
|
-
const out: GcCandidate[] = [];
|
|
195
|
-
for (const entry of entries) {
|
|
196
|
-
if (entry.type !== "dir") continue;
|
|
197
|
-
const namedAt = agentTrashTimestamp(entry.name);
|
|
198
|
-
if (namedAt !== null && !Number.isFinite(namedAt)) continue;
|
|
199
|
-
const ageMs = now - (namedAt ?? entry.mtimeMs);
|
|
200
|
-
if (ageMs <= AGENT_TRASH_MIN_AGE_MS) continue;
|
|
201
|
-
const target = join(trash, entry.name);
|
|
202
|
-
out.push({ id: `agent-trash:${target}`, kind: "agent-trash", target, reason: say(REASON_AGENT_TRASH, { days: AGENT_TRASH_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
|
|
203
|
-
}
|
|
204
|
-
return out;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
37
|
/** Read-only: every candidate, each path kept strictly inside its own root. */
|
|
208
38
|
export async function findCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
|
|
209
39
|
const found: GcCandidate[] = [];
|
|
210
|
-
found.push(...
|
|
211
|
-
|
|
212
|
-
found.push(...agentTrashCandidates(roots, deps).filter((c) => guarded(join(roots.agents, ".trash"), c.target)));
|
|
213
|
-
found.push(...(await omoCandidates(roots, deps)).filter((c) => guarded(roots.omo, c.target)));
|
|
214
|
-
found.push(...updateCandidates(roots, deps).filter((c) => guarded(roots.update, c.target)));
|
|
215
|
-
found.push(...(await tempCandidates(roots, deps)).filter((c) => guarded(roots.tmp, c.target)));
|
|
216
|
-
found.push(...await daemonCandidates(deps));
|
|
217
|
-
}
|
|
218
|
-
const sized: GcCandidate[] = [];
|
|
219
|
-
for (const candidate of found) sized.push(candidate.bytes === null ? { ...candidate, bytes: await deps.size(candidate.target) } : candidate);
|
|
220
|
-
return sized;
|
|
40
|
+
for (const collector of GC_COLLECTORS) found.push(...await collector.listed({ roots, deps }));
|
|
41
|
+
return sized(found, deps);
|
|
221
42
|
}
|
|
222
43
|
|
|
223
44
|
export async function report(roots: GcRoots, deps: GcDeps): Promise<GcReport> {
|
|
@@ -246,9 +67,10 @@ export async function clean(roots: GcRoots, deps: GcDeps, selection: GcSelection
|
|
|
246
67
|
const failed: { id: string; error: string }[] = [];
|
|
247
68
|
for (const candidate of picked) {
|
|
248
69
|
try {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
cleaned.push(candidate);
|
|
70
|
+
const collector = GC_COLLECTORS.find((c) => c.name === candidate.kind)!;
|
|
71
|
+
const done = await collector.act(toCandidate(candidate), { roots, deps });
|
|
72
|
+
if (done.ok) cleaned.push(candidate);
|
|
73
|
+
else failed.push({ id: candidate.id, error: done.error ?? "act_failed" });
|
|
252
74
|
} catch (error) {
|
|
253
75
|
failed.push({ id: candidate.id, error: error instanceof Error ? error.message : String(error) });
|
|
254
76
|
}
|
|
@@ -256,25 +78,6 @@ export async function clean(roots: GcRoots, deps: GcDeps, selection: GcSelection
|
|
|
256
78
|
return { cleaned, failed, missing };
|
|
257
79
|
}
|
|
258
80
|
|
|
259
|
-
/** `rpc-host-gc.ts list --json` output; any malformed entry makes the whole list unreadable (null). */
|
|
260
|
-
export function parseRpcUnits(text: string): RpcUnit[] | null {
|
|
261
|
-
let body: unknown;
|
|
262
|
-
try {
|
|
263
|
-
body = JSON.parse(text);
|
|
264
|
-
} catch {
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
if (!Array.isArray(body)) return null;
|
|
268
|
-
const units: RpcUnit[] = [];
|
|
269
|
-
for (const item of body as Record<string, unknown>[]) {
|
|
270
|
-
const { version, verdict, pids, ageMs, rssKb } = item ?? {};
|
|
271
|
-
if (typeof version !== "string" || typeof verdict !== "string" || typeof ageMs !== "number" || typeof rssKb !== "number") return null;
|
|
272
|
-
if (!Array.isArray(pids) || !pids.every((pid) => Number.isInteger(pid) && pid > 1)) return null;
|
|
273
|
-
units.push({ version, verdict, pids, ageMs, rssKb });
|
|
274
|
-
}
|
|
275
|
-
return units;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
81
|
function entryType(path: string): GcEntry | null {
|
|
279
82
|
try {
|
|
280
83
|
const stat = lstatSync(path);
|
|
@@ -293,9 +96,10 @@ async function output(command: string[], env?: Record<string, string>): Promise<
|
|
|
293
96
|
|
|
294
97
|
/**
|
|
295
98
|
* Real file system and process access. `rpcEnv` is the environment of the `rpc-host-gc.ts` child, so it reads the same
|
|
296
|
-
* home as the caller; `packageRoot` is where host/omo-bridge/rpc-host-gc.ts is
|
|
99
|
+
* home as the caller; `packageRoot` is where host/omo-bridge/rpc-host-gc.ts is; `trash` moves a removed path away (tests
|
|
100
|
+
* inject one that stays inside their temp root).
|
|
297
101
|
*/
|
|
298
|
-
export function systemGcDeps(packageRoot: string, rpcEnv: Record<string, string | undefined
|
|
102
|
+
export function systemGcDeps(packageRoot: string, rpcEnv: Record<string, string | undefined>, trash: Trash = moveToTrash): GcDeps {
|
|
299
103
|
return {
|
|
300
104
|
now: Date.now,
|
|
301
105
|
list: (dir) => {
|
|
@@ -339,7 +143,11 @@ export function systemGcDeps(packageRoot: string, rpcEnv: Record<string, string
|
|
|
339
143
|
const run = await output([process.execPath, join(packageRoot, "host/omo-bridge/rpc-host-gc.ts"), "list", "--json"], env);
|
|
340
144
|
return run.code === 0 ? parseRpcUnits(run.out) : null;
|
|
341
145
|
},
|
|
342
|
-
remove: (path) =>
|
|
146
|
+
remove: async (path, kind) => {
|
|
147
|
+
const moved = await trash(path, kind);
|
|
148
|
+
if (!moved.ok) throw new Error(moved.error ?? "trash_failed");
|
|
149
|
+
return moved;
|
|
150
|
+
},
|
|
343
151
|
terminate: (pid) => process.kill(pid, "SIGTERM"),
|
|
344
152
|
};
|
|
345
153
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { GC_CLEAN_PATH, GC_PATH, type GcCandidate, type GcCleanResult, type GcKind, type GcReport, parseSelection as parseGcSelection } from "../gc.ts";
|
|
2
|
+
import type { InstallKind } from "../runtime/install-kind.ts";
|
|
3
|
+
import { AUDIT_TAIL_DEFAULT, AUDIT_TAIL_MAX, type AuditLine } from "./audit.ts";
|
|
4
|
+
import { GC_KINDS } from "./collectors/shared.ts";
|
|
5
|
+
import type { CleanResult, CollectorReport, Housekeeping, Selection } from "./engine.ts";
|
|
6
|
+
import type { Candidate, Confirmation, Reversibility, Role, Trigger, Verdict } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Housekeeping todo 5: the web-port API of the engine and the /api/gc adapters kept for one released version (N-1).
|
|
10
|
+
* GET /api/housekeeping runs `scan("web_open")` (`?trigger=doctor` runs `scan("doctor")`) and returns the latest result of
|
|
11
|
+
* every collector by role; POST /api/housekeeping/clean acts only on the picked ids/collectors (operator).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const HOUSEKEEPING_PATH = "/api/housekeeping";
|
|
15
|
+
export const HOUSEKEEPING_CLEAN_PATH = "/api/housekeeping/clean";
|
|
16
|
+
export const HOUSEKEEPING_AUDIT_PATH = "/api/housekeeping/audit";
|
|
17
|
+
|
|
18
|
+
export interface CandidateView extends Candidate { readonly verdict: Verdict; readonly confirmation: Confirmation; readonly reversibility: Reversibility }
|
|
19
|
+
export interface KeptView { readonly id: string; readonly collector: string; readonly target: string; readonly why: string; readonly bytes: number | null; readonly reversibility: Reversibility }
|
|
20
|
+
export interface CollectorView { readonly name: string; readonly confirmation: Confirmation; readonly reversibility: Reversibility; readonly scannedAt: string | null; readonly trigger: Trigger | null; readonly error: string | null }
|
|
21
|
+
/** `candidates` are the open act/confirm verdicts (already acted items are left out); `kept` shows what a rule keeps, with why. */
|
|
22
|
+
export interface RoleView {
|
|
23
|
+
readonly total: number;
|
|
24
|
+
readonly bytes: number;
|
|
25
|
+
readonly counts: Readonly<Record<string, number>>;
|
|
26
|
+
readonly candidates: readonly CandidateView[];
|
|
27
|
+
readonly kept: readonly KeptView[];
|
|
28
|
+
readonly collectors: readonly CollectorView[];
|
|
29
|
+
}
|
|
30
|
+
export interface HousekeepingReport { readonly kind: InstallKind; readonly roles: Readonly<Record<Role, RoleView>> }
|
|
31
|
+
|
|
32
|
+
function roleView(reports: readonly CollectorReport[]): RoleView {
|
|
33
|
+
const candidates: CandidateView[] = [];
|
|
34
|
+
const kept: KeptView[] = [];
|
|
35
|
+
const counts: Record<string, number> = {};
|
|
36
|
+
for (const r of reports) {
|
|
37
|
+
let open = 0;
|
|
38
|
+
for (const item of r.items) {
|
|
39
|
+
if (item.result !== undefined) continue;
|
|
40
|
+
const c = item.candidate;
|
|
41
|
+
if (item.verdict.kind === "keep") {
|
|
42
|
+
kept.push({ id: c.id, collector: c.collector, target: c.target, why: item.verdict.why, bytes: c.bytes, reversibility: r.reversibility });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
open += 1;
|
|
46
|
+
candidates.push({ ...c, verdict: item.verdict, confirmation: r.confirmation, reversibility: r.reversibility });
|
|
47
|
+
}
|
|
48
|
+
counts[r.name] = open;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
total: candidates.length,
|
|
52
|
+
bytes: candidates.reduce((sum, c) => sum + (c.bytes ?? 0), 0),
|
|
53
|
+
counts, candidates, kept,
|
|
54
|
+
collectors: reports.map((r) => ({ name: r.name, confirmation: r.confirmation, reversibility: r.reversibility, scannedAt: r.scannedAt, trigger: r.trigger, error: r.error })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function view(kind: InstallKind, reports: readonly CollectorReport[]): HousekeepingReport {
|
|
59
|
+
return { kind, roles: { reconcile: roleView(reports.filter((r) => r.role === "reconcile")), gc: roleView(reports.filter((r) => r.role === "gc")) } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function oldCandidate(c: Candidate): GcCandidate {
|
|
63
|
+
const pids = c.details?.pids;
|
|
64
|
+
return { id: c.id, kind: c.collector as GcKind, target: c.target, reason: c.reason, ageMs: c.ageMs ?? 0, bytes: c.bytes, ...(Array.isArray(pids) ? { pids: pids as number[] } : {}) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The old GcReport: the six kinds of improvement 66 only, built from the gc role. */
|
|
68
|
+
export function gcView(report: HousekeepingReport): GcReport {
|
|
69
|
+
const candidates = report.roles.gc.candidates.filter((c) => (GC_KINDS as readonly string[]).includes(c.collector)).map(oldCandidate);
|
|
70
|
+
const counts = Object.fromEntries(GC_KINDS.map((kind) => [kind, candidates.filter((c) => c.kind === kind).length])) as Record<GcKind, number>;
|
|
71
|
+
return { kind: report.kind, total: candidates.length, bytes: candidates.reduce((sum, c) => sum + (c.bytes ?? 0), 0), counts, candidates };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The old GcCleanResult; a picked candidate a rule now keeps is reported failed with `kept:<why>`. */
|
|
75
|
+
export function gcCleanResult(result: CleanResult): GcCleanResult {
|
|
76
|
+
return {
|
|
77
|
+
cleaned: result.cleaned.map(oldCandidate),
|
|
78
|
+
failed: [...result.failed, ...result.kept.map((k) => ({ id: k.id, error: `kept:${k.why}` }))],
|
|
79
|
+
missing: result.missing,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type SelectionError = "selection_required" | "unknown_collector";
|
|
84
|
+
|
|
85
|
+
/** The body of POST /api/housekeeping/clean: `{ ids?: string[], collectors?: string[] }` with at least one entry. */
|
|
86
|
+
export function parseSelection(body: unknown, collectors: readonly string[]): Selection | SelectionError {
|
|
87
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return "selection_required";
|
|
88
|
+
const { ids = [], collectors: names = [] } = body as { ids?: unknown; collectors?: unknown };
|
|
89
|
+
if (!Array.isArray(ids) || !Array.isArray(names)) return "selection_required";
|
|
90
|
+
if (!ids.every((id) => typeof id === "string") || !names.every((name) => typeof name === "string")) return "selection_required";
|
|
91
|
+
if (ids.length + names.length === 0) return "selection_required";
|
|
92
|
+
if (!names.every((name) => collectors.includes(name))) return "unknown_collector";
|
|
93
|
+
return { ids: ids as string[], collectors: names as string[] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs scans and cleans one at a time, in call order, so two triggers never run collectors at once (no timers: a promise
|
|
98
|
+
* chain). A scan asked for while a scan of the same trigger is still queued joins that queued scan.
|
|
99
|
+
*/
|
|
100
|
+
export function serialize(housekeeping: Housekeeping): Housekeeping {
|
|
101
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
102
|
+
const queued = new Map<Trigger, Promise<CollectorReport[]>>();
|
|
103
|
+
const run = <T>(task: () => Promise<T>): Promise<T> => {
|
|
104
|
+
const next = tail.then(task, task);
|
|
105
|
+
tail = next.catch(() => undefined);
|
|
106
|
+
return next;
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
scan(trigger) {
|
|
110
|
+
const waiting = queued.get(trigger);
|
|
111
|
+
if (waiting !== undefined) return waiting;
|
|
112
|
+
const next = run(() => {
|
|
113
|
+
queued.delete(trigger);
|
|
114
|
+
return housekeeping.scan(trigger);
|
|
115
|
+
});
|
|
116
|
+
queued.set(trigger, next);
|
|
117
|
+
return next;
|
|
118
|
+
},
|
|
119
|
+
clean: (selection, actor) => run(() => housekeeping.clean(selection, actor)),
|
|
120
|
+
report: () => housekeeping.report(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface HousekeepingApi {
|
|
125
|
+
readonly housekeeping: Housekeeping;
|
|
126
|
+
readonly kind: InstallKind;
|
|
127
|
+
readonly audit: { tail(limit?: number): AuditLine[] };
|
|
128
|
+
readonly collectors: readonly string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const bad = (error: string) => Response.json({ error }, { status: 400 });
|
|
132
|
+
|
|
133
|
+
export async function handleHousekeeping(req: Request, api: HousekeepingApi): Promise<Response | null> {
|
|
134
|
+
const url = new URL(req.url);
|
|
135
|
+
const { housekeeping, kind } = api;
|
|
136
|
+
if (url.pathname === HOUSEKEEPING_PATH && req.method === "GET") {
|
|
137
|
+
const asked = url.searchParams.get("trigger");
|
|
138
|
+
if (asked !== null && asked !== "doctor") return bad("bad_trigger");
|
|
139
|
+
await housekeeping.scan(asked === "doctor" ? "doctor" : "web_open");
|
|
140
|
+
return Response.json(view(kind, housekeeping.report()));
|
|
141
|
+
}
|
|
142
|
+
if (url.pathname === HOUSEKEEPING_CLEAN_PATH && req.method === "POST") {
|
|
143
|
+
const selection = parseSelection(await req.json().catch(() => null), api.collectors);
|
|
144
|
+
if (typeof selection === "string") return bad(selection);
|
|
145
|
+
return Response.json(await housekeeping.clean(selection, "operator"));
|
|
146
|
+
}
|
|
147
|
+
if (url.pathname === HOUSEKEEPING_AUDIT_PATH && req.method === "GET") {
|
|
148
|
+
const asked = url.searchParams.get("limit");
|
|
149
|
+
if (asked !== null && !/^[1-9]\d*$/.test(asked)) return bad("bad_limit");
|
|
150
|
+
const limit = asked === null ? AUDIT_TAIL_DEFAULT : Math.min(Number(asked), AUDIT_TAIL_MAX);
|
|
151
|
+
return Response.json({ lines: api.audit.tail(limit) });
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === GC_PATH && req.method === "GET") {
|
|
154
|
+
await housekeeping.scan("web_open");
|
|
155
|
+
return Response.json(gcView(view(kind, housekeeping.report())));
|
|
156
|
+
}
|
|
157
|
+
if (url.pathname === GC_CLEAN_PATH && req.method === "POST") {
|
|
158
|
+
const selection = parseGcSelection(await req.json().catch(() => null));
|
|
159
|
+
if (selection === null) return bad("selection_required");
|
|
160
|
+
return Response.json(gcCleanResult(await housekeeping.clean({ ids: selection.ids, collectors: selection.kinds }, "operator")));
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { ActResult, Trigger, Verdict } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type AuditActor = "auto" | "operator" | "reconcile";
|
|
6
|
+
|
|
7
|
+
/** One line of `<home>/logs/housekeeping.jsonl`. `trigger` is null for an operator or reconcile clean; `verdict` "error" means find/judge threw. */
|
|
8
|
+
export interface AuditLine {
|
|
9
|
+
at: string;
|
|
10
|
+
trigger: Trigger | null;
|
|
11
|
+
actor: AuditActor;
|
|
12
|
+
collector: string;
|
|
13
|
+
id: string | null;
|
|
14
|
+
target: string | null;
|
|
15
|
+
verdict: Verdict["kind"] | "error";
|
|
16
|
+
evidence: Record<string, unknown> | null;
|
|
17
|
+
method: ActResult["method"] | null;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AuditSink {
|
|
23
|
+
append(line: AuditLine): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const AUDIT_TAIL_DEFAULT = 100;
|
|
27
|
+
export const AUDIT_TAIL_MAX = 1000;
|
|
28
|
+
|
|
29
|
+
export function auditPath(home: string): string {
|
|
30
|
+
return join(home, "logs", "housekeeping.jsonl");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAudit(file: string): AuditSink & { tail(limit?: number): AuditLine[] } {
|
|
34
|
+
return {
|
|
35
|
+
append(line) {
|
|
36
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
37
|
+
appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
38
|
+
},
|
|
39
|
+
tail(limit = AUDIT_TAIL_DEFAULT) {
|
|
40
|
+
const count = Math.min(Math.max(Math.floor(limit), 0), AUDIT_TAIL_MAX);
|
|
41
|
+
if (count === 0) return [];
|
|
42
|
+
let text: string;
|
|
43
|
+
try {
|
|
44
|
+
text = readFileSync(file, "utf8");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const lines: AuditLine[] = [];
|
|
50
|
+
for (const raw of text.split("\n")) {
|
|
51
|
+
if (raw.trim() === "") continue;
|
|
52
|
+
try {
|
|
53
|
+
lines.push(JSON.parse(raw) as AuditLine);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lines.slice(-count);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { say, type Text } from "../../server-text.ts";
|
|
2
|
+
import type { Candidate, Collector } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
export interface AgentCollaborationCleanupRow { readonly artifactId: string; readonly target: string; readonly state: string; readonly bytes: number }
|
|
5
|
+
export interface AgentCollaborationCleanupDeps {
|
|
6
|
+
readonly list: () => readonly AgentCollaborationCleanupRow[];
|
|
7
|
+
/** Moves one artifact to the Trash after the Store repeats its reference checks; a kept reference throws. */
|
|
8
|
+
readonly cleanup: (artifactId: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REASON: Text = { en: "Agent collaboration file no longer kept by a request ({state})", ko: "더 이상 요청이 보관하지 않는 에이전트 협업 파일입니다({state})" };
|
|
12
|
+
const candidate = (row: AgentCollaborationCleanupRow): Candidate => ({ id: `agent-collaboration:${row.artifactId}`, collector: "agent-collaboration",
|
|
13
|
+
target: row.target, reason: say(REASON, { state: row.state }), ageMs: null, bytes: row.bytes, details: { artifactId: row.artifactId } });
|
|
14
|
+
|
|
15
|
+
/** Only an operator-selected artifact moves to the Trash; time plays no part and a scan changes nothing. */
|
|
16
|
+
export const agentCollaborationCleanup: Collector<AgentCollaborationCleanupDeps> = {
|
|
17
|
+
name: "agent-collaboration",
|
|
18
|
+
role: "gc",
|
|
19
|
+
triggers: ["web_open", "doctor"],
|
|
20
|
+
confirmation: "operator",
|
|
21
|
+
reversibility: "trash",
|
|
22
|
+
find: async deps => deps.list().map(candidate),
|
|
23
|
+
judge: async (c, deps) => {
|
|
24
|
+
const row = deps.list().find(item => item.artifactId === c.details?.artifactId);
|
|
25
|
+
if (!row) return { kind: "keep", why: "no_longer_a_candidate" };
|
|
26
|
+
return { kind: "act", why: "rule_holds", evidence: { state: row.state } };
|
|
27
|
+
},
|
|
28
|
+
act: async (c, deps) => {
|
|
29
|
+
try { await deps.cleanup(String(c.details?.artifactId)); }
|
|
30
|
+
catch (error) { return { ok: false, method: "trash", error: error instanceof Error ? error.message : String(error) }; }
|
|
31
|
+
return { ok: true, method: "trash" };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { utcIsoTime } from "../../agent/files.ts";
|
|
3
|
+
import { say, type Text } from "../../server-text.ts";
|
|
4
|
+
import { AGENT_TRASH_MIN_AGE_MS, DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Agent drafts in agents/.drafts/ and archived drafts in agents/.drafts-trash/, unchanged for AGENT_TRASH_MIN_AGE_MS:
|
|
8
|
+
* the age comes from the draft's own `updatedAt` (a strict UTC ISO time), or from the file mtime when the draft cannot be
|
|
9
|
+
* read or the time is not one toISOString writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const REASON_AGENT_DRAFT: Text = { en: "Agent draft not changed for more than {days} days", ko: "{days}일 넘게 바뀌지 않은 에이전트 임시 저장" };
|
|
13
|
+
|
|
14
|
+
function updatedAt(text: string | null): number | null {
|
|
15
|
+
if (text === null) return null;
|
|
16
|
+
let value: { readonly updatedAt?: unknown } | null;
|
|
17
|
+
try {
|
|
18
|
+
// Any JSON value: property access on a number, string or array yields undefined, and null is caught by ?.
|
|
19
|
+
value = JSON.parse(text);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const at = value?.updatedAt;
|
|
24
|
+
return typeof at === "string" ? utcIsoTime(at) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function agentDraftCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
28
|
+
const now = deps.now();
|
|
29
|
+
const out: GcCandidate[] = [];
|
|
30
|
+
for (const folder of [".drafts", ".drafts-trash"]) {
|
|
31
|
+
const dir = join(roots.agents, folder);
|
|
32
|
+
for (const entry of deps.list(dir) ?? []) {
|
|
33
|
+
if (entry.type !== "file" || !entry.name.endsWith(".json")) continue;
|
|
34
|
+
const target = join(dir, entry.name);
|
|
35
|
+
const ageMs = now - (updatedAt(deps.readText(target)) ?? entry.mtimeMs);
|
|
36
|
+
if (ageMs <= AGENT_TRASH_MIN_AGE_MS) continue;
|
|
37
|
+
out.push({ id: `agent-drafts:${target}`, kind: "agent-drafts", target, reason: say(REASON_AGENT_DRAFT, { days: AGENT_TRASH_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const agentDrafts = gcCollector({ kind: "agent-drafts", npmOnly: true, root: (roots) => roots.agents, candidates: agentDraftCandidates });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { parseAgentFileTrashName } from "../../agent/files.ts";
|
|
3
|
+
import { say, type Text } from "../../server-text.ts";
|
|
4
|
+
import { AGENT_TRASH_MIN_AGE_MS, DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deleted agent knowledge files in agents/.file-trash/<id>/, older than AGENT_TRASH_MIN_AGE_MS by the time in the
|
|
8
|
+
* entry name only (the store writes it; folder and file mtimes change when files move). Each file is one candidate; the
|
|
9
|
+
* per-agent folders stay for the next deletion.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const REASON_AGENT_FILE_TRASH: Text = { en: "Deleted agent knowledge file older than {days} days", ko: "삭제한 지 {days}일 지난 에이전트 지식 파일" };
|
|
13
|
+
|
|
14
|
+
export function agentFileTrashCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
15
|
+
const trash = join(roots.agents, ".file-trash");
|
|
16
|
+
const agents = deps.list(trash);
|
|
17
|
+
if (agents === null) return [];
|
|
18
|
+
const now = deps.now();
|
|
19
|
+
const out: GcCandidate[] = [];
|
|
20
|
+
for (const agent of agents) {
|
|
21
|
+
if (agent.type !== "dir") continue;
|
|
22
|
+
const folder = join(trash, agent.name);
|
|
23
|
+
for (const entry of deps.list(folder) ?? []) {
|
|
24
|
+
if (entry.type !== "file") continue;
|
|
25
|
+
const parsed = parseAgentFileTrashName(entry.name);
|
|
26
|
+
if (parsed === null) continue;
|
|
27
|
+
const ageMs = now - parsed.deletedAt;
|
|
28
|
+
if (ageMs <= AGENT_TRASH_MIN_AGE_MS) continue;
|
|
29
|
+
const target = join(folder, entry.name);
|
|
30
|
+
out.push({ id: `agent-file-trash:${target}`, kind: "agent-file-trash", target, reason: say(REASON_AGENT_FILE_TRASH, { days: AGENT_TRASH_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const agentFileTrash = gcCollector({ kind: "agent-file-trash", npmOnly: true, root: (roots) => join(roots.agents, ".file-trash"), candidates: agentFileTrashCandidates });
|