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

@@ -95,10 +95,22 @@ describe.skipIf(!hasPg)("整理执行:onExisting / partial 清理 / TIMEOUT 重
.select("result")
.where("operation_id", "=", `org:${plan.recordId}:0`)
.executeTakeFirstOrThrow();
const r = op.result as { replaced?: { disposition: string; path: string } };
const r = op.result as { replaced?: { disposition: string; path: string; trashItemId?: string } };
expect(r.replaced?.disposition).toBe("trash");
expect(r.replaced!.path.startsWith(join(lib.root, ".umt-trash"))).toBe(true);
expect(await readFile(r.replaced!.path, "utf8")).toBe("OLD-VERSION");
// 旧文件登记进文件管理回收站:布局 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,可还原/清除
const ti = await h.db
.selectFrom("trash_item")
.selectAll()
.where("id", "=", r.replaced!.trashItemId!)
.executeTakeFirstOrThrow();
expect(ti.trash_path).toBe(r.replaced!.path);
expect(ti.root_id).toBe(`lib:${ti.library_root_id}`);
expect(ti.trash_path.endsWith(`/${ti.original_path}`)).toBe(true);
expect(join(ti.root_host_path, ti.original_path)).toBe(dst);
expect(Number(ti.size_bytes)).toBe("OLD-VERSION".length);
expect((await readdir(join(lib.root, ".umt-trash"))).filter((n) => n.startsWith("replaced-"))).toEqual([]);
expect(existsSync(stale)).toBe(false);
expect((await readdir(dirname(dst))).filter((n) => n.startsWith("."))).toEqual([]);
}, 60_000);
@@ -1,5 +1,6 @@
import { mkdir, readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { randomUUID } from "node:crypto";
import { mkdir, readdir, realpath, rmdir } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import {
type CopyReceipt,
type CrossMoveStepLog,
@@ -794,6 +795,8 @@ export async function executeRecord(
mbps: r.durationMs > 0 ? Math.round(((r.bytes ?? pf.size) / 1e6 / r.durationMs) * 1000) : null,
hashVia: r.hashVia ?? null,
resumed: info.resumed,
// 执行方式:subprocess(workerKind script=deno run / self=编译二进制 --role fsworker)或 inprocess 回退
fsproxy: fsr.proxy.stats(),
},
"organize.transfer",
);
@@ -860,6 +863,11 @@ export async function executeRecord(
options: [{ key: "retry", label: "检查挂载后重新执行整理" }],
});
}
// 被替换的旧文件登记进文件管理回收站(可在回收站视图还原/清除);登记失败只告警,旧文件留在 .umt-trash
if (outcome.replaced?.disposition === "trash" && outcome.replaced.path)
await trashReplacedOld(d, fsr, frozen, pf.dst, { recordId, opId }, outcome.replaced).catch((err) =>
d.ctx.logger.warn({ err, recordId, dst: pf.dst }, "替换旧文件登记回收站失败"),
);
if (outcome.replaced?.disposition === "left") {
await upsertDecision(d, db, {
kind: "other",
@@ -892,6 +900,54 @@ export async function executeRecord(
return finalizeRecord(d, recordId);
}
/**
* replace 的旧文件(copy 已同卷移到 <根>/.umt-trash/replaced-*)改放成文件管理回收站的布局
* <根real>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径> 并写 trash_item(与 file-manager/trash.ts moveToTrash 同构,
* 还原/清除按 original_path 反推 uuid 目录,布局必须一致)。
*/
async function trashReplacedOld(
d: AoDeps,
fsr: FsRuntime,
frozen: FrozenPlan,
dst: string,
ref: { recordId: string; opId: string },
old: { path?: string; size: number },
): Promise<void> {
const rootReal = await realpath(frozen.rootPath);
const rel = relative(rootReal, await realpath(dirname(dst)).then((p) => join(p, basename(dst))));
if (!rel || rel.startsWith("..") || !old.path) return;
const id = randomUUID();
const now = d.ports.now();
const to = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
await mkdir(dirname(to), { recursive: true });
const m = await fsr.proxy.moveSafe(old.path, to);
if (m.status !== "moved") throw new Error(`旧文件移入回收站失败(${m.status}): ${old.path}`);
await rmdir(dirname(old.path)).catch(() => undefined);
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
await d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: `lib:${frozen.libraryRootId}`,
library_root_id: frozen.libraryRootId,
root_host_path: rootReal,
original_path: rel,
trash_path: to,
is_dir: false,
size_bytes: old.size,
actor: "system:organize",
deleted_at: now,
expires_at: new Date(now.getTime() + days * 86_400_000),
asset_snapshot: jsonb({ source: "organize_replace", recordId: ref.recordId, dst }),
})
.execute();
// 账本里的旧文件去向跟着改到回收站路径
await sql`update asset_operation
set result = jsonb_set(jsonb_set(result, '{replaced,path}', to_jsonb(${to}::text)),
'{replaced,trashItemId}', to_jsonb(${id}::text))
where operation_id = ${ref.opId} and result ? 'replaced'`.execute(d.ctx.db);
}
/** TIMEOUT/IO 类失败且未到尝试上限:从 withAttemptToken 内抛出,账本不落结果 */
class RetryableTransfer extends Error {
constructor(