feat(server): W2-D agent assembly, session API, runAgent/resolveDecision and backend e2e

- Capabilities: all 14 registered (AO: inventory.query, download.plan/submit, organize.plan/execute,
  policy.preview/apply; agent: task.inspect/retry/cancel); startup asserts count == 14.
  Registry injects server-side authorize (automation_grant by action/scope/policy version/budget,
  one-shot approval bound to plan id + payload hash + revision + expiry; model claims ignored;
  automation switch), audit_event, server idempotency keys and a capability_invocation op ledger
  (migration 0021) used for crash reconciliation. download.plan creates draft plans only;
  download.submit promotes draft -> reserved after authorization.
- Agent session API: POST/GET /agent/runs, GET /agent/runs/{id} (events by seq, messages, report),
  POST .../messages (wake), POST .../abort; automation-grants, approvals, capabilities invoke, tasks.
- runAgent task: Pi or fake provider from settings (apiKey via secrets), events persisted to
  agent_run_event then published, resume + ledger reconciliation when pendingOperations > 0,
  waits for goal-linked downloads/imports and reports from business tables (submitted != imported).
- Background triggers: decision.created -> bounded plan_only investigation (dedupe per decision);
  decision.updated(resolved) -> resolveDecision executes chosen option.
- W2 closure: all OpenAPI routes registered in contracts api.ts (+ subset test); TMDB key from
  settings secret_ref with env fallback; PUT /sites/{id}/credentials documented as alias of
  /site-accounts; completion events documented as existing status events.
- e2e (real PG + OpenWorkflow + testkit mocks): RSS->library, agent delegation with grant/deny,
  background continuation, worker kill -9 recovery (single qB add), auto investigation dedupe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:52:12 +08:00
1 parent 0dec0854df
commit 6280d52ec3
46 files changed
+5133 -111

No files matched your search

+512 -69
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
import * as agent from "./agent.ts";
import * as assets from "./assets.ts";
import * as auth from "./auth.ts";
import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts";
import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "./common.ts";
import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts";
import * as content from "./content.ts";
import * as execution from "./execution.ts";
import * as identity from "./identity.ts";
@@ -105,6 +106,37 @@ const R = { read: "read", write: "admin" } as const;
const del = (path: string, summary: string) =>
ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult });
/** 实例连通性测试 */
const instanceTest = (path: string, summary: string) =>
ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult });
/** 入队后台任务的受理结果(taskId = 引擎 run id) */
const Accepted = z.object({ taskId: Id });
/** 人工指定来源资源身份的结果 */
const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) });
/** 字段覆盖/取消覆盖的结果 */
const FieldOverrideResult = z.object({
mediaEntityId: Id,
changedFields: z.array(z.string()),
lockedFields: z.array(z.string()),
});
const FieldParam = z.object({ id: Id, field: content.EntityMetadataField });
/** 引擎 run id 不是 uuid(openworkflow 自有 id) */
const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) });
/** 待处理事项聚合分组 */
const DecisionSummaryGroup = z.object({
kind: agent.DecisionKind,
mediaEntityId: Id.nullable(),
mediaEntityTitle: z.string().nullable(),
count: z.number().int(),
maxSeverity: z.enum(["info", "warning", "critical"]),
latestAt: z.string(),
});
export const Api = {
// ── 内容 / 身份 ──
mediaEntities: {
@@ -132,6 +164,53 @@ export const Api = {
body: z.object({ intoId: Id, reason: z.string().max(2000) }),
response: content.MediaEntity,
}),
detail: ep({
method: "get",
path: "/media-entities/{id}/detail",
scope: "read",
summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)",
params: IdParam,
response: content.MediaEntityDetail,
}),
setIdentity: ep({
method: "post",
path: "/media-entities/{id}/identities",
scope: "organize.execute",
summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁",
params: IdParam,
body: identity.EntityIdentitySet,
response: z.object({
mediaEntityId: Id,
changed: z.boolean(),
identityId: Id.optional(),
retired: z.array(Id).optional(),
}),
}),
refresh: ep({
method: "post",
path: "/media-entities/{id}/refresh",
scope: "organize.execute",
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
params: IdParam,
response: Accepted,
}),
setField: ep({
method: "put",
path: "/media-entities/{id}/fields/{field}",
scope: "organize.execute",
summary: "覆盖作品字段并锁定(自动刷新不再改写)",
params: FieldParam,
body: content.FieldOverrideInput,
response: FieldOverrideResult,
}),
clearField: ep({
method: "delete",
path: "/media-entities/{id}/fields/{field}",
scope: "organize.execute",
summary: "取消字段覆盖与锁定",
params: FieldParam,
response: FieldOverrideResult,
}),
},
contentUnits: crud(
"/content-units",
@@ -229,16 +308,20 @@ export const Api = {
),
// ── 实例 ──
libraryRoots: crud(
"/library-roots",
"媒体库根",
{
row: instances.LibraryRoot,
create: instances.LibraryRootCreate,
update: instances.LibraryRootUpdate,
},
R,
),
libraryRoots: {
...crud(
"/library-roots",
"媒体库根",
{
row: instances.LibraryRoot,
create: instances.LibraryRootCreate,
update: instances.LibraryRootUpdate,
},
R,
),
test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"),
remove: del("/library-roots/{id}", "删除媒体库根"),
},
downloaderInstances: {
...crud(
"/downloader-instances",
@@ -258,17 +341,22 @@ export const Api = {
params: IdParam,
response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }),
}),
remove: del("/downloader-instances/{id}", "删除下载器实例"),
},
mediaServerInstances: {
...crud(
"/media-server-instances",
"媒体服务器实例",
{
row: instances.MediaServerInstance,
create: instances.MediaServerInstanceCreate,
update: instances.MediaServerInstanceUpdate,
},
R,
),
test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"),
remove: del("/media-server-instances/{id}", "删除媒体服务器实例"),
},
mediaServerInstances: crud(
"/media-server-instances",
"媒体服务器实例",
{
row: instances.MediaServerInstance,
create: instances.MediaServerInstanceCreate,
update: instances.MediaServerInstanceUpdate,
},
R,
),
pathMappings: {
...crud(
"/path-mappings",
@@ -292,7 +380,84 @@ export const Api = {
},
// ── 来源 ──
sites: crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
sites: {
...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R),
enable: ep({
method: "post",
path: "/sites/{id}/enable",
scope: "admin",
summary: "开启站点",
params: IdParam,
response: sources.Site,
}),
disable: ep({
method: "post",
path: "/sites/{id}/disable",
scope: "admin",
summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)",
params: IdParam,
response: sources.Site,
}),
snapshot: ep({
method: "post",
path: "/sites/{id}/snapshot",
scope: "admin",
summary: "立即同步站点快照(受站点限流)",
params: IdParam,
body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }),
response: Accepted,
}),
snapshots: ep({
method: "get",
path: "/sites/{id}/snapshots",
scope: "read",
summary: "站点快照/补查记录(覆盖范围与新鲜度)",
params: IdParam,
query: CursorQuery,
response: cursorPage(sources.SiteSnapshot),
}),
/** 兼容别名:规范接口是 /site-accounts 资源 CRUD;这里按 (site,label) 新建或更新 site_account 的凭证 */
credentials: ep({
method: "put",
path: "/sites/{id}/credentials",
scope: "admin",
summary:
"兼容别名:按 (站点, label) 新建或更新 site_account 凭证(→ secret_ref,不回显);新客户端请用 POST/PATCH /site-accounts",
params: IdParam,
body: instances.SiteCredentialsPut,
response: instances.SiteCredentialsResult,
}),
},
siteDefinitions: {
list: ep({
method: "get",
path: "/site-definitions",
scope: "read",
summary: "随产品发布的站点适配器定义(含是否已登记)",
response: z.object({ items: z.array(sources.SiteDefinitionView) }),
}),
},
/** 站点工作台(整理视图):库存叠加走 inventory.overlay */
siteWorkbench: {
releases: ep({
method: "get",
path: "/sites/{id}/releases",
scope: "read",
summary: "站点资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)",
params: IdParam,
query: sources.WorkbenchReleaseQuery,
response: sources.WorkbenchReleasePage,
}),
release: ep({
method: "get",
path: "/sites/{id}/releases/{rid}",
scope: "read",
summary: "站点资源详情(refresh=true 时实时复核详情;失败保留既有数据)",
params: z.object({ id: Id, rid: Id }),
query: z.object({ refresh: z.enum(["true", "false"]).optional() }),
response: sources.WorkbenchReleaseDetail,
}),
},
siteAccounts: crud(
"/site-accounts",
"站点账号",
@@ -324,21 +489,64 @@ export const Api = {
params: IdParam,
response: z.object({ taskId: Id }),
}),
enable: ep({
method: "post",
path: "/feed-sources/{id}/enable",
scope: "admin",
summary: "启用 Feed",
params: IdParam,
response: sources.FeedSource,
}),
disable: ep({
method: "post",
path: "/feed-sources/{id}/disable",
scope: "admin",
summary: "停用 Feed",
params: IdParam,
response: sources.FeedSource,
}),
reset: ep({
method: "post",
path: "/feed-sources/{id}/reset",
scope: "admin",
summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)",
params: IdParam,
response: sources.FeedSource,
}),
},
sourceReleases: crud(
"/source-releases",
"站点资源",
{
row: sources.SourceRelease,
filters: {
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: sources.ResolutionStatus.optional(),
q: z.string().max(200).optional(),
sourceReleases: {
...crud(
"/source-releases",
"站点资源",
{
row: sources.SourceRelease,
filters: {
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: sources.ResolutionStatus.optional(),
q: z.string().max(200).optional(),
},
},
},
R,
),
R,
),
assignIdentity: ep({
method: "post",
path: "/source-releases/{id}/identity",
scope: "organize.execute",
summary: "人工指定来源资源的作品/内容单元(加人工锁,自动识别不再改写)",
params: IdParam,
body: identity.ReleaseIdentityAssign,
response: ReleaseIdentityResult,
}),
unlock: ep({
method: "post",
path: "/source-releases/{id}/unlock",
scope: "organize.execute",
summary: "解除来源资源的人工锁并重新识别",
params: IdParam,
response: Accepted,
}),
},
observations: crud(
"/observations",
"观察",
@@ -539,17 +747,35 @@ export const Api = {
body: agent.DecisionResolveInput,
response: agent.Decision,
}),
summary: ep({
method: "get",
path: "/decisions/summary",
scope: "read",
summary: "未关闭待处理事项按作品与问题类型聚合",
query: z.object({ status: agent.DecisionStatus.optional() }),
response: z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }),
}),
},
approvals: {
...crud(
"/approvals",
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
create: ep({
method: "post",
path: "/approvals",
scope: "organize.execute",
summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期)",
body: agent.ApprovalRequest,
response: agent.Approval,
}),
},
approvals: crud(
"/approvals",
"审批",
{
row: agent.Approval,
update: agent.ApprovalUpdate,
filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
automationGrants: crud(
"/automation-grants",
"持续授权",
@@ -561,32 +787,49 @@ export const Api = {
},
R,
),
/** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */
agentRuns: {
...crud(
"/agent-runs",
"Agent 运行",
{
row: agent.AgentRun,
create: agent.AgentRunCreate,
filters: { status: agent.AgentRunStatus.optional(), taskId: Id.optional() },
},
{ read: "read", write: "organize.execute" },
),
cancel: ep({
method: "post",
path: "/agent-runs/{id}/cancel",
scope: "organize.execute",
summary: "取消 Agent 运行",
list: ep({
method: "get",
path: "/agent/runs",
scope: "read",
summary: "Agent 运行列表(新→旧)",
query: agent.AgentRunListQuery,
response: cursorPage(agent.AgentRun),
}),
get: ep({
method: "get",
path: "/agent/runs/{id}",
scope: "read",
summary: "Agent 运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报",
params: IdParam,
query: agent.AgentRunDetailQuery,
response: agent.AgentRunDetail,
}),
create: ep({
method: "post",
path: "/agent/runs",
scope: "organize.execute",
summary: "委托目标:建 agent_run 并入队 runAgent(execute | plan_only)",
body: agent.AgentRunStartInput,
response: agent.AgentRun,
}),
message: ep({
method: "post",
path: "/agent-runs/{id}/messages",
path: "/agent/runs/{id}/messages",
scope: "organize.execute",
summary: "向运行中的会话追加用户消息(纠正/补充)",
summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑",
params: IdParam,
body: z.object({ text: z.string().min(1).max(8000) }),
body: agent.AgentRunMessageInput,
response: agent.AgentRun,
}),
abort: ep({
method: "post",
path: "/agent/runs/{id}/abort",
scope: "organize.execute",
summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)",
params: IdParam,
body: agent.AgentRunAbortInput,
response: agent.AgentRun,
}),
},
@@ -651,18 +894,15 @@ export const Api = {
body: ops.ApiKeyUpdate,
response: ops.ApiKey,
}),
revoke: del("/api-keys/{id}", "撤销 API Key"),
},
auditEvents: {
list: ep({
method: "get",
path: "/audit-events",
scope: "admin",
summary: "审计事件",
query: listQuery({
targetType: z.string().optional(),
targetId: z.string().optional(),
action: z.string().optional(),
}),
summary: "审计事件(按 actor/action/target 过滤,新→旧)",
query: ops.AuditEventQuery,
response: cursorPage(ops.AuditEvent),
}),
},
@@ -692,6 +932,7 @@ export const Api = {
body: ops.SecretUpdate,
response: ops.Secret,
}),
remove: del("/secrets/{id}", "删除凭证(被引用时 409)"),
},
// ── 整理(公开合同) ──
@@ -762,6 +1003,208 @@ export const Api = {
response: GlobalSettings,
}),
},
// ── 登录门禁 ──
/** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */
auth: {
setup: ep({
method: "post",
path: "/auth/setup",
scope: "read",
summary: "首次初始化管理员",
body: auth.AdminSetupRequest,
response: auth.SessionInfo,
}),
login: ep({
method: "post",
path: "/auth/login",
scope: "read",
summary: "管理员登录",
body: auth.LoginRequest,
response: auth.LoginResult,
}),
logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }),
session: ep({
method: "get",
path: "/auth/session",
scope: "read",
summary: "当前登录态(免鉴权)",
response: auth.SessionInfo,
}),
password: ep({
method: "post",
path: "/auth/password",
scope: "admin",
summary: "修改密码(吊销其它会话)",
body: auth.PasswordChangeRequest,
response: auth.PasswordChangeResult,
}),
totpStatus: ep({
method: "get",
path: "/auth/totp",
scope: "admin",
summary: "TOTP 状态",
response: auth.TotpStatus,
}),
totp: ep({
method: "post",
path: "/auth/totp",
scope: "admin",
summary: "TOTP 绑定/启用/停用",
body: auth.TotpAction,
response: auth.TotpActionResult,
}),
},
// ── 系统 ──
system: {
health: ep({
method: "get",
path: "/health",
scope: "read",
summary: "服务健康状态(免鉴权)",
response: HealthStatus,
}),
},
// ── 引擎面板(仅 admin) ──
engine: {
runs: ep({
method: "get",
path: "/engine/runs",
scope: "admin",
summary: "引擎 runs(状态/名称/时间筛选,新→旧)",
query: ops.EngineRunQuery,
response: cursorPage(ops.EngineRun),
}),
run: ep({
method: "get",
path: "/engine/runs/{id}",
scope: "admin",
summary: "run 详情(step attempts + signals)",
params: EngineRunIdParam,
response: ops.EngineRunDetail,
}),
stats: ep({
method: "get",
path: "/engine/stats",
scope: "admin",
summary: "按状态计数 / 最老待处理 / 活跃 worker 快照",
response: ops.EngineStats,
}),
cancel: ep({
method: "post",
path: "/engine/runs/{id}/cancel",
scope: "admin",
summary: "取消 run",
params: EngineRunIdParam,
body: ops.EngineCancelRequest,
response: ops.EngineCancelResult,
}),
},
// ── 待识别池 ──
identityPool: {
list: ep({
method: "get",
path: "/identity-pool",
scope: "read",
summary: "待识别池(可按原始标题检索;候选不建正式作品)",
query: identity.IdentityPoolQuery,
response: cursorPage(identity.IdentityPoolItem),
}),
assign: ep({
method: "post",
path: "/identity-pool/{id}/assign",
scope: "organize.execute",
summary: "为待识别资源手动指定候选 / 本地作品 / 外部身份",
params: IdParam,
body: identity.ReleaseIdentityAssign,
response: ReleaseIdentityResult,
}),
dismiss: ep({
method: "post",
path: "/identity-pool/{id}/dismiss",
scope: "organize.execute",
summary: "忽略待识别资源(不再自动复查)",
params: IdParam,
body: z.object({ note: z.string().max(2000).optional() }),
response: identity.IdentityCandidate,
}),
retry: ep({
method: "post",
path: "/identity-pool/{id}/retry",
scope: "organize.execute",
summary: "立即重新识别",
params: IdParam,
response: Accepted,
}),
},
// ── 媒体库 / 库存 / 首页 ──
library: {
list: ep({
method: "get",
path: "/library",
scope: "read",
summary: "媒体库列表(已登记资产或正在追踪的作品)",
query: organization.LibraryListQuery,
response: cursorPage(organization.LibraryItem),
}),
get: ep({
method: "get",
path: "/library/{id}",
scope: "read",
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
params: IdParam,
response: organization.LibraryDetail,
}),
reconcile: ep({
method: "post",
path: "/library/reconcile",
scope: "admin",
summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)",
body: z.object({ libraryRootId: z.uuid().optional() }),
response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }),
}),
},
inventory: {
overlay: ep({
method: "get",
path: "/inventory",
scope: "read",
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
query: organization.InventoryQuery,
response: organization.InventoryResponse,
}),
mediaServers: ep({
method: "get",
path: "/inventory/media-servers",
scope: "read",
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
query: organization.MediaServerInventoryQuery,
response: organization.MediaServerInventory,
}),
},
dashboard: {
get: ep({
method: "get",
path: "/dashboard",
scope: "read",
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
response: organization.Dashboard,
}),
},
downloadCache: {
cleanup: ep({
method: "post",
path: "/download-cache/cleanup",
scope: "admin",
summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)",
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
response: z.object({ report: z.record(z.string(), z.unknown()) }),
}),
},
} as const;
/** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */