zigmini 0.9.0-beta.3 → 1.0.0-beta.1

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.
Files changed (141) hide show
  1. package/README.ko.md +24 -4
  2. package/README.md +24 -4
  3. package/assets/agent/gateway-rules.md +1 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +51 -11
  8. package/docs/manual.md +52 -12
  9. package/host/omo-bridge/agent-control.ts +274 -0
  10. package/host/omo-bridge/bridge.ts +1 -1
  11. package/host/omo-bridge/continuity-control.ts +1 -1
  12. package/host/omo-bridge/task-control.ts +26 -16
  13. package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
  14. package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
  15. package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
  16. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
  17. package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
  18. package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
  19. package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
  20. package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
  21. package/package.json +3 -2
  22. package/src/agent/api.ts +215 -0
  23. package/src/agent/definition.ts +177 -0
  24. package/src/agent/fields.ts +42 -0
  25. package/src/agent/files.ts +192 -0
  26. package/src/agent/legacy-files.ts +46 -0
  27. package/src/agent/list.ts +21 -0
  28. package/src/agent-rules.ts +14 -5
  29. package/src/approvals.ts +6 -1
  30. package/src/cli/main.ts +33 -0
  31. package/src/cli/messages.ts +10 -2
  32. package/src/cli/rotate-main.ts +2 -1
  33. package/src/cli/scan.ts +414 -0
  34. package/src/cli/security-import.ts +155 -0
  35. package/src/compact.ts +22 -0
  36. package/src/decisions.ts +6 -4
  37. package/src/engine/compat.json +3 -3
  38. package/src/engine/index.ts +1 -0
  39. package/src/engine/schema-guard.ts +12 -4
  40. package/src/engine/sdk.ts +8 -0
  41. package/src/formal-verification.ts +366 -0
  42. package/src/gc.ts +37 -6
  43. package/src/hub.ts +339 -79
  44. package/src/main.ts +95 -9
  45. package/src/maintenance.ts +1 -1
  46. package/src/manual.ts +9 -2
  47. package/src/profile-prompt.ts +73 -0
  48. package/src/profile.ts +33 -10
  49. package/src/protocol.ts +11 -2
  50. package/src/question-notifications.ts +4 -1
  51. package/src/reply-notifications.ts +7 -1
  52. package/src/repo-security-setting.ts +33 -0
  53. package/src/runtime/paths.ts +16 -0
  54. package/src/security/baseline.ts +170 -0
  55. package/src/security/dex-strings.ts +242 -0
  56. package/src/security/repo-scan.ts +369 -0
  57. package/src/security/revision-probe.ts +144 -0
  58. package/src/security/terms.ts +97 -0
  59. package/src/security-allow.ts +1 -0
  60. package/src/security-baseline.ts +113 -0
  61. package/src/security-check.ts +91 -16
  62. package/src/server-text.ts +38 -0
  63. package/src/store.ts +142 -16
  64. package/src/transcript.ts +7 -1
  65. package/src/ui-language.ts +33 -0
  66. package/src/update/check.ts +7 -3
  67. package/src/update/handoff-system.ts +24 -16
  68. package/src/update/handoff.ts +23 -12
  69. package/src/update/helper.ts +72 -36
  70. package/src/update/request.ts +46 -30
  71. package/src/update/state.ts +21 -11
  72. package/src/update/strategy.ts +12 -5
  73. package/src/update/target.ts +32 -15
  74. package/src/usage.ts +43 -20
  75. package/src/version.ts +39 -16
  76. package/src/voice/agents.ts +8 -3
  77. package/src/voice/install.ts +29 -18
  78. package/src/work-watch.ts +40 -18
  79. package/web/agent-icon.js +15 -9
  80. package/web/agents.d.ts +201 -0
  81. package/web/agents.js +813 -0
  82. package/web/app-download.js +32 -19
  83. package/web/app.js +1407 -278
  84. package/web/autocopy.js +6 -1
  85. package/web/boot.js +27 -8
  86. package/web/commands.js +20 -11
  87. package/web/decisions.js +70 -49
  88. package/web/devlog.js +25 -19
  89. package/web/diagram.js +8 -3
  90. package/web/enroll.js +43 -22
  91. package/web/gc.js +32 -19
  92. package/web/i18n/agent-icon.js +24 -0
  93. package/web/i18n/agents.js +48 -0
  94. package/web/i18n/app-download.js +26 -0
  95. package/web/i18n/autocopy.js +5 -0
  96. package/web/i18n/boot.js +24 -0
  97. package/web/i18n/commands.js +34 -0
  98. package/web/i18n/decisions.js +24 -0
  99. package/web/i18n/devlog.js +60 -0
  100. package/web/i18n/diagram.js +12 -0
  101. package/web/i18n/enroll.js +22 -0
  102. package/web/i18n/gc.js +38 -0
  103. package/web/i18n/lastsent.js +10 -0
  104. package/web/i18n/main.d.ts +4 -0
  105. package/web/i18n/main.js +527 -0
  106. package/web/i18n/manual.d.ts +4 -0
  107. package/web/i18n/manual.js +14 -0
  108. package/web/i18n/memo.js +30 -0
  109. package/web/i18n/notifications.js +16 -0
  110. package/web/i18n/profile.js +13 -0
  111. package/web/i18n/reference.js +26 -0
  112. package/web/i18n/security-inbox.js +40 -0
  113. package/web/i18n/slash.js +38 -0
  114. package/web/i18n/turn-stats.js +5 -0
  115. package/web/i18n/update.js +42 -0
  116. package/web/i18n/voice.js +28 -0
  117. package/web/i18n/wake.js +16 -0
  118. package/web/i18n/workid.js +10 -0
  119. package/web/i18n/workstatus.js +20 -0
  120. package/web/i18n.d.ts +20 -0
  121. package/web/i18n.js +63 -0
  122. package/web/index.html +156 -96
  123. package/web/lastsent.js +9 -4
  124. package/web/manual.d.ts +1 -1
  125. package/web/manual.js +11 -5
  126. package/web/memo.js +18 -13
  127. package/web/notifications.js +12 -7
  128. package/web/profile.js +42 -17
  129. package/web/reference.js +20 -15
  130. package/web/security-inbox.d.ts +18 -1
  131. package/web/security-inbox.js +173 -4
  132. package/web/slash.js +23 -13
  133. package/web/style.css +132 -3
  134. package/web/tooltip.js +5 -2
  135. package/web/turn-stats.js +6 -2
  136. package/web/update.js +31 -23
  137. package/web/voice.js +18 -8
  138. package/web/wake.d.ts +4 -0
  139. package/web/wake.js +7 -3
  140. package/web/workid.js +12 -2
  141. package/web/workstatus.js +36 -32
@@ -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 `공유 폴더 스키마가 ${schema}이라 이 게이트웨이 엔진이 읽지 못합니다(지금 설치된 전역 OMO: ${globalVersion ?? "버전 알 수 없음"}, 스키마를 올린 주체는 확인하지 않음). ` +
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: `최근 ${KEEP_BACKUPS}개 밖이고 ${BACKUP_KEEP_MS / DAY_MS}일 지남`, ageMs, bytes: null });
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: `현재(${current})·직전 버전이 아니고 실행 중인 프로세스 없음`, ageMs: now - entry.mtimeMs, bytes: null });
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: `${UPDATE_MIN_AGE_MS / DAY_MS}일 지난 업데이트 로그·요청 파일`, ageMs, bytes: null });
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: `${TEMP_MIN_AGE_MS / DAY_MS}일 지났고 쓰는 프로세스 없음`, ageMs: now - entry.mtimeMs, bytes: null });
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: "이전 버전 RPC 호스트 데몬, 부모 없음, 연결된 클라이언트 없음", ageMs: unit.ageMs, bytes: unit.rssKb * 1024, pids: unit.pids,
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)));