@co0ontty/wand 4.93.0 → 4.94.0
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.md +25 -30
- package/dist/ai-text-runner.d.ts +2 -10
- package/dist/ai-text-runner.js +13 -74
- package/dist/build-info.json +3 -3
- package/dist/cli.js +47 -9
- package/dist/config.d.ts +2 -3
- package/dist/config.js +26 -5
- package/dist/core-status-cli.d.ts +1 -1
- package/dist/core-status-cli.js +3 -1
- package/dist/daemon-connection.d.ts +10 -0
- package/dist/daemon-connection.js +28 -0
- package/dist/diagnostic-logs.d.ts +10 -0
- package/dist/diagnostic-logs.js +121 -0
- package/dist/employee-text.js +1 -3
- package/dist/git-quick-commit.d.ts +1 -1
- package/dist/git-quick-commit.js +2 -9
- package/dist/layout-tree.d.ts +3 -1
- package/dist/layout-tree.js +8 -0
- package/dist/local-model-process.js +20 -0
- package/dist/local-model-setup.js +5 -3
- package/dist/managed-temp.d.ts +32 -0
- package/dist/managed-temp.js +176 -0
- package/dist/middleware/api-scope.js +1 -0
- package/dist/models/shared.d.ts +0 -6
- package/dist/models/shared.js +0 -12
- package/dist/models/thinking.d.ts +0 -2
- package/dist/models/thinking.js +0 -2
- package/dist/models.d.ts +0 -1
- package/dist/models.js +3 -11
- package/dist/native-session-title.d.ts +1 -1
- package/dist/native-session-title.js +1 -1
- package/dist/pi-model-discovery.js +5 -17
- package/dist/pi-resource-recommendation.d.ts +1 -1
- package/dist/pi-resource-recommendation.js +129 -42
- package/dist/process-manager-native-history.js +1 -6
- package/dist/process-manager.d.ts +0 -1
- package/dist/process-manager.js +5 -18
- package/dist/provider-catalog.d.ts +1 -2
- package/dist/provider-catalog.js +1 -6
- package/dist/provider-cli-updater.js +1 -9
- package/dist/provider-history-scanner.d.ts +0 -3
- package/dist/provider-history-scanner.js +0 -26
- package/dist/render-daemon-client.js +6 -29
- package/dist/render-structured-client.js +2 -7
- package/dist/retention.js +1 -6
- package/dist/run-command.d.ts +5 -0
- package/dist/run-command.js +14 -1
- package/dist/server-session-lifecycle-routes.js +1 -1
- package/dist/server-storage-cleanup-routes.d.ts +3 -0
- package/dist/server-storage-cleanup-routes.js +19 -0
- package/dist/server-workspace-routes.d.ts +4 -1
- package/dist/server-workspace-routes.js +75 -9
- package/dist/server.js +21 -16
- package/dist/service-command-privilege.d.ts +17 -0
- package/dist/service-command-privilege.js +55 -0
- package/dist/service-manager/index.d.ts +3 -0
- package/dist/service-manager/index.js +3 -0
- package/dist/service-manager/launchd.d.ts +1 -0
- package/dist/service-manager/launchd.js +15 -0
- package/dist/service-manager/systemd.d.ts +1 -0
- package/dist/service-manager/systemd.js +11 -0
- package/dist/session-ai-context.js +17 -6
- package/dist/session-completion-state.d.ts +2 -0
- package/dist/session-completion-state.js +6 -2
- package/dist/session-logger.js +7 -1
- package/dist/session-model.d.ts +4 -0
- package/dist/session-model.js +13 -4
- package/dist/session-registry.d.ts +1 -1
- package/dist/session-registry.js +0 -3
- package/dist/silicon-employee-draft.js +0 -1
- package/dist/speech-service.js +18 -8
- package/dist/storage/mappers.js +6 -10
- package/dist/storage/migrations.js +14 -0
- package/dist/storage/uploads-repository.d.ts +36 -0
- package/dist/storage/uploads-repository.js +98 -0
- package/dist/storage-cleanup.d.ts +50 -0
- package/dist/storage-cleanup.js +344 -0
- package/dist/storage.d.ts +3 -1
- package/dist/storage.js +4 -0
- package/dist/structured-failure.d.ts +2 -0
- package/dist/structured-failure.js +9 -0
- package/dist/structured-grok-adapter.js +4 -4
- package/dist/structured-opencode-adapter.js +4 -9
- package/dist/structured-provider-common.d.ts +1 -2
- package/dist/structured-provider-common.js +3 -6
- package/dist/structured-session-manager-replay.js +2 -6
- package/dist/structured-session-manager.d.ts +1 -2
- package/dist/structured-session-manager.js +32 -25
- package/dist/task-organize-contract.d.ts +41 -0
- package/dist/task-organize-contract.js +4 -0
- package/dist/task-title-organize-service.d.ts +21 -0
- package/dist/task-title-organize-service.js +168 -0
- package/dist/task-title-organize.d.ts +77 -0
- package/dist/task-title-organize.js +346 -0
- package/dist/terminal-daemon-client.js +10 -48
- package/dist/time-utils.d.ts +2 -0
- package/dist/time-utils.js +7 -0
- package/dist/tui/attach.js +6 -5
- package/dist/tui/commands.d.ts +4 -1
- package/dist/tui/commands.js +36 -25
- package/dist/tui/index.js +2 -2
- package/dist/tui/service-actions.js +5 -4
- package/dist/tui/service-command.d.ts +6 -0
- package/dist/tui/service-command.js +40 -0
- package/dist/tui/service-panel.js +9 -8
- package/dist/types.d.ts +4 -4
- package/dist/upload-reference-guard.d.ts +4 -0
- package/dist/upload-reference-guard.js +29 -0
- package/dist/upload-routes.d.ts +4 -0
- package/dist/upload-routes.js +121 -21
- package/dist/user-profile.d.ts +1 -1
- package/dist/user-profile.js +8 -3
- package/dist/web-ui/content/ai-teams.js +9 -9
- package/dist/web-ui/content/module-manifest.json +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chat-render-EODYMQF6.js → 0051bd3d55f10ab7/chat-render-RY2I2CF3.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-ZTEQSXPA.js → 0051bd3d55f10ab7/chunk-2F6J2MKX.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-A33YPCY6.js → 0051bd3d55f10ab7/chunk-7AFM245W.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-QNUWUMKW.js → 0051bd3d55f10ab7/chunk-MHX2QXIS.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-WGADUEIT.js → 0051bd3d55f10ab7/chunk-RAAPETNH.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-I3UYDQLS.js → 0051bd3d55f10ab7/chunk-RXKBHYUR.js} +2 -2
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-3TCUG766.js → 0051bd3d55f10ab7/chunk-U4EDP4TI.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-HK7RBU3M.js → 0051bd3d55f10ab7/chunk-WHXQR7JT.js} +1 -1
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/employee-card-NV34TZIH.js +1 -0
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-2PU6TV2I.js → 0051bd3d55f10ab7/host-2IVIZ2YC.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-BPYNMKFC.js → 0051bd3d55f10ab7/host-AZXNFCRW.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-HZIKPWYY.js → 0051bd3d55f10ab7/host-EPEQX22R.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-YELXZID4.js → 0051bd3d55f10ab7/host-FZYIMOLC.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-OGD6AWHL.js → 0051bd3d55f10ab7/host-G6GL4SNL.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-E3SCPOIU.js → 0051bd3d55f10ab7/host-PEILC5NU.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-OWD32FZ3.js → 0051bd3d55f10ab7/host-PHHNGVNL.js} +2 -2
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-CXDAFMXX.js → 0051bd3d55f10ab7/host-Q524TMSR.js} +1 -1
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/host-RO5MB6RB.js +69 -0
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-J5XRAQFP.js → 0051bd3d55f10ab7/host-WOQRK6NG.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-HKXE2DOT.js → 0051bd3d55f10ab7/host-XNIR3NTJ.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/host-4E4H4JZJ.js → 0051bd3d55f10ab7/host-XRVOIEP6.js} +1 -1
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/host-ZDKOQEMS.js +1 -0
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/host-bridge-PMQZRG5G.js +1 -0
- package/dist/web-ui/content/modules/{2548dbe0d0783577/library-bridge-Y3Y24CPQ.js → 0051bd3d55f10ab7/library-bridge-DTMQIHVJ.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/page-GQBUU7T7.js → 0051bd3d55f10ab7/page-PY4JUL4W.js} +1 -1
- package/dist/web-ui/content/modules/{2548dbe0d0783577/panel-PG662OC5.js → 0051bd3d55f10ab7/panel-BBQFC55Q.js} +1 -1
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/runtime.js +974 -0
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/task-board-host-PXZSWBBG.js +2 -0
- package/dist/web-ui/content/modules/0051bd3d55f10ab7/workspace-agent-picker-DJSECSY6.js +1 -0
- package/dist/web-ui/content/modules/{2548dbe0d0783577/workspace-window-HV4T3WKR.js → 0051bd3d55f10ab7/workspace-window-MNMKSOBG.js} +1 -1
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +27 -27
- package/dist/web-ui/embedded-assets.js +4 -4
- package/dist/web-ui/provider-identity.d.ts +4 -5
- package/dist/web-ui/provider-identity.js +4 -10
- package/dist/web-ui/react/workspaces/sidebar-manage.d.ts +39 -0
- package/dist/web-ui/react/workspaces/sidebar-manage.js +149 -0
- package/dist/web-ui/react/workspaces/types.d.ts +408 -0
- package/dist/web-ui/react/workspaces/types.js +16 -0
- package/dist/web-ui/session-activity.js +1 -1
- package/package.json +2 -2
- package/dist/structured-gemini-adapter.d.ts +0 -40
- package/dist/structured-gemini-adapter.js +0 -208
- package/dist/web-ui/content/modules/2548dbe0d0783577/chunk-XGVT4ZWT.js +0 -1
- package/dist/web-ui/content/modules/2548dbe0d0783577/employee-card-LJ3TX72B.js +0 -1
- package/dist/web-ui/content/modules/2548dbe0d0783577/host-ITV2QZ4A.js +0 -1
- package/dist/web-ui/content/modules/2548dbe0d0783577/host-XNWC7KPR.js +0 -69
- package/dist/web-ui/content/modules/2548dbe0d0783577/host-bridge-G5HEVADT.js +0 -1
- package/dist/web-ui/content/modules/2548dbe0d0783577/runtime.js +0 -946
- package/dist/web-ui/content/modules/2548dbe0d0783577/task-board-host-RAN2Z2KR.js +0 -2
- package/dist/web-ui/content/modules/2548dbe0d0783577/workspace-agent-picker-FXLH6N5K.js +0 -1
- /package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-YX475LXO.js → 0051bd3d55f10ab7/chunk-6C6A2RHG.js} +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-NP35GDQ3.js → 0051bd3d55f10ab7/chunk-ECWYA3U5.js} +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-47EVOSJS.js → 0051bd3d55f10ab7/chunk-T35D57FW.js} +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577 → 0051bd3d55f10ab7}/chunk-YOXXNXOX.js +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577/chunk-C3K34WJD.js → 0051bd3d55f10ab7/chunk-YQJ6XYV7.js} +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577/host-J6HBDK4A.js → 0051bd3d55f10ab7/host-2AQAIUNO.js} +0 -0
- /package/dist/web-ui/content/modules/{2548dbe0d0783577 → 0051bd3d55f10ab7}/sha256-ANXV72UC.js +0 -0
package/dist/speech-service.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { constants, createReadStream } from "node:fs";
|
|
4
|
-
import { access, mkdir,
|
|
4
|
+
import { access, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { buildChildEnv, systemEnvValue } from "./env-utils.js";
|
|
8
8
|
import { SPEECH_MODELS, speechModel, speechModelUrl } from "./speech-models.js";
|
|
9
9
|
import { SPEECH_MAX_SECONDS, SPEECH_MAX_WAV_BYTES, SPEECH_SAMPLE_RATE } from "./speech-types.js";
|
|
10
|
+
import { configTemporaryRoot, createManagedTemporary, registerTemporaryChild } from "./managed-temp.js";
|
|
10
11
|
export const SPEECH_SETTINGS_KEY = "pref:speechRecognition";
|
|
11
12
|
export class SpeechError extends Error {
|
|
12
13
|
code;
|
|
@@ -111,8 +112,8 @@ export class SpeechService {
|
|
|
111
112
|
if (!runtime.executable || !await this.modelReady(model))
|
|
112
113
|
throw new SpeechError("NOT_READY", "请先下载模型并安装语音运行时。", 503);
|
|
113
114
|
signal.throwIfAborted();
|
|
114
|
-
|
|
115
|
-
const directory =
|
|
115
|
+
const temporary = createManagedTemporary("speech-check", configTemporaryRoot(path.dirname(this.root)));
|
|
116
|
+
const directory = temporary.directory;
|
|
116
117
|
try {
|
|
117
118
|
const wav = Buffer.alloc(8044);
|
|
118
119
|
wav.write("RIFF");
|
|
@@ -145,7 +146,7 @@ export class SpeechService {
|
|
|
145
146
|
this.initialized.add(id);
|
|
146
147
|
}
|
|
147
148
|
finally {
|
|
148
|
-
await
|
|
149
|
+
await temporary.closeAsync();
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
modelPath(model) { return path.join(this.root, "models", `ggml-${model.id}.bin`); }
|
|
@@ -305,6 +306,7 @@ export class SpeechService {
|
|
|
305
306
|
const cancel = () => abort.abort();
|
|
306
307
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
307
308
|
let directory = null;
|
|
309
|
+
let temporary = null;
|
|
308
310
|
try {
|
|
309
311
|
const settings = this.settings();
|
|
310
312
|
const model = this.models.find((item) => item.id === settings.model);
|
|
@@ -319,8 +321,8 @@ export class SpeechService {
|
|
|
319
321
|
let backend = settings.acceleration === "cpu" ? "cpu" : runtime.backend;
|
|
320
322
|
if (silent)
|
|
321
323
|
return { text: "", model: model.id, backend };
|
|
322
|
-
|
|
323
|
-
directory =
|
|
324
|
+
temporary = createManagedTemporary("speech-voice", configTemporaryRoot(path.dirname(this.root)));
|
|
325
|
+
directory = temporary.directory;
|
|
324
326
|
const wav = path.join(directory, "audio.wav");
|
|
325
327
|
await writeFile(wav, audio, { mode: 0o600 });
|
|
326
328
|
const output = path.join(directory, "transcript");
|
|
@@ -362,8 +364,7 @@ export class SpeechService {
|
|
|
362
364
|
throw new SpeechError("ENGINE_FAILED", "服务端语音识别失败,请检查运行时、模型与可用磁盘。", 503);
|
|
363
365
|
}
|
|
364
366
|
finally {
|
|
365
|
-
|
|
366
|
-
await rm(directory, { recursive: true, force: true }).catch(() => { });
|
|
367
|
+
await temporary?.closeAsync();
|
|
367
368
|
signal?.removeEventListener("abort", cancel);
|
|
368
369
|
if (this.active === abort)
|
|
369
370
|
this.active = null;
|
|
@@ -379,6 +380,15 @@ export class SpeechService {
|
|
|
379
380
|
LD_LIBRARY_PATH: systemEnvValue("LD_LIBRARY_PATH"), DYLD_LIBRARY_PATH: systemEnvValue("DYLD_LIBRARY_PATH"),
|
|
380
381
|
CUDA_VISIBLE_DEVICES: systemEnvValue("CUDA_VISIBLE_DEVICES"),
|
|
381
382
|
}), stdio: "ignore", windowsHide: true });
|
|
383
|
+
try {
|
|
384
|
+
registerTemporaryChild(cwd, child.pid);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
child.on("error", () => { });
|
|
388
|
+
child.once("close", () => { reject(new SpeechError("ENGINE_FAILED", "临时文件保护失败。", 503)); });
|
|
389
|
+
child.kill("SIGKILL");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
382
392
|
let failure = null;
|
|
383
393
|
let killTimer;
|
|
384
394
|
const stop = (error) => {
|
package/dist/storage/mappers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { legacyPtyRoleIdentity } from "../default-employee.js";
|
|
2
2
|
import { isRecord } from "../object-utils.js";
|
|
3
3
|
import { parseHarnessExtensionState } from "../core-extension-host.js";
|
|
4
|
-
import {
|
|
4
|
+
import { wrapLegacyTaskLayout } from "../layout-tree.js";
|
|
5
5
|
import { defaultPiSessionSettings, patchPiSessionSettings } from "../pi-session-settings.js";
|
|
6
6
|
import { inferProviderFromCommand, inferProviderFromRunner, isSessionProvider } from "../session-provider.js";
|
|
7
7
|
import { isThinkingEffort } from "../structured-provider-common.js";
|
|
@@ -365,12 +365,7 @@ export function mapWorkspaceTaskLayout(raw) {
|
|
|
365
365
|
return parsed;
|
|
366
366
|
}
|
|
367
367
|
if (record.type === "pane" || record.type === "split") {
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
type: "windows",
|
|
371
|
-
windows: [{ id: "window-legacy", layout: legacy, activeTabId: firstLayoutTabId(legacy) }],
|
|
372
|
-
activeWindowId: "window-legacy",
|
|
373
|
-
};
|
|
368
|
+
return wrapLegacyTaskLayout(parsed);
|
|
374
369
|
}
|
|
375
370
|
return null;
|
|
376
371
|
}
|
|
@@ -509,9 +504,10 @@ export function sessionRuntimeMetadataValues(snapshot) {
|
|
|
509
504
|
];
|
|
510
505
|
}
|
|
511
506
|
export function mapSessionCore(row) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
507
|
+
// Durable identity is historical data, not a new executable selection.
|
|
508
|
+
// Preserve unknown/removed values so execution owners can reject them without
|
|
509
|
+
// silently inferring a different provider or rewriting the original row.
|
|
510
|
+
const provider = row.provider ?? inferProviderFromRunner(row.runner) ?? inferProviderFromCommand(row.command);
|
|
515
511
|
const sessionOptions = parseSessionOptions(row.session_options);
|
|
516
512
|
const queuedMessages = parseQueuedMessages(row.queued_messages);
|
|
517
513
|
return {
|
|
@@ -211,4 +211,18 @@ export const STORAGE_MIGRATIONS = [
|
|
|
211
211
|
{ version: 2, name: "task-records-backfill", up: migrateTaskRecordsV2 },
|
|
212
212
|
{ version: 3, name: "auth-token-digests", up: migrateAuthTokensV3 },
|
|
213
213
|
{ version: 4, name: "unique-card-per-container", up: uniqueCardPerContainerV4 },
|
|
214
|
+
{ version: 5, name: "managed-upload-provenance", up: db => db.exec(`
|
|
215
|
+
CREATE TABLE IF NOT EXISTS wand_uploads (
|
|
216
|
+
file_path TEXT PRIMARY KEY, canonical_path TEXT NOT NULL UNIQUE,
|
|
217
|
+
cwd TEXT NOT NULL, canonical_cwd TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
218
|
+
device INTEGER NOT NULL, inode INTEGER NOT NULL, size INTEGER NOT NULL,
|
|
219
|
+
mtime_ms REAL NOT NULL, ctime_ms REAL NOT NULL, created_at INTEGER NOT NULL,
|
|
220
|
+
orphaned_at INTEGER, checked_at INTEGER NOT NULL DEFAULT 0
|
|
221
|
+
);
|
|
222
|
+
CREATE INDEX IF NOT EXISTS idx_wand_uploads_checked ON wand_uploads(checked_at, created_at);
|
|
223
|
+
CREATE TABLE IF NOT EXISTS wand_upload_references (
|
|
224
|
+
file_path TEXT NOT NULL, session_id TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
225
|
+
PRIMARY KEY(file_path, session_id)
|
|
226
|
+
);
|
|
227
|
+
`) },
|
|
214
228
|
];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import type { WandStorage } from "../storage.js";
|
|
3
|
+
export interface ManagedUpload {
|
|
4
|
+
filePath: string;
|
|
5
|
+
canonicalPath: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
canonicalCwd: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
device: number;
|
|
10
|
+
inode: number;
|
|
11
|
+
size: number;
|
|
12
|
+
mtimeMs: number;
|
|
13
|
+
ctimeMs: number;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
orphanedAt: number | null;
|
|
16
|
+
}
|
|
17
|
+
/** Upload receipts are provenance, not a filename/prefix guess or a conversation DTO. */
|
|
18
|
+
export declare class UploadRepository {
|
|
19
|
+
private readonly db;
|
|
20
|
+
private readonly owner?;
|
|
21
|
+
constructor(db: DatabaseSync, owner?: Pick<WandStorage, "transaction"> | undefined);
|
|
22
|
+
recordManagedUpload(upload: Omit<ManagedUpload, "orphanedAt">): void;
|
|
23
|
+
recordManagedUploads(uploads: Omit<ManagedUpload, "orphanedAt">[]): void;
|
|
24
|
+
listManagedUploads(limit?: number): ManagedUpload[];
|
|
25
|
+
/** Keep original owners, shared workspaces, queues and exact historical file references. */
|
|
26
|
+
managedUploadReferenced(upload: ManagedUpload): boolean;
|
|
27
|
+
/** Pin an orphan before PTY/structured dispatch, including unknown delivery; no prompt content is persisted. */
|
|
28
|
+
retainManagedUploadReferences(sessionId: string, requestJson: string, now?: number): void;
|
|
29
|
+
observeManagedUpload(upload: ManagedUpload, referenced: boolean, now: number): void;
|
|
30
|
+
forgetManagedUpload(upload: ManagedUpload): void;
|
|
31
|
+
}
|
|
32
|
+
/** Existing npm installations: no migrations, vault access or new database on CLI preview. */
|
|
33
|
+
export declare function openUploadCleanupStore(dbPath: string, write?: boolean): {
|
|
34
|
+
uploads: UploadRepository;
|
|
35
|
+
close(): void;
|
|
36
|
+
} | null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
/** Upload receipts are provenance, not a filename/prefix guess or a conversation DTO. */
|
|
4
|
+
export class UploadRepository {
|
|
5
|
+
db;
|
|
6
|
+
owner;
|
|
7
|
+
constructor(db, owner) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.owner = owner;
|
|
10
|
+
}
|
|
11
|
+
recordManagedUpload(upload) {
|
|
12
|
+
this.db.prepare(`INSERT INTO wand_uploads(file_path, canonical_path, cwd, canonical_cwd, session_id,
|
|
13
|
+
device, inode, size, mtime_ms, ctime_ms, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(upload.filePath, upload.canonicalPath, upload.cwd, upload.canonicalCwd, upload.sessionId, upload.device, upload.inode, upload.size, upload.mtimeMs, upload.ctimeMs, upload.createdAt);
|
|
14
|
+
}
|
|
15
|
+
recordManagedUploads(uploads) {
|
|
16
|
+
const save = () => { for (const upload of uploads)
|
|
17
|
+
this.recordManagedUpload(upload); };
|
|
18
|
+
if (this.owner)
|
|
19
|
+
this.owner.transaction(save);
|
|
20
|
+
else
|
|
21
|
+
save();
|
|
22
|
+
}
|
|
23
|
+
listManagedUploads(limit = 100) {
|
|
24
|
+
return this.db.prepare(`SELECT file_path AS filePath, canonical_path AS canonicalPath, cwd,
|
|
25
|
+
canonical_cwd AS canonicalCwd, session_id AS sessionId, device, inode, size,
|
|
26
|
+
mtime_ms AS mtimeMs, ctime_ms AS ctimeMs, created_at AS createdAt,
|
|
27
|
+
CASE WHEN orphaned_at IS NULL THEN NULL ELSE MAX(orphaned_at, COALESCE((SELECT MAX(created_at)
|
|
28
|
+
FROM wand_upload_references r WHERE r.file_path=wand_uploads.file_path),0)) END AS orphanedAt
|
|
29
|
+
FROM wand_uploads ORDER BY checked_at, created_at LIMIT ?`).all(Math.min(100, Math.max(1, limit)));
|
|
30
|
+
}
|
|
31
|
+
/** Keep original owners, shared workspaces, queues and exact historical file references. */
|
|
32
|
+
managedUploadReferenced(upload) {
|
|
33
|
+
const escaped = JSON.stringify(upload.filePath).slice(1, -1);
|
|
34
|
+
const canonical = JSON.stringify(upload.canonicalPath).slice(1, -1);
|
|
35
|
+
if (this.db.prepare(`SELECT 1 FROM wand_upload_references r JOIN command_sessions s ON s.id=r.session_id
|
|
36
|
+
WHERE r.file_path=? LIMIT 1`).get(upload.filePath))
|
|
37
|
+
return true;
|
|
38
|
+
if (this.db.prepare(`SELECT 1 FROM command_sessions WHERE id=? OR cwd IN (?,?) OR (status='running' AND session_kind='structured')
|
|
39
|
+
OR instr(output,?)>0 OR instr(output,?)>0 OR instr(COALESCE(messages,''),?)>0
|
|
40
|
+
OR instr(COALESCE(messages,''),?)>0 OR instr(COALESCE(queued_messages,''),?)>0
|
|
41
|
+
OR instr(COALESCE(queued_messages,''),?)>0 LIMIT 1`).get(upload.sessionId, upload.cwd, upload.canonicalCwd, upload.filePath, upload.canonicalPath, escaped, canonical, escaped, canonical))
|
|
42
|
+
return true;
|
|
43
|
+
if (this.db.prepare("SELECT 1 FROM workspace_tasks WHERE cwd IN (?,?) LIMIT 1").get(upload.cwd, upload.canonicalCwd))
|
|
44
|
+
return true;
|
|
45
|
+
if (this.db.prepare("SELECT 1 FROM conversation_messages WHERE instr(turn_json,?)>0 OR instr(turn_json,?)>0 LIMIT 1").get(escaped, canonical))
|
|
46
|
+
return true;
|
|
47
|
+
// The team run stores delivered reports/attachment references even after its relay session is gone.
|
|
48
|
+
if (this.db.prepare("SELECT 1 FROM ai_team_runs WHERE cwd IN (?,?) OR instr(objective,?)>0 OR instr(objective,?)>0 LIMIT 1")
|
|
49
|
+
.get(upload.cwd, upload.canonicalCwd, upload.filePath, upload.canonicalPath))
|
|
50
|
+
return true;
|
|
51
|
+
if (this.db.prepare(`SELECT 1 FROM ai_team_steps WHERE instr(report,?)>0 OR instr(report,?)>0
|
|
52
|
+
OR instr(instructions,?)>0 OR instr(instructions,?)>0 OR report_path IN (?,?)
|
|
53
|
+
OR instr(COALESCE(dispatch_info_json,''),?)>0 OR instr(COALESCE(dispatch_info_json,''),?)>0 LIMIT 1`)
|
|
54
|
+
.get(upload.filePath, upload.canonicalPath, upload.filePath, upload.canonicalPath, upload.filePath, upload.canonicalPath, escaped, canonical))
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/** Pin an orphan before PTY/structured dispatch, including unknown delivery; no prompt content is persisted. */
|
|
59
|
+
retainManagedUploadReferences(sessionId, requestJson, now = Date.now()) {
|
|
60
|
+
this.db.prepare(`INSERT INTO wand_upload_references(file_path,session_id,created_at)
|
|
61
|
+
SELECT file_path,?,? FROM wand_uploads WHERE orphaned_at IS NOT NULL AND
|
|
62
|
+
(instr(?,substr(json_quote(file_path),2,length(json_quote(file_path))-2))>0
|
|
63
|
+
OR instr(?,substr(json_quote(canonical_path),2,length(json_quote(canonical_path))-2))>0)
|
|
64
|
+
ON CONFLICT(file_path,session_id) DO UPDATE SET created_at=excluded.created_at`).run(sessionId, now, requestJson, requestJson);
|
|
65
|
+
}
|
|
66
|
+
observeManagedUpload(upload, referenced, now) {
|
|
67
|
+
this.db.prepare(`UPDATE wand_uploads SET checked_at=?, orphaned_at=CASE WHEN ? THEN NULL
|
|
68
|
+
ELSE COALESCE(orphaned_at,?) END WHERE file_path=? AND inode=? AND device=?`).run(now, Number(referenced), now, upload.filePath, upload.inode, upload.device);
|
|
69
|
+
}
|
|
70
|
+
forgetManagedUpload(upload) {
|
|
71
|
+
const remove = () => {
|
|
72
|
+
this.db.prepare("DELETE FROM wand_uploads WHERE file_path=? AND inode=? AND device=?").run(upload.filePath, upload.inode, upload.device);
|
|
73
|
+
this.db.prepare("DELETE FROM wand_upload_references WHERE file_path=?").run(upload.filePath);
|
|
74
|
+
};
|
|
75
|
+
if (this.owner)
|
|
76
|
+
this.owner.transaction(remove);
|
|
77
|
+
else
|
|
78
|
+
remove();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Existing npm installations: no migrations, vault access or new database on CLI preview. */
|
|
82
|
+
export function openUploadCleanupStore(dbPath, write = false) {
|
|
83
|
+
if (!existsSync(dbPath))
|
|
84
|
+
return null;
|
|
85
|
+
const db = new DatabaseSync(dbPath, { readOnly: !write });
|
|
86
|
+
try {
|
|
87
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
88
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='wand_uploads'").get()) {
|
|
89
|
+
db.close();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return { uploads: new UploadRepository(db), close: () => db.close() };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
db.close();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type TempKind } from "./managed-temp.js";
|
|
2
|
+
import type { UploadRepository } from "./storage/uploads-repository.js";
|
|
3
|
+
export declare const STORAGE_CLEANUP_RETENTION_DAYS = 7;
|
|
4
|
+
export interface CleanupReport {
|
|
5
|
+
applied: boolean;
|
|
6
|
+
checkedAt: string;
|
|
7
|
+
retentionDays: number;
|
|
8
|
+
candidates: number;
|
|
9
|
+
removed: number;
|
|
10
|
+
logicalBytes: number;
|
|
11
|
+
allocatedBytes: number;
|
|
12
|
+
skipped: number;
|
|
13
|
+
issues: string[];
|
|
14
|
+
byKind: Partial<Record<TempKind | "upload" | "diagnostic-log", number>>;
|
|
15
|
+
}
|
|
16
|
+
interface CleanupOptions {
|
|
17
|
+
roots: string[];
|
|
18
|
+
now?: () => number;
|
|
19
|
+
isAlive?: (pid: number, group?: boolean) => boolean;
|
|
20
|
+
inspectOpenPaths?: () => Promise<string[]>;
|
|
21
|
+
maxEntries?: number;
|
|
22
|
+
configDirectory?: string;
|
|
23
|
+
uploads?: Pick<UploadRepository, "listManagedUploads" | "managedUploadReferenced" | "observeManagedUpload" | "forgetManagedUpload">;
|
|
24
|
+
uploadsActive?: () => boolean;
|
|
25
|
+
onUploadRemoved?: (cwd: string) => void;
|
|
26
|
+
}
|
|
27
|
+
/** Runtime-only, explicit ownership. User history, downloads and rollback inputs are never candidates. */
|
|
28
|
+
export declare class StorageCleanup {
|
|
29
|
+
private readonly options;
|
|
30
|
+
private busy;
|
|
31
|
+
private stopped;
|
|
32
|
+
private timer;
|
|
33
|
+
private startup;
|
|
34
|
+
private active;
|
|
35
|
+
private last;
|
|
36
|
+
constructor(options: CleanupOptions);
|
|
37
|
+
status(): {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
running: boolean;
|
|
40
|
+
retentionDays: number;
|
|
41
|
+
lastRun: CleanupReport | null;
|
|
42
|
+
};
|
|
43
|
+
start(): void;
|
|
44
|
+
stop(): Promise<void>;
|
|
45
|
+
run(apply?: boolean): Promise<CleanupReport>;
|
|
46
|
+
private sweep;
|
|
47
|
+
private sweepTemporary;
|
|
48
|
+
}
|
|
49
|
+
export declare function createStorageCleanup(configDir: string, options?: Pick<CleanupOptions, "uploads" | "uploadsActive" | "onUploadRemoved">): StorageCleanup;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { lstat, readdir, realpath, rm, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { TEMP_ROOT_MARKER, configTemporaryRoot, readTemporaryLease, systemTemporaryRoot, testTemporaryRoot, temporaryPidAlive, validateTemporaryRoot } from "./managed-temp.js";
|
|
6
|
+
import { assertPlainTempPath } from "./managed-temp.js";
|
|
7
|
+
import { DIAGNOSTIC_LOG_RETENTION_DAYS, DIAGNOSTIC_RECEIPT_SUFFIX, diagnosticReceiptMatches, isDiagnosticArchive } from "./diagnostic-logs.js";
|
|
8
|
+
export const STORAGE_CLEANUP_RETENTION_DAYS = 7;
|
|
9
|
+
const DAY = 86_400_000;
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
async function openPaths() {
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
throw new Error("Open-file inspection unavailable.");
|
|
14
|
+
const { stdout, stderr } = await exec("lsof", ["-nP", "-F", "n"], { timeout: 20_000, maxBuffer: 16 * 1024 * 1024 });
|
|
15
|
+
if (stderr.trim())
|
|
16
|
+
throw new Error("Open-file inspection incomplete.");
|
|
17
|
+
return stdout.split("\n").filter(line => line.startsWith("n")).map(line => line.slice(1));
|
|
18
|
+
}
|
|
19
|
+
/** Runtime-only, explicit ownership. User history, downloads and rollback inputs are never candidates. */
|
|
20
|
+
export class StorageCleanup {
|
|
21
|
+
options;
|
|
22
|
+
busy = false;
|
|
23
|
+
stopped = false;
|
|
24
|
+
timer = null;
|
|
25
|
+
startup = null;
|
|
26
|
+
active = null;
|
|
27
|
+
last = null;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
status() { return { enabled: this.timer !== null, running: this.busy, retentionDays: STORAGE_CLEANUP_RETENTION_DAYS, lastRun: this.last }; }
|
|
32
|
+
start() {
|
|
33
|
+
if (this.timer || this.stopped)
|
|
34
|
+
return;
|
|
35
|
+
const sweep = () => { if (!this.busy && !this.stopped)
|
|
36
|
+
void this.run(true).catch(() => { }); };
|
|
37
|
+
this.startup = setTimeout(sweep, 30_000);
|
|
38
|
+
this.startup.unref();
|
|
39
|
+
this.timer = setInterval(sweep, 6 * 60 * 60_000);
|
|
40
|
+
this.timer.unref();
|
|
41
|
+
}
|
|
42
|
+
async stop() {
|
|
43
|
+
this.stopped = true;
|
|
44
|
+
if (this.startup)
|
|
45
|
+
clearTimeout(this.startup);
|
|
46
|
+
if (this.timer)
|
|
47
|
+
clearInterval(this.timer);
|
|
48
|
+
this.startup = this.timer = null;
|
|
49
|
+
await this.active?.catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
async run(apply = false) {
|
|
52
|
+
if (this.busy || this.stopped)
|
|
53
|
+
throw new Error("Storage cleanup is busy or stopping.");
|
|
54
|
+
this.busy = true;
|
|
55
|
+
const task = this.sweep(apply);
|
|
56
|
+
this.active = task;
|
|
57
|
+
try {
|
|
58
|
+
const report = await task;
|
|
59
|
+
if (apply)
|
|
60
|
+
this.last = report;
|
|
61
|
+
return report;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
this.active = null;
|
|
65
|
+
this.busy = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async sweep(apply) {
|
|
69
|
+
const report = await this.sweepTemporary(apply);
|
|
70
|
+
if (this.stopped)
|
|
71
|
+
return report;
|
|
72
|
+
const now = (this.options.now ?? Date.now)();
|
|
73
|
+
const files = [];
|
|
74
|
+
if (this.options.uploads && !this.options.uploadsActive?.()) {
|
|
75
|
+
try {
|
|
76
|
+
for (const upload of this.options.uploads.listManagedUploads()) {
|
|
77
|
+
const referenced = this.options.uploads.managedUploadReferenced(upload);
|
|
78
|
+
if (apply)
|
|
79
|
+
this.options.uploads.observeManagedUpload(upload, referenced, now);
|
|
80
|
+
if (referenced || upload.orphanedAt === null || upload.orphanedAt >= now - STORAGE_CLEANUP_RETENTION_DAYS * DAY) {
|
|
81
|
+
report.skipped++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
assertPlainTempPath(upload.canonicalPath);
|
|
86
|
+
if (path.dirname(upload.canonicalPath) !== path.join(upload.canonicalCwd, ".wand-uploads"))
|
|
87
|
+
throw new Error("invalid upload scope");
|
|
88
|
+
if (await realpath(upload.canonicalCwd) !== upload.canonicalCwd)
|
|
89
|
+
throw new Error("upload cwd changed");
|
|
90
|
+
const stat = await lstat(upload.canonicalPath);
|
|
91
|
+
if (!stat.isFile() || stat.dev !== upload.device || stat.ino !== upload.inode || stat.size !== upload.size
|
|
92
|
+
|| stat.mtimeMs !== upload.mtimeMs || stat.ctimeMs !== upload.ctimeMs) {
|
|
93
|
+
report.skipped++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
files.push({ file: upload.canonicalPath, kind: "upload", upload, size: stat.size, blocks: stat.blocks });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
report.skipped++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
report.issues.push("Upload ownership/reference inspection failed; uploads preserved.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (this.options.configDirectory) {
|
|
108
|
+
const directory = this.options.configDirectory;
|
|
109
|
+
const inspect = async (root) => {
|
|
110
|
+
assertPlainTempPath(root);
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error.code === "ENOENT")
|
|
117
|
+
return;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.isFile() || !isDiagnosticArchive(entry.name))
|
|
122
|
+
continue;
|
|
123
|
+
const file = path.join(root, entry.name);
|
|
124
|
+
try {
|
|
125
|
+
if (!diagnosticReceiptMatches(file)) {
|
|
126
|
+
report.skipped++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const stat = await lstat(file);
|
|
130
|
+
if (Math.max(stat.ctimeMs, stat.mtimeMs) >= now - DIAGNOSTIC_LOG_RETENTION_DAYS * DAY) {
|
|
131
|
+
report.skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
files.push({ file, kind: "diagnostic-log", size: stat.size, blocks: stat.blocks });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
report.skipped++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
await inspect(directory);
|
|
143
|
+
const sessionRoot = path.join(directory, "sessions");
|
|
144
|
+
assertPlainTempPath(sessionRoot);
|
|
145
|
+
const entries = await readdir(sessionRoot, { withFileTypes: true }).catch(error => {
|
|
146
|
+
if (error.code === "ENOENT")
|
|
147
|
+
return [];
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
150
|
+
if (entries.length > 1000)
|
|
151
|
+
report.issues.push("Diagnostic session scan limit reached; session archives preserved.");
|
|
152
|
+
else
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.isDirectory())
|
|
155
|
+
await inspect(path.join(sessionRoot, entry.name));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
report.issues.push("Diagnostic archive inspection failed; uncertain files preserved.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
report.candidates += files.length;
|
|
163
|
+
let opened = [];
|
|
164
|
+
if (apply && files.length) {
|
|
165
|
+
try {
|
|
166
|
+
opened = await (this.options.inspectOpenPaths ?? openPaths)();
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
report.skipped += files.length;
|
|
170
|
+
report.issues.push("Open-file inspection failed; uploaded files and diagnostic archives preserved.");
|
|
171
|
+
return report;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
if (this.stopped)
|
|
176
|
+
break;
|
|
177
|
+
try {
|
|
178
|
+
if (opened.some(entry => entry === file.file || entry === path.dirname(file.file))) {
|
|
179
|
+
report.skipped++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
assertPlainTempPath(file.file);
|
|
183
|
+
const stat = await lstat(file.file);
|
|
184
|
+
if (file.upload) {
|
|
185
|
+
const upload = file.upload;
|
|
186
|
+
if (stat.dev !== upload.device || stat.ino !== upload.inode || stat.size !== upload.size
|
|
187
|
+
|| stat.mtimeMs !== upload.mtimeMs || stat.ctimeMs !== upload.ctimeMs || this.options.uploadsActive?.()
|
|
188
|
+
|| this.options.uploads.managedUploadReferenced(upload)) {
|
|
189
|
+
report.skipped++;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (!diagnosticReceiptMatches(file.file) || Math.max(stat.ctimeMs, stat.mtimeMs) >= now - DIAGNOSTIC_LOG_RETENTION_DAYS * DAY) {
|
|
194
|
+
report.skipped++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (apply) {
|
|
198
|
+
await unlink(file.file);
|
|
199
|
+
report.removed++;
|
|
200
|
+
if (file.upload) {
|
|
201
|
+
this.options.uploads.forgetManagedUpload(file.upload);
|
|
202
|
+
this.options.onUploadRemoved?.(file.upload.cwd);
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
await unlink(file.file + DIAGNOSTIC_RECEIPT_SUFFIX).catch(() => { });
|
|
206
|
+
}
|
|
207
|
+
report.logicalBytes += stat.size;
|
|
208
|
+
report.allocatedBytes += stat.blocks * 512;
|
|
209
|
+
report.byKind[file.kind] = (report.byKind[file.kind] ?? 0) + 1;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
report.skipped++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return report;
|
|
216
|
+
}
|
|
217
|
+
async sweepTemporary(apply) {
|
|
218
|
+
const now = (this.options.now ?? Date.now)();
|
|
219
|
+
const cutoff = now - STORAGE_CLEANUP_RETENTION_DAYS * DAY;
|
|
220
|
+
const report = { applied: apply, checkedAt: new Date(now).toISOString(), retentionDays: STORAGE_CLEANUP_RETENTION_DAYS,
|
|
221
|
+
candidates: 0, removed: 0, logicalBytes: 0, allocatedBytes: 0, skipped: 0, issues: [], byKind: {} };
|
|
222
|
+
const alive = this.options.isAlive ?? temporaryPidAlive;
|
|
223
|
+
const active = (lease) => alive(lease.ownerPid) || lease.children.some(child => alive(child.pid, child.group));
|
|
224
|
+
let visited = 0;
|
|
225
|
+
const budget = this.options.maxEntries ?? 20_000;
|
|
226
|
+
const count = () => { if (++visited > budget)
|
|
227
|
+
throw new Error("scan-limit"); };
|
|
228
|
+
const stats = async (directory, device, disposableCheckout, depth = 0) => {
|
|
229
|
+
count();
|
|
230
|
+
const stat = await lstat(directory);
|
|
231
|
+
if (depth > 32 || stat.dev !== device || stat.isSymbolicLink())
|
|
232
|
+
throw new Error("unsafe-tree");
|
|
233
|
+
const result = { logicalBytes: stat.isFile() ? stat.size : 0, allocatedBytes: stat.blocks * 512,
|
|
234
|
+
latest: Math.max(stat.mtimeMs, stat.ctimeMs), dev: stat.dev, ino: stat.ino };
|
|
235
|
+
if (stat.isDirectory()) {
|
|
236
|
+
for (const entry of await readdir(directory)) {
|
|
237
|
+
if (entry === ".git" && !disposableCheckout)
|
|
238
|
+
throw new Error("git-metadata");
|
|
239
|
+
const child = await stats(path.join(directory, entry), device, disposableCheckout, depth + 1);
|
|
240
|
+
result.logicalBytes += child.logicalBytes;
|
|
241
|
+
result.allocatedBytes += child.allocatedBytes;
|
|
242
|
+
result.latest = Math.max(result.latest, child.latest);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (!stat.isFile())
|
|
246
|
+
throw new Error("special-file");
|
|
247
|
+
return result;
|
|
248
|
+
};
|
|
249
|
+
const candidates = [];
|
|
250
|
+
for (const root of new Set(this.options.roots)) {
|
|
251
|
+
try {
|
|
252
|
+
validateTemporaryRoot(root);
|
|
253
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
count();
|
|
256
|
+
if (entry.name === TEMP_ROOT_MARKER)
|
|
257
|
+
continue;
|
|
258
|
+
const directory = path.join(root, entry.name);
|
|
259
|
+
try {
|
|
260
|
+
if (!entry.isDirectory()) {
|
|
261
|
+
report.skipped++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const lease = readTemporaryLease(directory);
|
|
265
|
+
if (active(lease)) {
|
|
266
|
+
report.skipped++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const stat = await stats(directory, (await lstat(root)).dev, lease.kind === "test-run" || lease.kind === "speech-setup");
|
|
270
|
+
if (stat.latest >= cutoff) {
|
|
271
|
+
report.skipped++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
candidates.push({ root, directory, lease, stat });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (error instanceof Error && error.message === "scan-limit")
|
|
278
|
+
throw error;
|
|
279
|
+
report.skipped++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error.code === "ENOENT")
|
|
285
|
+
continue;
|
|
286
|
+
// Discovery failure never falls back to prefix-based /tmp deletion.
|
|
287
|
+
report.issues.push("Temporary root unavailable, unsafe, or scan limit reached.");
|
|
288
|
+
return report;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
report.candidates = candidates.length;
|
|
292
|
+
let opened = [];
|
|
293
|
+
if (apply && candidates.length) {
|
|
294
|
+
try {
|
|
295
|
+
opened = await (this.options.inspectOpenPaths ?? openPaths)();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
report.skipped += candidates.length;
|
|
299
|
+
report.issues.push("Open-file inspection failed; nothing removed.");
|
|
300
|
+
return report;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const candidate of candidates) {
|
|
304
|
+
if (this.stopped)
|
|
305
|
+
break;
|
|
306
|
+
try {
|
|
307
|
+
validateTemporaryRoot(candidate.root);
|
|
308
|
+
const lease = readTemporaryLease(candidate.directory);
|
|
309
|
+
if (lease.id !== candidate.lease.id || active(lease)
|
|
310
|
+
|| opened.some(file => file === candidate.directory || file.startsWith(candidate.directory + path.sep))) {
|
|
311
|
+
report.skipped++;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const latest = await stats(candidate.directory, candidate.stat.dev, lease.kind === "test-run" || lease.kind === "speech-setup");
|
|
315
|
+
if (latest.ino !== candidate.stat.ino || latest.latest >= cutoff) {
|
|
316
|
+
report.skipped++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// The final synchronous lease/PID/path check leaves no await before removal starts.
|
|
320
|
+
validateTemporaryRoot(candidate.root);
|
|
321
|
+
const final = readTemporaryLease(candidate.directory);
|
|
322
|
+
if (final.id !== lease.id || active(final)) {
|
|
323
|
+
report.skipped++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (apply) {
|
|
327
|
+
await rm(candidate.directory, { recursive: true, force: false });
|
|
328
|
+
report.removed++;
|
|
329
|
+
}
|
|
330
|
+
report.logicalBytes += latest.logicalBytes;
|
|
331
|
+
report.allocatedBytes += latest.allocatedBytes;
|
|
332
|
+
report.byKind[lease.kind] = (report.byKind[lease.kind] ?? 0) + 1;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
report.skipped++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return report;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
export function createStorageCleanup(configDir, options = {}) {
|
|
342
|
+
const root = configTemporaryRoot(configDir);
|
|
343
|
+
return new StorageCleanup({ roots: [root, systemTemporaryRoot(), testTemporaryRoot()], configDirectory: path.dirname(path.dirname(root)), ...options });
|
|
344
|
+
}
|