feat(sources,organize): site agent capabilities and organize.minSizeBytes setting (W10-pre2)

- site.list / site.health_check / site.refresh_login / site.user_data capabilities
  over the W9-A site center services; outputs whitelisted (no secret refs,
  cookies, passwords, login names); execute kinds need automation grant.
  IDs use snake_case (CapabilityId regex); registry count 17 -> 21.
- settings.organize.minSizeBytes (default 50 MiB); file-manager identify /
  organize and planImport take request > setting > port default.
- tests and runbook notes (sites, files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:02:30 +08:00
1 parent c08d8ce62a
commit 81bf010e3e
18 files changed
+563 -15

No files matched your search

@@ -172,7 +172,12 @@ export async function createHarness(name: string, overrides: AoOverrides = {}):
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
// 整理最小体积缺省跟随端口注入的 minVideoBytes(测试用小文件)
const base =
k === "organize"
? { ...DEFAULT_SETTINGS.organize, minSizeBytes: overrides.options?.minVideoBytes ?? 100_000 }
: DEFAULT_SETTINGS[k];
out[k] = GlobalSettings.shape[k].parse({ ...base, ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
@@ -524,7 +524,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
templates: fileTemplates,
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
retention: { hnrSatisfied: hnr },
minVideoBytes: req.minSizeBytes ?? d.ports.options.minVideoBytes,
minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes,
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
@@ -86,7 +86,7 @@ export async function identify(
): Promise<FilesIdentifyResult> {
const db = d.ctx.db;
const { videos, truncated } = await collectVideos(d, body);
// G2:最小体积(请求 > settings.organize.minSizeBytes > 不过滤)
// G2:最小体积(请求/向导 > settings.organize.minSizeBytes,缺省 50 MiB;设置读不到时不过滤)
const orgSettings = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
const minSize = body.minSizeBytes ?? (typeof orgSettings?.minSizeBytes === "number" ? orgSettings.minSizeBytes : 0);
let excludedBySize = 0;
@@ -118,7 +118,7 @@ function segmentsOf(plan: FrozenPlan | undefined, overrideDir: boolean): FilesOr
);
}
/** 设置里的最小体积(settings.organize.minSizeBytes;契约未加该字段前恒为 undefined → planImport 用端口默认) */
/** 设置里的最小体积(settings.organize.minSizeBytes,缺省 50 MiB);读不到时 undefined → planImport 再读设置 / 端口默认 */
async function settingsMinSize(d: FmDeps): Promise<number | undefined> {
const org = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
return typeof org?.minSizeBytes === "number" && org.minSizeBytes >= 0 ? org.minSizeBytes : undefined;
@@ -0,0 +1,123 @@
import type { CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type SiteAccountUserData, type SiteLastHealth } from "@umt/contracts";
import type { SiteAccountRow, SiteRow } from "@umt/db";
import type { SourcesRuntime } from "./runtime.ts";
import { checkSiteHealth, overallAccountStatus, refreshSiteAccount, userDataHistory } from "./service/account.ts";
// W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data,处理器调 W9-A 站点中心的 service。
// 脱敏:输出按白名单逐字段拼装——不含 secret 引用、Cookie、密码、登录名、UA 与站点 config。
const iso = (d: Date | null) => (d ? d.toISOString() : null);
function accountBrief(a: SiteAccountRow) {
return {
id: a.id,
label: a.label,
authKind: a.auth_kind,
enabled: a.enabled,
status: a.status,
lastLoginAt: iso(a.last_login_at),
lastVerifiedAt: iso(a.last_verified_at),
lastError: (a.last_error ?? null) as never,
userDataAt: iso(a.user_data_at),
hasSession: !!a.session_secret_ref,
hasPasskey: !!a.passkey_secret_ref || (a.auth_kind === "passkey" && !!a.secret_ref),
};
}
async function liveSite(r: SourcesRuntime, siteId: string): Promise<SiteRow> {
const s = await r.ctx.db
.selectFrom("site")
.selectAll()
.where("id", "=", siteId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!s) throw new Error("站点不存在");
return s;
}
export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void {
registry.register(capabilitySpec("site.list"), async (input) => {
const { db } = rt().ctx;
let q = db.selectFrom("site").selectAll().where("deleted_at", "is", null);
if (!input.includeDisabled) q = q.where("enabled", "=", true);
if (input.keys?.length) q = q.where("key", "in", input.keys);
const sites = await q.orderBy("key").execute();
const accounts = sites.length
? await db
.selectFrom("site_account")
.selectAll()
.where(
"site_id",
"in",
sites.map((s) => s.id),
)
.orderBy("created_at")
.execute()
: [];
return {
items: sites.map((s) => {
const mine = accounts.filter((a) => a.site_id === s.id);
return {
id: s.id,
key: s.key,
name: s.name,
adapter: s.adapter,
baseUrl: s.base_url,
enabled: s.enabled,
healthStatus: s.health_status,
lastHealth: (s.last_health ?? null) as SiteLastHealth | null,
lastHealthAt: iso(s.last_health_at),
accountStatus: overallAccountStatus(mine),
accounts: mine.map(accountBrief),
};
}),
};
});
registry.register(capabilitySpec("site.health_check"), async (input, ctx) => {
const r = rt();
return checkSiteHealth(r, await liveSite(r, input.siteId), { type: "agent", id: ctx.actorId });
});
registry.register(capabilitySpec("site.refresh_login"), async (input, ctx) => {
const r = rt();
const s = await liveSite(r, input.siteId);
let q = r.ctx.db.selectFrom("site_account").select(["id"]).where("site_id", "=", s.id);
q = input.accountId
? q.where("id", "=", input.accountId)
: q.where("enabled", "=", true).where("auth_kind", "!=", "passkey");
const acc = await q.orderBy("created_at").executeTakeFirst();
if (!acc) throw new Error(input.accountId ? "站点账号不存在" : "站点还没有凭证");
return refreshSiteAccount(r, acc.id, {
mode: "login",
actor: { type: "agent", id: ctx.actorId },
signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]),
});
});
registry.register(capabilitySpec("site.user_data"), async (input) => {
const r = rt();
const s = await liveSite(r, input.siteId);
let q = r.ctx.db
.selectFrom("site_account")
.select(["id", "label", "status", "user_data", "user_data_at"])
.where("site_id", "=", s.id);
if (input.accountId) q = q.where("id", "=", input.accountId);
const accounts = await q.orderBy("created_at").execute();
return {
siteId: s.id,
accounts: accounts.map((a) => ({
accountId: a.id,
label: a.label,
status: a.status,
userData: (a.user_data ?? null) as SiteAccountUserData | null,
userDataAt: iso(a.user_data_at),
})),
history: await userDataHistory(r, s.id, {
...(input.accountId ? { accountId: input.accountId } : {}),
days: input.days,
}),
};
});
}
@@ -2,6 +2,7 @@ import type { CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type ExternalEntityType, type SourceRelease } from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { registerSiteCapabilities } from "./capabilities-sites.ts";
import { toApi, toApiList } from "./rows.ts";
import type { SourcesRuntime } from "./runtime.ts";
import { assignReleaseIdentity } from "./service/identity-admin.ts";
@@ -9,10 +10,12 @@ import { resolveRelease } from "./service/resolve.ts";
import { refreshReleaseDetail } from "./service/snapshot.ts";
import { liveSearch } from "./service/workbench.ts";
// 本模块领域的能力处理器(规格一律取 contracts):catalog.lookup / release.search / release.inspect / identity.resolve。
// 本模块领域的能力处理器(规格一律取 contracts):catalog.lookup / release.search / release.inspect / identity.resolve;
// 站点能力 site.* 见 capabilities-sites.ts。
// identity.resolve 属 propose 类,授权回调由 W2-D 接入前会被注册中心拒绝(安全默认)。
export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () => SourcesRuntime): void {
registerSiteCapabilities(registry, rt);
registry.register(capabilitySpec("catalog.lookup"), async (input) => {
const { db } = rt().ctx;
const local: { entity: unknown; score: number; matchedAlias: string | null }[] = [];