zigmini 0.9.0-beta.3 → 1.0.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 +36 -4
- package/README.md +36 -4
- package/assets/agent/gateway-rules.md +29 -19
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +60 -11
- package/docs/manual.md +61 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +97 -13
- package/src/approvals.ts +6 -1
- package/src/cli/doctor.ts +7 -0
- package/src/cli/main.ts +38 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/rules.ts +70 -0
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +172 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
package/src/decisions.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { decisionContext } from "./decision-context.ts";
|
|
|
3
3
|
import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
|
|
4
4
|
import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
|
|
5
5
|
import type { OmoEvent } from "./omo/client.ts";
|
|
6
|
+
import { say, type Text } from "./server-text.ts";
|
|
6
7
|
import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
|
|
7
8
|
import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
|
|
8
9
|
|
|
@@ -66,9 +67,9 @@ function detachedQuestion(decision: Decision): boolean {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/** Chat card footer for an item the user deleted from the inbox (web markResolved, Android resolvedLabel). */
|
|
69
|
-
const DISMISSED_REASONS: Record<string,
|
|
70
|
-
user_cancelled: "확인함에서 삭제해 질문을 취소했습니다",
|
|
71
|
-
user_dismissed: "확인함에서 삭제했습니다",
|
|
70
|
+
export const DISMISSED_REASONS: Readonly<Record<string, Text>> = {
|
|
71
|
+
user_cancelled: { en: "Deleted from the inbox, so the question was cancelled", ko: "확인함에서 삭제해 질문을 취소했습니다" },
|
|
72
|
+
user_dismissed: { en: "Deleted from the inbox", ko: "확인함에서 삭제했습니다" },
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
export function parseDecisionDismiss(value: unknown): number {
|
|
@@ -576,7 +577,8 @@ export class DecisionService {
|
|
|
576
577
|
if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
|
|
577
578
|
const answer = record(decision.answer) ? decision.answer : {};
|
|
578
579
|
const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
|
|
579
|
-
const
|
|
580
|
+
const dismissedText = decision.status === "closed" && Object.hasOwn(DISMISSED_REASONS, decision.reason ?? "") ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
|
|
581
|
+
const dismissed = dismissedText ? say(dismissedText) : undefined;
|
|
580
582
|
const event = this.store.appendEvent(decision.ownerConv, "action.resolved", {
|
|
581
583
|
actionId: decision.legacyActionId ?? `${decision.scope}::${decision.sourceRequestId}`,
|
|
582
584
|
decisionId: decision.id, receiptConfirmed: decision.status === "delivered",
|
package/src/engine/compat.json
CHANGED
package/src/engine/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export type { EngineAdapter, EngineLaunch, EngineLaunchArgs } from "./adapter.ts
|
|
|
2
2
|
export { omoAi as engine, CORE_NAME, ENGINE_PACKAGE, PLUGIN, OMO_REGISTRY_URL,
|
|
3
3
|
readEnginePin, formatEnginePin, engineSearchPath, daemonPattern, type EnginePin } from "./omo-ai.ts";
|
|
4
4
|
export { WORKER, countElements, supportedSchema } from "./thread-schema.ts";
|
|
5
|
+
export { loadEngineSdk } from "./sdk.ts";
|
|
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { say, type Text } from "../server-text.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Bug 73 prevention (plan Todo 12). The gateway's OMO shares the user's agent folder, whose thread gateway store
|
|
@@ -17,7 +18,15 @@ export const STORE = join("gateway", "gateway.sqlite");
|
|
|
17
18
|
export const GUARD_FILE = "schema-guard.json";
|
|
18
19
|
export const GLOBAL_ENGINE_PACKAGE = join("install", "global", "node_modules", "omo-ai", "package.json");
|
|
19
20
|
export const SCHEMA_TOO_NEW = "schema_too_new";
|
|
20
|
-
export const NOTICE_TITLE = "엔진 스키마 충돌: 브리지를 멈췄습니다";
|
|
21
|
+
export const NOTICE_TITLE: Text = { en: "Engine schema conflict: the bridge was stopped", ko: "엔진 스키마 충돌: 브리지를 멈췄습니다" };
|
|
22
|
+
|
|
23
|
+
const TOO_NEW: Text = {
|
|
24
|
+
en: "The shared folder schema is {schema}, which this gateway engine cannot read (global OMO installed now: {version}; who raised the schema was not checked). " +
|
|
25
|
+
"Get a matching version with `zigmini update`, or use an agent folder only for this gateway (`zigmini setup --agent-dir`)",
|
|
26
|
+
ko: "공유 폴더 스키마가 {schema}이라 이 게이트웨이 엔진이 읽지 못합니다(지금 설치된 전역 OMO: {version}, 스키마를 올린 주체는 확인하지 않음). " +
|
|
27
|
+
"`zigmini update`로 맞는 버전을 받거나, 이 게이트웨이 전용 에이전트 폴더를 쓰세요(`zigmini setup --agent-dir`)",
|
|
28
|
+
};
|
|
29
|
+
const VERSION_UNKNOWN: Text = { en: "version unknown", ko: "버전 알 수 없음" };
|
|
21
30
|
|
|
22
31
|
export type StoreReading =
|
|
23
32
|
| { readonly state: "ok"; readonly version: number }
|
|
@@ -128,8 +137,7 @@ export function compareVersions(a: string, b: string): number {
|
|
|
128
137
|
|
|
129
138
|
/** Findings 214-217: the installed global version is an observation, not the proven actor of the schema change. */
|
|
130
139
|
export function tooNewMessage(schema: number, globalVersion: string | null): string {
|
|
131
|
-
return
|
|
132
|
-
"`zigmini update`로 맞는 버전을 받거나, 이 게이트웨이 전용 에이전트 폴더를 쓰세요(`zigmini setup --agent-dir`)";
|
|
140
|
+
return say(TOO_NEW, { schema, version: globalVersion ?? say(VERSION_UNKNOWN) });
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
export const SCHEMA_INCOMPATIBLE = "schema_incompatible";
|
|
@@ -210,7 +218,7 @@ export function healthOmo(status: string, saved: SavedGuard | null): string {
|
|
|
210
218
|
/** The one notice per refusal (keyed by its time) the gateway sends to the web and app; null when already sent. */
|
|
211
219
|
export function pendingNotice(saved: SavedGuard | null, status: string, sentAt: string | null): { readonly key: string; readonly title: string; readonly note: string } | null {
|
|
212
220
|
if (saved === null || healthOmo(status, saved) !== SCHEMA_TOO_NEW || saved.at === sentAt) return null;
|
|
213
|
-
return { key: saved.at, title: NOTICE_TITLE, note: saved.message ?? "" };
|
|
221
|
+
return { key: saved.at, title: say(NOTICE_TITLE), note: saved.message ?? "" };
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
/** Doctor: a global OMO newer than the gateway's pinned engine can migrate the shared store past it. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { runtimeHome } from "../runtime/paths.ts";
|
|
3
|
+
import { omoAi } from "./omo-ai.ts";
|
|
4
|
+
|
|
5
|
+
/** Load the public SDK of the installed engine. The module name stays inside the Docker-shipped engine boundary. */
|
|
6
|
+
export function loadEngineSdk<T>(): Promise<T> {
|
|
7
|
+
return import(Bun.resolveSync("@code-yeongyu/senpi", realpathSync(omoAi.binPath(runtimeHome()))));
|
|
8
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { say, type Text } from "./server-text.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Improvement 250 and bug 259: the formal verification setting. One switch per tool in FORMAL_TOOLS (only TLA+/TLC
|
|
8
|
+
* today; later tools are new registry entries); a switch means "use this tool in checks once it is installed" and never
|
|
9
|
+
* installs anything. Only the tool's Install action (POST FORMAL_TOOLS_PATH/<id>/install) installs it with Homebrew
|
|
10
|
+
* (`brew install <package>`, never `brew link`, so the system java stays as it is) and downloads its pinned files with a
|
|
11
|
+
* size and SHA-256 check. A failed install shows the cause on that tool and keeps its switch. The state is one JSON file
|
|
12
|
+
* in the data dir; installed files live in <home>/formal-tools.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const FORMAL_VERIFICATION_PATH = "/api/settings/formal-verification";
|
|
16
|
+
export const FORMAL_TOOLS_PATH = `${FORMAL_VERIFICATION_PATH}/tools`;
|
|
17
|
+
|
|
18
|
+
export interface FormalArtifact { readonly name: string; readonly url: string; readonly sha256: string; readonly size: number }
|
|
19
|
+
export interface FormalTool {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly description: Text;
|
|
23
|
+
/** Homebrew formulas; each must provide `<brew --prefix formula>/bin/java` when `java` names it. */
|
|
24
|
+
readonly brew: readonly string[];
|
|
25
|
+
readonly java?: string;
|
|
26
|
+
readonly artifacts: readonly FormalArtifact[];
|
|
27
|
+
/** Default of the tool switch; the overall switch is off by default. */
|
|
28
|
+
readonly defaultOn: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const FORMAL_TOOLS: readonly FormalTool[] = [{
|
|
32
|
+
id: "tla-tlc",
|
|
33
|
+
label: "TLA+ (TLC 1.7.4)",
|
|
34
|
+
description: {
|
|
35
|
+
en: "Checks every execution order of concurrent designs (such as session replacement). Installs Homebrew openjdk@21 and tla2tools.jar 1.7.4.",
|
|
36
|
+
ko: "동시 실행 설계(세션 교체 등)의 모든 실행 순서를 검사합니다. Homebrew openjdk@21과 tla2tools.jar 1.7.4를 설치합니다.",
|
|
37
|
+
},
|
|
38
|
+
brew: ["openjdk@21"],
|
|
39
|
+
java: "openjdk@21",
|
|
40
|
+
artifacts: [{
|
|
41
|
+
name: "tla2tools-1.7.4.jar",
|
|
42
|
+
url: "https://github.com/tlaplus/tlaplus/releases/download/v1.7.4/tla2tools.jar",
|
|
43
|
+
sha256: "936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88",
|
|
44
|
+
size: 2274532,
|
|
45
|
+
}],
|
|
46
|
+
defaultOn: true,
|
|
47
|
+
}];
|
|
48
|
+
|
|
49
|
+
export interface FormalSettings { readonly tools: Readonly<Record<string, boolean>> }
|
|
50
|
+
export type ToolStatus = "off" | "not_installed" | "installing" | "installed" | "failed";
|
|
51
|
+
export interface ToolState {
|
|
52
|
+
readonly id: string; readonly label: string; readonly description: string;
|
|
53
|
+
readonly enabled: boolean; readonly status: ToolStatus; readonly error: string | null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `enabled` (some tool switch is on) and `error` (always null) stay for apps from before bug 259, which read them and show
|
|
57
|
+
* an overall switch; the web and newer apps show only the tools. `revision` grows with every published change (it starts
|
|
58
|
+
* at the process start time in ms, so it also grows across a restart); a client drops a state older than the one it shows,
|
|
59
|
+
* because the answer to an Install request can arrive after the events of an install that already ended.
|
|
60
|
+
*/
|
|
61
|
+
export interface FormalState {
|
|
62
|
+
readonly enabled: boolean; readonly busy: boolean; readonly error: string | null; readonly revision: number; readonly tools: readonly ToolState[];
|
|
63
|
+
}
|
|
64
|
+
export interface Installed { readonly java: string | null; readonly files: Readonly<Record<string, string>> }
|
|
65
|
+
|
|
66
|
+
export const FORMAL_ERRORS: Readonly<Record<string, Text>> = {
|
|
67
|
+
brew_missing: { en: "Homebrew (brew) was not found. Install Homebrew from https://brew.sh, then press Install again.", ko: "Homebrew(brew)를 찾지 못했습니다. https://brew.sh 에서 Homebrew를 설치한 뒤 설치를 다시 누르세요." },
|
|
68
|
+
brew_failed: { en: "brew install failed.", ko: "brew install이 실패했습니다." },
|
|
69
|
+
java_missing: { en: "The installed Java was not found.", ko: "설치한 Java를 찾지 못했습니다." },
|
|
70
|
+
download_failed: { en: "The tool files could not be downloaded.", ko: "도구 파일을 내려받지 못했습니다." },
|
|
71
|
+
size_mismatch: { en: "The size of the downloaded file differs from the pinned value.", ko: "내려받은 파일의 크기가 고정값과 다릅니다." },
|
|
72
|
+
hash_mismatch: { en: "The SHA-256 of the downloaded file differs from the pinned value.", ko: "내려받은 파일의 SHA-256이 고정값과 다릅니다." },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The error text of a code in the current language; an unknown code stays as the code. */
|
|
76
|
+
function errorText(code: string): string {
|
|
77
|
+
const text = FORMAL_ERRORS[code];
|
|
78
|
+
return text === undefined ? code : say(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readFormalSettings(file: string, tools: readonly FormalTool[] = FORMAL_TOOLS): FormalSettings {
|
|
82
|
+
let raw: unknown = null;
|
|
83
|
+
try { raw = JSON.parse(readFileSync(file, "utf8")); } catch { raw = null; }
|
|
84
|
+
const body = typeof raw === "object" && raw !== null ? raw as { enabled?: unknown; tools?: unknown } : {};
|
|
85
|
+
const saved = typeof body.tools === "object" && body.tools !== null ? body.tools as Record<string, unknown> : {};
|
|
86
|
+
// A file from before bug 259 has the overall switch; when it was not on, no tool ran, so every tool stays off.
|
|
87
|
+
const legacyOff = "enabled" in body && body.enabled !== true;
|
|
88
|
+
const out: Record<string, boolean> = {};
|
|
89
|
+
for (const tool of tools) out[tool.id] = legacyOff ? false : typeof saved[tool.id] === "boolean" ? saved[tool.id] as boolean : tool.defaultOn;
|
|
90
|
+
return { tools: out };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeFormalSettings(file: string, settings: FormalSettings): void {
|
|
94
|
+
const dir = dirname(file);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const temp = join(dir, `.${basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(temp, `${JSON.stringify(settings)}\n`);
|
|
99
|
+
renameSync(temp, file);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
rmSync(temp, { force: true });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sha256File(path: string): string {
|
|
107
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The installed files of one tool, or null when any pinned file or the recorded java is missing or does not match. */
|
|
111
|
+
export function installedTool(tool: FormalTool, toolsDir: string): Installed | null {
|
|
112
|
+
const files: Record<string, string> = {};
|
|
113
|
+
for (const artifact of tool.artifacts) {
|
|
114
|
+
const path = join(toolsDir, tool.id, artifact.name);
|
|
115
|
+
if (!existsSync(path) || statSync(path).size !== artifact.size || sha256File(path) !== artifact.sha256) return null;
|
|
116
|
+
files[artifact.name] = path;
|
|
117
|
+
}
|
|
118
|
+
let java: string | null = null;
|
|
119
|
+
if (tool.java !== undefined) {
|
|
120
|
+
try { java = (JSON.parse(readFileSync(join(toolsDir, tool.id, "java.json"), "utf8")) as { java?: unknown }).java as string; }
|
|
121
|
+
catch { return null; }
|
|
122
|
+
if (typeof java !== "string" || !existsSync(java)) return null;
|
|
123
|
+
}
|
|
124
|
+
return { java, files };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface RunResult { readonly code: number; readonly stdout: string; readonly stderr: string }
|
|
128
|
+
export interface FormalDeps {
|
|
129
|
+
/** The brew executable, or null when Homebrew is not installed. */
|
|
130
|
+
readonly brew: () => string | null;
|
|
131
|
+
readonly run: (cmd: string, args: readonly string[]) => Promise<RunResult>;
|
|
132
|
+
readonly fetch: (url: string) => Promise<Response>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function findBrew(env: Record<string, string | undefined> = process.env, exists: (path: string) => boolean = existsSync): string | null {
|
|
136
|
+
const candidates = env.ZGW_BREW_BIN !== undefined ? [env.ZGW_BREW_BIN] : ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
|
|
137
|
+
return candidates.find((path) => path.length > 0 && exists(path)) ?? null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function defaultFormalDeps(env: Record<string, string | undefined> = process.env): FormalDeps {
|
|
141
|
+
return {
|
|
142
|
+
brew: () => findBrew(env),
|
|
143
|
+
run: async (cmd, args) => {
|
|
144
|
+
const child = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe", env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: "1" } });
|
|
145
|
+
const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
146
|
+
return { code, stdout, stderr };
|
|
147
|
+
},
|
|
148
|
+
fetch: (url) => fetch(url, { redirect: "follow" }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class InstallError extends Error {
|
|
153
|
+
constructor(readonly code: string, readonly detail = "") { super(code); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Installs one tool. Throws InstallError; leaves no partial download behind. */
|
|
157
|
+
export async function installTool(tool: FormalTool, toolsDir: string, deps: FormalDeps): Promise<Installed> {
|
|
158
|
+
const brew = deps.brew();
|
|
159
|
+
if (brew === null) throw new InstallError("brew_missing");
|
|
160
|
+
for (const formula of tool.brew) {
|
|
161
|
+
const listed = await deps.run(brew, ["list", "--versions", formula]);
|
|
162
|
+
if (listed.code === 0 && listed.stdout.trim() !== "") continue;
|
|
163
|
+
const result = await deps.run(brew, ["install", formula]);
|
|
164
|
+
if (result.code !== 0) throw new InstallError("brew_failed", `${formula}: ${result.stderr.trim().split("\n").slice(-3).join(" ")}`);
|
|
165
|
+
}
|
|
166
|
+
const dir = join(toolsDir, tool.id);
|
|
167
|
+
mkdirSync(dir, { recursive: true });
|
|
168
|
+
if (tool.java !== undefined) {
|
|
169
|
+
const prefix = await deps.run(brew, ["--prefix", tool.java]);
|
|
170
|
+
const java = join(prefix.stdout.trim(), "bin/java");
|
|
171
|
+
if (prefix.code !== 0 || prefix.stdout.trim() === "" || !existsSync(java)) throw new InstallError("java_missing", tool.java);
|
|
172
|
+
writeFileSync(join(dir, "java.json"), `${JSON.stringify({ java })}\n`);
|
|
173
|
+
}
|
|
174
|
+
for (const artifact of tool.artifacts) {
|
|
175
|
+
const target = join(dir, artifact.name);
|
|
176
|
+
if (existsSync(target) && statSync(target).size === artifact.size && sha256File(target) === artifact.sha256) continue;
|
|
177
|
+
let bytes: Uint8Array;
|
|
178
|
+
try {
|
|
179
|
+
const response = await deps.fetch(artifact.url);
|
|
180
|
+
if (!response.ok) throw new InstallError("download_failed", `HTTP ${response.status}`);
|
|
181
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error instanceof InstallError) throw error;
|
|
184
|
+
throw new InstallError("download_failed", error instanceof Error ? error.message : String(error));
|
|
185
|
+
}
|
|
186
|
+
if (bytes.byteLength !== artifact.size) throw new InstallError("size_mismatch", `${artifact.name}: ${bytes.byteLength}`);
|
|
187
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
188
|
+
if (hash !== artifact.sha256) throw new InstallError("hash_mismatch", artifact.name);
|
|
189
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
190
|
+
writeFileSync(temp, bytes);
|
|
191
|
+
renameSync(temp, target);
|
|
192
|
+
}
|
|
193
|
+
const installed = installedTool(tool, toolsDir);
|
|
194
|
+
if (installed === null) throw new InstallError("java_missing", tool.id);
|
|
195
|
+
return installed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function message(error: unknown): string {
|
|
199
|
+
if (error instanceof InstallError) return `${errorText(error.code)}${error.detail ? ` (${error.detail})` : ""}`;
|
|
200
|
+
return error instanceof Error ? error.message : String(error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export class FormalVerification {
|
|
204
|
+
private settings: FormalSettings;
|
|
205
|
+
private readonly status = new Map<string, { status: ToolStatus; error: string | null }>();
|
|
206
|
+
private running: Promise<void> | null = null;
|
|
207
|
+
private revision = Date.now();
|
|
208
|
+
|
|
209
|
+
constructor(readonly file: string, readonly toolsDir: string, private readonly deps: FormalDeps,
|
|
210
|
+
private readonly publish: (state: FormalState) => void = () => {}, readonly tools: readonly FormalTool[] = FORMAL_TOOLS) {
|
|
211
|
+
this.settings = readFormalSettings(file, tools);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Resolves when the install started last has ended (tests and shutdown). */
|
|
215
|
+
idle(): Promise<void> { return this.running ?? Promise.resolve(); }
|
|
216
|
+
|
|
217
|
+
state(): FormalState {
|
|
218
|
+
return {
|
|
219
|
+
enabled: this.tools.some((tool) => this.settings.tools[tool.id] === true), busy: this.running !== null, error: null, revision: this.revision,
|
|
220
|
+
tools: this.tools.map((tool) => {
|
|
221
|
+
const enabled = this.settings.tools[tool.id] === true;
|
|
222
|
+
const known = this.status.get(tool.id);
|
|
223
|
+
const status: ToolStatus = known?.status === "installing" ? "installing"
|
|
224
|
+
: installedTool(tool, this.toolsDir) !== null ? "installed"
|
|
225
|
+
: known?.status === "failed" ? "failed" : "not_installed";
|
|
226
|
+
return { id: tool.id, label: tool.label, description: say(tool.description), enabled, status, error: status === "failed" ? known?.error ?? null : null };
|
|
227
|
+
}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private save(settings: FormalSettings): void {
|
|
232
|
+
writeFormalSettings(this.file, settings);
|
|
233
|
+
this.settings = settings;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The overall PUT that apps from before bug 259 still send: on installs every switched-on tool that is not installed,
|
|
238
|
+
* off turns every tool switch off. Returns "busy" while an install runs, else the new state.
|
|
239
|
+
*/
|
|
240
|
+
setEnabled(enabled: boolean): FormalState | "busy" {
|
|
241
|
+
if (this.running) return "busy";
|
|
242
|
+
if (!enabled) {
|
|
243
|
+
this.save({ tools: Object.fromEntries(this.tools.map((tool) => [tool.id, false])) });
|
|
244
|
+
return this.emit();
|
|
245
|
+
}
|
|
246
|
+
this.start(this.tools.filter((tool) => this.settings.tools[tool.id] === true));
|
|
247
|
+
return this.emit();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Saves whether checks use the tool; installs nothing. "busy" while an install runs, "unknown_tool" for an id not in the registry. */
|
|
251
|
+
setTool(id: string, enabled: boolean): FormalState | "busy" | "unknown_tool" {
|
|
252
|
+
if (!this.tools.some((candidate) => candidate.id === id)) return "unknown_tool";
|
|
253
|
+
if (this.running) return "busy";
|
|
254
|
+
this.save({ tools: { ...this.settings.tools, [id]: enabled } });
|
|
255
|
+
return this.emit();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The Install action of one tool: installs it when it is not installed, whatever its switch. */
|
|
259
|
+
install(id: string): FormalState | "busy" | "unknown_tool" {
|
|
260
|
+
const tool = this.tools.find((candidate) => candidate.id === id);
|
|
261
|
+
if (tool === undefined) return "unknown_tool";
|
|
262
|
+
if (this.running) return "busy";
|
|
263
|
+
this.start([tool]);
|
|
264
|
+
return this.emit();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private start(tools: readonly FormalTool[]): void {
|
|
268
|
+
const pending = tools.filter((tool) => installedTool(tool, this.toolsDir) === null);
|
|
269
|
+
if (pending.length === 0) return;
|
|
270
|
+
for (const tool of pending) this.status.set(tool.id, { status: "installing", error: null });
|
|
271
|
+
this.running = (async () => {
|
|
272
|
+
for (const tool of pending) {
|
|
273
|
+
try {
|
|
274
|
+
await installTool(tool, this.toolsDir, this.deps);
|
|
275
|
+
this.status.set(tool.id, { status: "installed", error: null });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
this.status.set(tool.id, { status: "failed", error: message(error) });
|
|
278
|
+
}
|
|
279
|
+
this.emit();
|
|
280
|
+
}
|
|
281
|
+
})().finally(() => {
|
|
282
|
+
this.running = null;
|
|
283
|
+
this.emit();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private emit(): FormalState {
|
|
288
|
+
this.revision++;
|
|
289
|
+
const state = this.state();
|
|
290
|
+
this.publish(state);
|
|
291
|
+
return state;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* GET (both ports) of FORMAL_VERIFICATION_PATH; PUT (trusted web port) of it and of FORMAL_TOOLS_PATH/<id>; POST
|
|
297
|
+
* (trusted web port, no body) of FORMAL_TOOLS_PATH/<id>/install.
|
|
298
|
+
*/
|
|
299
|
+
export async function handleFormalVerification(req: Request, url: URL, trusted: boolean, formal: FormalVerification): Promise<Response | null> {
|
|
300
|
+
const root = url.pathname === FORMAL_VERIFICATION_PATH;
|
|
301
|
+
const toolId = url.pathname.startsWith(`${FORMAL_TOOLS_PATH}/`) ? decodeURIComponent(url.pathname.slice(FORMAL_TOOLS_PATH.length + 1)) : null;
|
|
302
|
+
if (!root && toolId === null) return null;
|
|
303
|
+
const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
|
|
304
|
+
if (req.method === "GET" && root) return json(formal.state());
|
|
305
|
+
const install = req.method === "POST" && toolId !== null && toolId.endsWith("/install");
|
|
306
|
+
if (req.method !== "PUT" && !install) return null;
|
|
307
|
+
if (!trusted) return json({ error: "forbidden" }, 403);
|
|
308
|
+
if (install) {
|
|
309
|
+
const result = formal.install(toolId.slice(0, -"/install".length));
|
|
310
|
+
if (result === "busy") return json({ error: "busy", state: formal.state() }, 409);
|
|
311
|
+
if (result === "unknown_tool") return json({ error: "unknown_tool" }, 404);
|
|
312
|
+
return json(result);
|
|
313
|
+
}
|
|
314
|
+
const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
|
|
315
|
+
if (typeof body?.enabled !== "boolean") return json({ error: "enabled_required" }, 400);
|
|
316
|
+
const result = root ? formal.setEnabled(body.enabled) : formal.setTool(toolId!, body.enabled);
|
|
317
|
+
if (result === "busy") return json({ error: "busy", state: formal.state() }, 409);
|
|
318
|
+
if (result === "unknown_tool") return json({ error: "unknown_tool" }, 404);
|
|
319
|
+
return json(result);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- The check entry point (bun run formal:check) ----
|
|
323
|
+
|
|
324
|
+
export interface CheckDeps {
|
|
325
|
+
readonly run: (cmd: string, args: readonly string[], cwd: string) => Promise<number>;
|
|
326
|
+
readonly log: (line: string) => void;
|
|
327
|
+
}
|
|
328
|
+
export type CheckExit = 0 | 1;
|
|
329
|
+
export interface CheckOptions { readonly repo: string; readonly settingsFile: string; readonly toolsDir: string; readonly timeoutS: number }
|
|
330
|
+
|
|
331
|
+
/** TLA+ models are specs/tla/<Name>.tla with a matching <Name>.cfg (the plan's first stage). */
|
|
332
|
+
export function tlaModels(repo: string): { spec: string; cfg: string }[] {
|
|
333
|
+
const dir = join(repo, "specs/tla");
|
|
334
|
+
if (!existsSync(dir)) return [];
|
|
335
|
+
return readdirSync(dir).filter((name) => name.endsWith(".cfg")).sort().flatMap((cfg) => {
|
|
336
|
+
const spec = `${cfg.replace(/_[^_]*\.cfg$|\.cfg$/, "")}.tla`;
|
|
337
|
+
return existsSync(join(dir, spec)) ? [{ spec: join(dir, spec), cfg: join(dir, cfg) }] : [];
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 0: every enabled, installed tool passed or was skipped with a notice; 1: a check failed. Off, not installed and no
|
|
343
|
+
* model are skips, not failures, so a project without formal verification is not blocked.
|
|
344
|
+
*/
|
|
345
|
+
export async function formalCheck(options: CheckOptions, deps: CheckDeps, tools: readonly FormalTool[] = FORMAL_TOOLS): Promise<CheckExit> {
|
|
346
|
+
const settings = readFormalSettings(options.settingsFile, tools);
|
|
347
|
+
let failed = false;
|
|
348
|
+
for (const tool of tools) {
|
|
349
|
+
if (settings.tools[tool.id] !== true) { deps.log(`${tool.label}: 설정에서 꺼져 있어 건너뜁니다.`); continue; }
|
|
350
|
+
const installed = installedTool(tool, options.toolsDir);
|
|
351
|
+
if (installed === null) { deps.log(`${tool.label}: 설치되지 않아 건너뜁니다. 설정의 형식 검증에서 이 도구의 설치 버튼을 누르면 설치합니다.`); continue; }
|
|
352
|
+
if (tool.id !== "tla-tlc") continue;
|
|
353
|
+
const models = tlaModels(options.repo);
|
|
354
|
+
if (models.length === 0) { deps.log(`${tool.label}: 검사할 모델(specs/tla/*.tla + .cfg)이 없어 건너뜁니다.`); continue; }
|
|
355
|
+
const jar = installed.files[tool.artifacts[0]!.name]!;
|
|
356
|
+
for (const model of models) {
|
|
357
|
+
const meta = join(options.toolsDir, tool.id, "states", crypto.randomUUID());
|
|
358
|
+
const code = await deps.run("perl", ["-e", "alarm shift; exec @ARGV", String(options.timeoutS), installed.java!, "-XX:+UseParallelGC",
|
|
359
|
+
"-cp", jar, "tlc2.TLC", "-workers", "auto", "-metadir", meta, "-config", model.cfg, model.spec], dirname(model.spec));
|
|
360
|
+
rmSync(meta, { recursive: true, force: true });
|
|
361
|
+
deps.log(`${tool.label}: ${basename(model.cfg)} ${code === 0 ? "통과" : `실패(종료 코드 ${code})`}`);
|
|
362
|
+
if (code !== 0) failed = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return failed ? 1 : 0;
|
|
366
|
+
}
|
package/src/gc.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { lstatSync, readdirSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import type { InstallKind } from "./runtime/install-kind.ts";
|
|
4
|
+
import { agentTrashTimestamp } from "./agent/files.ts";
|
|
5
|
+
import { say, type Text } from "./server-text.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Improvement 66 (plan Todo 24): the gateway's resource cleanup (GC). `findCandidates` only reads and lists; `clean`
|
|
@@ -12,21 +14,30 @@ import type { InstallKind } from "./runtime/install-kind.ts";
|
|
|
12
14
|
* update-file `*.log` or `request*.json` file in <home>/update/, older than UPDATE_MIN_AGE_MS.
|
|
13
15
|
* temp-dir `zgw-*` or `zigmini-*` folder in $TMPDIR, older than TEMP_MIN_AGE_MS, named by no process, opened by none.
|
|
14
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.
|
|
15
18
|
* A checkout (this Mac's Docker gateway) cannot see host processes and folders, so it lists DB backups only.
|
|
16
19
|
* A rule input that cannot be read (current link, process list, lsof) makes its kind list nothing. Daemons get SIGTERM only.
|
|
17
20
|
* Work cards, worktrees and Docker are not part of this (the open decisions of improvement 66).
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
|
-
export const GC_KINDS = ["db-backup", "omo-version", "update-file", "temp-dir", "rpc-daemon"] as const;
|
|
23
|
+
export const GC_KINDS = ["db-backup", "omo-version", "update-file", "temp-dir", "rpc-daemon", "agent-trash"] as const;
|
|
21
24
|
export type GcKind = (typeof GC_KINDS)[number];
|
|
22
25
|
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
26
|
export const KEEP_BACKUPS = 10;
|
|
24
27
|
export const BACKUP_KEEP_MS = 30 * DAY_MS;
|
|
25
28
|
export const UPDATE_MIN_AGE_MS = 7 * DAY_MS;
|
|
26
29
|
export const TEMP_MIN_AGE_MS = 7 * DAY_MS;
|
|
30
|
+
export const AGENT_TRASH_MIN_AGE_MS = 30 * DAY_MS;
|
|
27
31
|
export const GC_PATH = "/api/gc";
|
|
28
32
|
export const GC_CLEAN_PATH = "/api/gc/clean";
|
|
29
33
|
|
|
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
|
+
|
|
30
41
|
const TEMP_NAME = /^(?:zgw-|zigmini-)/;
|
|
31
42
|
const UPDATE_NAME = /^(?:.+\.log|request.*\.json)$/;
|
|
32
43
|
/** Never a candidate, whatever folder it is found in. */
|
|
@@ -52,6 +63,7 @@ export interface GcRoots {
|
|
|
52
63
|
readonly omo: string;
|
|
53
64
|
readonly update: string;
|
|
54
65
|
readonly tmp: string;
|
|
66
|
+
readonly agents: string;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
type MaybeAsync<T> = T | Promise<T>;
|
|
@@ -104,7 +116,7 @@ export function backupCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
|
104
116
|
if (index < KEEP_BACKUPS) continue;
|
|
105
117
|
if (ageMs <= BACKUP_KEEP_MS) continue;
|
|
106
118
|
const target = join(roots.backups, entry.name);
|
|
107
|
-
out.push({ id: `db-backup:${target}`, kind: "db-backup", target, reason:
|
|
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 });
|
|
108
120
|
}
|
|
109
121
|
return out;
|
|
110
122
|
}
|
|
@@ -127,7 +139,7 @@ export async function omoCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCan
|
|
|
127
139
|
if (entry.name === previous) continue;
|
|
128
140
|
const target = join(roots.omo, entry.name);
|
|
129
141
|
if (commands.some((command) => command.includes(`${target}${sep}`))) continue;
|
|
130
|
-
out.push({ id: `omo-version:${target}`, kind: "omo-version", target, reason:
|
|
142
|
+
out.push({ id: `omo-version:${target}`, kind: "omo-version", target, reason: say(REASON_OMO, { current }), ageMs: now - entry.mtimeMs, bytes: null });
|
|
131
143
|
}
|
|
132
144
|
return out;
|
|
133
145
|
}
|
|
@@ -142,7 +154,7 @@ export function updateCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
|
142
154
|
const ageMs = now - entry.mtimeMs;
|
|
143
155
|
if (ageMs <= UPDATE_MIN_AGE_MS) continue;
|
|
144
156
|
const target = join(roots.update, entry.name);
|
|
145
|
-
out.push({ id: `update-file:${target}`, kind: "update-file", target, reason:
|
|
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 });
|
|
146
158
|
}
|
|
147
159
|
return out;
|
|
148
160
|
}
|
|
@@ -160,7 +172,7 @@ export async function tempCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCa
|
|
|
160
172
|
const target = join(roots.tmp, entry.name);
|
|
161
173
|
if (commands.some((command) => command.includes(target))) continue;
|
|
162
174
|
if (await deps.openBy(target) !== false) continue;
|
|
163
|
-
out.push({ id: `temp-dir:${target}`, kind: "temp-dir", target, reason:
|
|
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 });
|
|
164
176
|
}
|
|
165
177
|
return out;
|
|
166
178
|
}
|
|
@@ -170,15 +182,34 @@ export async function daemonCandidates(deps: GcDeps): Promise<GcCandidate[]> {
|
|
|
170
182
|
if (units === null) return [];
|
|
171
183
|
return units.filter((unit) => unit.verdict === "stop" && unit.pids.length > 0).map((unit) => ({
|
|
172
184
|
id: `rpc-daemon:${unit.version}:${unit.pids[0]}`, kind: "rpc-daemon" as const, target: `${unit.version} pid ${unit.pids.join(",")}`,
|
|
173
|
-
reason:
|
|
185
|
+
reason: say(REASON_DAEMON), ageMs: unit.ageMs, bytes: unit.rssKb * 1024, pids: unit.pids,
|
|
174
186
|
}));
|
|
175
187
|
}
|
|
176
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
|
+
|
|
177
207
|
/** Read-only: every candidate, each path kept strictly inside its own root. */
|
|
178
208
|
export async function findCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
|
|
179
209
|
const found: GcCandidate[] = [];
|
|
180
210
|
found.push(...backupCandidates(roots, deps).filter((c) => guarded(roots.backups, c.target)));
|
|
181
211
|
if (roots.kind === "npm") {
|
|
212
|
+
found.push(...agentTrashCandidates(roots, deps).filter((c) => guarded(join(roots.agents, ".trash"), c.target)));
|
|
182
213
|
found.push(...(await omoCandidates(roots, deps)).filter((c) => guarded(roots.omo, c.target)));
|
|
183
214
|
found.push(...updateCandidates(roots, deps).filter((c) => guarded(roots.update, c.target)));
|
|
184
215
|
found.push(...(await tempCandidates(roots, deps)).filter((c) => guarded(roots.tmp, c.target)));
|