feat(server,fs): W5-E production fsworker role, settings auto-investigate mode, replace old file to trash_item

- main.ts: `--role fsworker` dispatched before the rest of the module graph is
  loaded (other imports became dynamic); runs fsWorkerMain from the new
  `@umt/adapters/fsworker` export (~150 ms startup in the compiled binary)
- fsproxy: default worker command picked per runtime (deno CLI -> `deno run
  fsworker.ts`, compiled binary -> `<execPath> --role fsworker`); stats expose
  workerKind; UMT_FS_TEST_HANG_AFTER_BYTES acceptance switch for containers
- compile: --allow-run adds /app/umt-server (spawn self); start task allows deno
- organize: replaced old file moved into the file-manager trash layout
  (<root>/.umt-trash/<day>/<uuid>/<rel>) and registered in trash_item, ledger
  result follows; organize.transfer log carries fsproxy stats
- settings: autoInvestigateMode (investigate/recover) + max recover attempts
- deploy.md: container verification (subprocess self, TIMEOUT -> SIGKILL x3)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:50:10 +08:00
1 parent fc49c58359
commit 3641ab9573
12 files changed
+248 -48

No files matched your search

+41 -1
View File
@@ -3,7 +3,15 @@ import { existsSync } from "node:fs";
import { readdir, readFile, writeFile } from "node:fs/promises";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterEach, describe, expect, it } from "vitest";
import { FsError, type FsProgress, FsProxy, fsErrorClass, sweepStalePartials, toFsError } from "./index.ts";
import {
defaultWorkerCommand,
FsError,
type FsProgress,
FsProxy,
fsErrorClass,
sweepStalePartials,
toFsError,
} from "./index.ts";
// fsproxy:单个文件操作在执行子进程里跑,按进度心跳判挂死 → SIGTERM → SIGKILL → FsError('TIMEOUT')。
@@ -122,6 +130,38 @@ describe("FsProxy(子进程)", () => {
}, 30_000);
});
describe("FsProxy 启动方式", () => {
it("deno CLI 下缺省 run fsworker.ts;显式 workerCommand 记为 custom", () => {
const auto = defaultWorkerCommand();
expect(auto?.kind).toBe("script");
expect(auto?.args.at(-1)).toMatch(/fsworker\.ts$/);
expect(new FsProxy().stats()).toMatchObject({ mode: "subprocess", workerKind: "script" });
const px = new FsProxy({ workerCommand: { cmd: "/app/umt-server", args: ["--role", "fsworker"] } });
expect(px.stats()).toMatchObject({ mode: "subprocess", workerKind: "custom" });
});
it("起不来的 worker 命令(auto)回退 in-process 并记原因", async () => {
const t = await tree({ "a.mkv": "x" });
const px = new FsProxy({ workerCommand: { cmd: "/nonexistent/umt-server", args: ["--role", "fsworker"] } });
expect(await px.statSafe(t.path("a.mkv"))).toMatchObject({ kind: "ok" });
expect(px.stats()).toMatchObject({ mode: "inprocess", fallbackReason: expect.stringMatching(/^spawn_failed/) });
});
it("UMT_FS_TEST_HANG_AFTER_BYTES(容器验收开关)→ 数据类操作卡死 → SIGKILL → TIMEOUT;快操作不受影响", async () => {
const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null });
const px = new FsProxy({ mode: "subprocess", ...fast });
Deno.env.set("UMT_FS_TEST_HANG_AFTER_BYTES", String(MiB));
try {
expect(await px.statSafe(t.path("src/a.mkv"))).toMatchObject({ kind: "ok" });
const e = await px.copy(t.path("src/a.mkv"), t.path("dst/a.mkv")).catch((x) => x);
expect(e.code).toBe("TIMEOUT");
expect(px.stats()).toMatchObject({ timeouts: 1, killed: 1 });
} finally {
Deno.env.delete("UMT_FS_TEST_HANG_AFTER_BYTES");
}
}, 30_000);
});
describe("FsProxy(in-process 回退)", () => {
it("正常复制有进度;FIFO 挂死 → TIMEOUT(看门狗),不等卡住的调用返回", async () => {
const t = await tree({ "src/a.mkv": 3 * MiB, "dst/": null });