feat(server): W5-A file manager module (/files)

Roots/list/stat with inline business status, path guard (realpath within
registered roots, symlink escape, offline root 503), mkdir/rename syncing
asset_location and path_binding revisions, two-phase move/copy/organize
(planId + source facts), trash with reference checks and purgeTrash task,
identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:02:20 +08:00
1 parent 09f6e3a3e5
commit 29cd10b27e
20 files changed
+4380 -1

No files matched your search

@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { defineTask } from "@umt/tasks";
import { z } from "zod";
import type { AoOverrides } from "../acquisition-organize/index.ts";
import { createPorts } from "../acquisition-organize/ports.ts";
import { timeSlot } from "../acquisition-organize/services/common.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { defaultOptions, errMessage, type FmDeps, type FmOptions } from "./common.ts";
import { registerFileRoutes } from "./routes.ts";
import { purgeTrash } from "./trash.ts";
/**
* W5-A 文件管理(/files,docs/plan/06-file-manager-spec.md):只浏览已登记根(库根 + 下载器路径映射),
* 行内业务状态、改名同步资产与路径绑定修订、移动/复制两段式 planId、整理走 planImport、回收站 + 引用检查、识别/探测/刮削。
* 复用 acquisition-organize 的服务导出(planImport / 路径守卫 / Move 授权 / H&R),不改其文件。
* 持久任务 purgeTrash:每日清理过期回收站项(FILES_TRASH_RETENTION_DAYS,默认 7 天)。
*/
export interface FmOverrides {
ao?: AoOverrides;
options?: Partial<FmOptions>;
}
export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } {
let deps: FmDeps | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
const lazy = (ctx: ServerContext): FmDeps => {
if (!deps) {
const { options: aoOptions, ...aoRest } = overrides.ao ?? {};
deps = {
ctx,
ao: { ctx, ports: createPorts(ctx, { ...aoRest, ...(aoOptions ? { options: aoOptions } : {}) }) },
options: { ...defaultOptions(ctx), ...overrides.options },
};
}
return deps;
};
const enqueuePurge = async (d: FmDeps) => {
try {
await d.ctx.engine.enqueue(
"purgeTrash",
{},
{
idempotencyKey: `purgeTrash:${timeSlot(d.ao.ports.now(), d.options.purgeIntervalMs)}`,
traceId: randomUUID(),
},
);
} catch (err) {
d.ctx.logger.warn({ err: { message: errMessage(err) } }, "回收站清理入队失败");
}
};
return {
name: "file-manager",
deps() {
if (!deps) throw new Error("file-manager 尚未装配");
return deps;
},
registerRoutes(app, ctx) {
registerFileRoutes(app, lazy(ctx));
},
registerTasks(registry, ctx) {
registry.register(
defineTask({
name: "purgeTrash",
version: 1,
inputSchema: z.object({}).passthrough(),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: async (t) =>
t.step.run("purge", async () => {
const r = await purgeTrash(lazy(ctx), undefined, {});
return { purged: r.purged.length, failed: r.failed.length };
}),
}),
);
},
start(ctx) {
const d = lazy(ctx);
if (!d.options.backgroundLoops || d.options.purgeIntervalMs <= 0) return;
void enqueuePurge(d);
timer = setInterval(() => void enqueuePurge(d), Math.min(d.options.purgeIntervalMs, 3600_000));
},
stop() {
if (timer) clearInterval(timer);
timer = null;
},
};
}
export const fileManagerModule = createFileManagerModule();