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:
1 parent
0dec0854df
commit
6280d52ec3
46 files changed
+5133
-111
No files matched your search
@@ -0,0 +1,415 @@
|
||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||
import { capabilitySpec } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle, transition } from "@umt/domain";
|
||||
import { previewPolicy, publishRevision } from "./routes/rules.ts";
|
||||
import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts";
|
||||
import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts";
|
||||
import { type FrozenPlan, planImport } from "./services/organize.ts";
|
||||
import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts";
|
||||
|
||||
// 本模块领域的能力处理器(规格一律取 contracts):
|
||||
// inventory.query / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply
|
||||
// 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts);
|
||||
// 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。
|
||||
|
||||
const BUSY = ["reserved", "downloading", "verifying"];
|
||||
|
||||
export function registerAoCapabilities(registry: CapabilityRegistry, deps: () => AoDeps): void {
|
||||
registry.register(capabilitySpec("inventory.query"), async (input) => {
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
let ids: string[] = [];
|
||||
if (input.mediaEntityId) ids = [input.mediaEntityId];
|
||||
else if (input.query) {
|
||||
const n = normalizeTitle(input.query);
|
||||
const like = `%${input.query.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
|
||||
const rows = await sql<{ id: string }>`
|
||||
select e.id, max(greatest(coalesce(similarity(a.normalized, ${n}), 0),
|
||||
case when a.normalized = ${n} then 1 else 0 end)) as score
|
||||
from media_entity e left join title_alias a on a.media_entity_id = e.id
|
||||
where e.status <> 'merged'
|
||||
and (e.title ilike ${like} or e.original_title ilike ${like} or a.normalized = ${n}
|
||||
or similarity(a.normalized, ${n}) >= 0.3)
|
||||
group by e.id order by score desc, e.id limit ${input.limit}`.execute(db);
|
||||
ids = rows.rows.map((r) => r.id);
|
||||
} else {
|
||||
ids = (
|
||||
await db
|
||||
.selectFrom("media_entity")
|
||||
.select("id")
|
||||
.where("status", "<>", "merged")
|
||||
.orderBy("updated_at", "desc")
|
||||
.limit(input.limit)
|
||||
.execute()
|
||||
).map((r) => r.id);
|
||||
}
|
||||
const items = [];
|
||||
for (const id of ids) {
|
||||
const e = await db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!e) continue;
|
||||
const [assets, slots, identities, server] = await Promise.all([
|
||||
db.selectFrom("media_asset").selectAll().where("media_entity_id", "=", id).execute(),
|
||||
db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", id).orderBy("created_at").execute(),
|
||||
db
|
||||
.selectFrom("external_identity")
|
||||
.select(["provider", "entity_type", "external_id", "status"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.execute(),
|
||||
input.includeServerInventory
|
||||
? db.selectFrom("server_mapping").selectAll().where("media_entity_id", "=", id).execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
items.push({
|
||||
entity: toApi(e),
|
||||
assets: toApiList(assets),
|
||||
slots: toApiList(slots),
|
||||
identities: identities.map((i) => ({
|
||||
provider: i.provider,
|
||||
entityType: i.entity_type,
|
||||
externalId: i.external_id,
|
||||
status: i.status,
|
||||
})),
|
||||
server: toApiList(server),
|
||||
});
|
||||
}
|
||||
return { items } as never;
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("download.plan"), async (input) => {
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
|
||||
if (!slot) throw new CapabilityError("not_found", "收录目标(Slot)不存在");
|
||||
const rel = await db
|
||||
.selectFrom("source_release")
|
||||
.select(["id", "media_entity_id"])
|
||||
.where("id", "=", input.sourceReleaseId)
|
||||
.executeTakeFirst();
|
||||
if (!rel) throw new CapabilityError("not_found", "来源资源不存在");
|
||||
const out = await evaluateAcquisition(d, {
|
||||
sourceReleaseId: input.sourceReleaseId,
|
||||
acquisitionSlotId: input.slotId,
|
||||
draft: true,
|
||||
});
|
||||
const planId = out.plans[0] ?? null;
|
||||
const plan = planId
|
||||
? await db.selectFrom("acquisition_plan").selectAll().where("id", "=", planId).executeTakeFirst()
|
||||
: undefined;
|
||||
const rejected: { code: string; message: string }[] = [];
|
||||
if (!plan) {
|
||||
for (const s of out.skipped) rejected.push({ code: s.reason.split(":")[0] ?? s.reason, message: s.reason });
|
||||
// 候选评估记录(硬条件拒绝等)
|
||||
const ev = await db
|
||||
.selectFrom("artifact")
|
||||
.select(["content"])
|
||||
.where("subject_type", "=", "acquisition_slot")
|
||||
.where("subject_id", "=", input.slotId)
|
||||
.where(sql<boolean>`content->>'sourceReleaseId' = ${input.sourceReleaseId}`)
|
||||
.orderBy("updated_at", "desc")
|
||||
.executeTakeFirst();
|
||||
const c = (ev?.content ?? {}) as { rejected?: { code: string; message: string }[]; reasons?: string[] };
|
||||
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
|
||||
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
|
||||
if (rejected.length === 0) rejected.push({ code: "not_planned", message: "未生成计划(Slot 忙或条件不满足)" });
|
||||
}
|
||||
const fresh = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", input.slotId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const grant = await db
|
||||
.selectFrom("automation_grant")
|
||||
.select("id")
|
||||
.where("status", "=", "active")
|
||||
.where("action", "in", ["download.submit", "policy.acquisition", "*"])
|
||||
.executeTakeFirst();
|
||||
return {
|
||||
plan: plan ? (toApi(plan) as never) : null,
|
||||
slot: toApi(fresh) as never,
|
||||
rejected,
|
||||
requiresApproval: !grant,
|
||||
};
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("download.submit"), async (input) => {
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", input.planId).executeTakeFirst();
|
||||
if (!plan) throw new CapabilityError("not_found", "下载计划不存在");
|
||||
if (plan.status === "draft") {
|
||||
// 草案 → reserved:Slot 必须与生成计划时一致(revision 未变、没有其它预留)
|
||||
const ok = await db
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const slot = await trx
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("id", "=", plan.slot_id)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (slot.revision + 1 !== plan.expected_revision) return "Slot 已变化,请重新生成计划";
|
||||
if (BUSY.includes(slot.status)) return `Slot 正忙(${slot.status})`;
|
||||
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
|
||||
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
|
||||
st = transition(st, { type: "reserve" });
|
||||
await trx
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ status: "reserved", status_reason: "authorized" })
|
||||
.where("id", "=", plan.id)
|
||||
.where("status", "=", "draft")
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("acquisition_slot")
|
||||
.set({
|
||||
status: st.status,
|
||||
reserved_plan_id: plan.id,
|
||||
revision: slot.revision + 1,
|
||||
status_reason: "reserved:agent",
|
||||
})
|
||||
.where("id", "=", slot.id)
|
||||
.execute();
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
if ((err as { code?: string }).code === "23505") return "Slot 已有其它有效预留";
|
||||
throw err;
|
||||
});
|
||||
if (ok) throw new CapabilityError("conflict", ok);
|
||||
} else if (!["reserved", "submitted", "completed"].includes(plan.status)) {
|
||||
throw new CapabilityError("precondition", `计划状态为 ${plan.status},不能提交`);
|
||||
}
|
||||
// 与自动路径同一幂等键:同一计划只会有一个 submitDownload 任务
|
||||
const taskId = await enqueueTracked(d, {
|
||||
kind: "submitDownload",
|
||||
idempotencyKey: `plan:${plan.id}`,
|
||||
input: { acquisitionPlanId: plan.id },
|
||||
subjectType: "acquisition_plan",
|
||||
subjectId: plan.id,
|
||||
triggeredBy: "agent",
|
||||
});
|
||||
await db
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ task_id: taskId })
|
||||
.where("id", "=", plan.id)
|
||||
.where("task_id", "is", null)
|
||||
.execute();
|
||||
const dt = await db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("acquisition_plan_id", "=", plan.id)
|
||||
.executeTakeFirst();
|
||||
return { taskId, planId: plan.id, downloadTask: dt ? (toApi(dt) as never) : null };
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("organize.plan"), async (input, ctx) => {
|
||||
const d = deps();
|
||||
if (!input.downloadTaskId)
|
||||
throw new CapabilityError("precondition", "v0.1 只支持按下载任务(downloadTaskId)生成整理计划");
|
||||
const dt = await d.ctx.db
|
||||
.selectFrom("download_task")
|
||||
.select(["status"])
|
||||
.where("id", "=", input.downloadTaskId)
|
||||
.executeTakeFirst();
|
||||
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
||||
if (!["completed", "importing", "imported"].includes(dt.status))
|
||||
throw new CapabilityError("precondition", `下载任务尚未完成(${dt.status})`);
|
||||
const action: "copy" | "move" = input.action === "move" ? "move" : "copy";
|
||||
const request = {
|
||||
downloadTaskId: input.downloadTaskId,
|
||||
action,
|
||||
...(input.targetLibraryRootId ? { targetLibraryRootId: input.targetLibraryRootId } : {}),
|
||||
};
|
||||
const res = await planImport(d, {
|
||||
...request,
|
||||
trigger: "agent",
|
||||
actorType: "agent",
|
||||
actorId: ctx.actorId,
|
||||
dryRun: true,
|
||||
});
|
||||
const frozen = res.plan;
|
||||
if (!frozen) {
|
||||
throw new CapabilityError(
|
||||
"precondition",
|
||||
res.recordId
|
||||
? `该下载任务已有进行中的整理记录 ${res.recordId}`
|
||||
: `无法生成整理计划:${res.reason ?? res.status}`,
|
||||
);
|
||||
}
|
||||
const entity = await d.ctx.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["kind"])
|
||||
.where("id", "=", frozen.entityId)
|
||||
.executeTakeFirst();
|
||||
const payloadHash = planHash(request, frozen);
|
||||
const art = await d.ctx.db
|
||||
.insertInto("artifact")
|
||||
.values({
|
||||
kind: "plan",
|
||||
agent_run_id: ctx.agentRunId && isUuid(ctx.agentRunId) ? ctx.agentRunId : null,
|
||||
subject_type: "download_task",
|
||||
subject_id: input.downloadTaskId,
|
||||
title: "organize_plan",
|
||||
content: jsonb({ request, plan: frozen, mediaEntityId: frozen.entityId, mediaKind: entity?.kind ?? null }),
|
||||
content_hash: payloadHash,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const org = frozen.organize;
|
||||
return {
|
||||
planId: art.id,
|
||||
payloadHash,
|
||||
files: org.files.map((f) => ({
|
||||
sourcePath: f.src,
|
||||
targetPath: f.action === "skip" || f.action === "hold" ? null : f.dst,
|
||||
action,
|
||||
skipReason: f.skipReason ?? (f.action === "hold" ? "hold" : null),
|
||||
})),
|
||||
conflicts: org.conflicts.map((c) => ({
|
||||
code: c.kind,
|
||||
message: c.detail ?? c.kind,
|
||||
path: c.dst ?? c.src ?? null,
|
||||
})),
|
||||
requiresApproval: org.requiresApproval,
|
||||
};
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("organize.execute"), async (input, ctx) => {
|
||||
const d = deps();
|
||||
const db = d.ctx.db;
|
||||
const art = await db
|
||||
.selectFrom("artifact")
|
||||
.selectAll()
|
||||
.where("id", "=", input.planId)
|
||||
.where("kind", "=", "plan")
|
||||
.where("title", "=", "organize_plan")
|
||||
.executeTakeFirst();
|
||||
if (!art) throw new CapabilityError("not_found", "整理计划不存在");
|
||||
if (art.content_hash !== input.payloadHash) throw new CapabilityError("conflict", "payloadHash 与计划不一致");
|
||||
const content = art.content as {
|
||||
request: { downloadTaskId: string; action: "copy" | "move"; targetLibraryRootId?: string };
|
||||
};
|
||||
const req = content.request;
|
||||
// 已执行过(同计划)→ 返回已有记录
|
||||
const taskKey = `organize-plan:${art.id}`;
|
||||
const existing = await db
|
||||
.selectFrom("task")
|
||||
.select(["id"])
|
||||
.where("kind", "=", "verifyAndImport")
|
||||
.where("idempotency_key", "=", taskKey)
|
||||
.executeTakeFirst();
|
||||
if (existing) {
|
||||
const rec = await db
|
||||
.selectFrom("organization_record")
|
||||
.selectAll()
|
||||
.where("task_id", "=", existing.id)
|
||||
.executeTakeFirst();
|
||||
return { taskId: existing.id, planId: art.id, record: rec ? (toApi(rec) as never) : null };
|
||||
}
|
||||
// 执行前复核:重新冻结一次,与审批/授权时看到的计划一致才执行
|
||||
const again = await planImport(d, {
|
||||
...req,
|
||||
trigger: "agent",
|
||||
actorType: "agent",
|
||||
actorId: ctx.actorId,
|
||||
dryRun: true,
|
||||
});
|
||||
if (!again.plan || planHash(req, again.plan) !== art.content_hash)
|
||||
throw new CapabilityError("conflict", "源文件或目标已变化,计划失效,请重新生成整理计划");
|
||||
const taskId = await ensureTaskRow(db, {
|
||||
kind: "verifyAndImport",
|
||||
idempotencyKey: taskKey,
|
||||
input: { ...req, planId: art.id },
|
||||
subjectType: "download_task",
|
||||
subjectId: req.downloadTaskId,
|
||||
triggeredBy: "agent",
|
||||
});
|
||||
let res: Awaited<ReturnType<typeof planImport>>;
|
||||
try {
|
||||
res = await planImport(d, { ...req, trigger: "agent", actorType: "agent", actorId: ctx.actorId, taskId });
|
||||
} catch (err) {
|
||||
await markTask(db, taskId, "failed", { error: errMessage(err) });
|
||||
throw new CapabilityError("precondition", errMessage(err));
|
||||
}
|
||||
if (res.recordId && (res.status === "running" || res.status === "queued")) {
|
||||
const { runId } = await d.ctx.engine.enqueue(
|
||||
"verifyAndImport",
|
||||
{ recordId: res.recordId, downloadTaskId: req.downloadTaskId, taskId },
|
||||
{ idempotencyKey: `verifyAndImport:${taskKey}`, traceId: taskId },
|
||||
);
|
||||
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
|
||||
await db
|
||||
.updateTable("organization_record")
|
||||
.set({ task_id: taskId })
|
||||
.where("id", "=", res.recordId)
|
||||
.where("task_id", "is", null)
|
||||
.execute();
|
||||
} else {
|
||||
await markTask(db, taskId, "failed", { error: res.reason ?? res.status });
|
||||
}
|
||||
const rec = res.recordId
|
||||
? await db.selectFrom("organization_record").selectAll().where("id", "=", res.recordId).executeTakeFirst()
|
||||
: undefined;
|
||||
return { taskId, planId: art.id, record: rec ? (toApi(rec) as never) : null };
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("policy.preview"), async (input) => {
|
||||
const d = deps();
|
||||
const cur = await d.ctx.db
|
||||
.selectFrom("policy_revision")
|
||||
.selectAll()
|
||||
.where("id", "=", input.policyRevisionId)
|
||||
.executeTakeFirst();
|
||||
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
|
||||
const draft = await previewPolicy(d, cur.rules);
|
||||
const published = (await publishedAcquisitionPolicies(d.ctx.db)).find(
|
||||
(p) => p.row.policy_key === cur.policy_key && p.row.id !== cur.id,
|
||||
);
|
||||
const base = published ? await previewPolicy(d, published.row.rules) : null;
|
||||
const before = new Map((base?.samples ?? []).map((s) => [`${s.sourceReleaseId}`, s.decision]));
|
||||
return {
|
||||
newlyMatched: Math.max(0, draft.matched - (base?.matched ?? 0)),
|
||||
newlyExcluded: Math.max(0, draft.excluded - (base?.excluded ?? 0)),
|
||||
samples: draft.samples.slice(0, input.sampleLimit).map((s) => ({
|
||||
sourceReleaseId: s.sourceReleaseId,
|
||||
before: before.get(s.sourceReleaseId) ?? "none",
|
||||
after: s.decision,
|
||||
reason: `template:${s.template}`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
registry.register(capabilitySpec("policy.apply"), async (input, ctx) => {
|
||||
const d = deps();
|
||||
const row = await d.ctx.db.transaction().execute(async (trx) => {
|
||||
const cur = await trx
|
||||
.selectFrom("policy_revision")
|
||||
.selectAll()
|
||||
.where("id", "=", input.policyRevisionId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!cur) throw new CapabilityError("not_found", "策略版本不存在");
|
||||
if (cur.status === "published") return cur; // 幂等:已发布
|
||||
if (cur.revision !== input.expectedRevision)
|
||||
throw new CapabilityError("conflict", `revision 不匹配(当前 ${cur.revision})`);
|
||||
if (cur.status !== "draft") throw new CapabilityError("precondition", `只能发布草案(当前 ${cur.status})`);
|
||||
return publishRevision(trx, cur.id, cur.policy_key, ctx.actorId, "superseded");
|
||||
});
|
||||
return { policy: toApi(row) as never };
|
||||
});
|
||||
}
|
||||
|
||||
/** 整理计划指纹:请求 + 冻结的文件动作/目标/前置条件(源大小变化、目标出现都会改变指纹) */
|
||||
function planHash(request: object, frozen: FrozenPlan): string {
|
||||
const o = frozen.organize;
|
||||
return hashJson({
|
||||
request,
|
||||
entityId: frozen.entityId,
|
||||
root: frozen.libraryRootId,
|
||||
files: o.files.map((f) => [f.src, f.dst, f.size, f.action, f.skipReason ?? null]),
|
||||
conflicts: o.conflicts.map((c) => [c.kind, c.src ?? null, c.dst ?? null, c.blocking]),
|
||||
preconditions: o.preconditions,
|
||||
});
|
||||
}
|
||||
|
||||
const isUuid = (s: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type EventPayload, EventType } from "@umt/contracts";
|
||||
import type { ServerContext, ServerModule } from "../types.ts";
|
||||
import { registerAoCapabilities } from "./capabilities.ts";
|
||||
import { registerAoTasks } from "./handlers.ts";
|
||||
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
|
||||
import { registerDashboardRoutes } from "./routes/dashboard.ts";
|
||||
@@ -70,6 +71,9 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
||||
registerTasks(registry, ctx) {
|
||||
registerAoTasks(registry, lazy(ctx));
|
||||
},
|
||||
registerCapabilities(registry, ctx) {
|
||||
registerAoCapabilities(registry, () => lazy(ctx));
|
||||
},
|
||||
outboxRoutes: () => ({
|
||||
[EventType.releaseResolved]: (ev) => {
|
||||
const p = ev.payload as EventPayload<"release.resolved">;
|
||||
|
||||
@@ -639,7 +639,7 @@ async function suppress(trx: Transaction<DB>, tagId: string, fingerprint: string
|
||||
}
|
||||
|
||||
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
|
||||
async function publishRevision(
|
||||
export async function publishRevision(
|
||||
trx: Transaction<DB>,
|
||||
id: string,
|
||||
policyKey: string,
|
||||
@@ -684,7 +684,7 @@ async function publishRevision(
|
||||
}
|
||||
|
||||
/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
|
||||
async function previewPolicy(d: AoDeps, rules: unknown) {
|
||||
export async function previewPolicy(d: AoDeps, rules: unknown) {
|
||||
const r = AcquisitionPolicyRules.safeParse(rules);
|
||||
const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
|
||||
const rels = await d.ctx.db
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface EvaluateInput {
|
||||
acquisitionSlotId?: string | undefined;
|
||||
/** release.resolved 带来的内容单元 */
|
||||
contentUnitIds?: string[] | undefined;
|
||||
/**
|
||||
* 只生成草案计划(download.plan 能力):status=draft,不预留 Slot、不计入预算、不入队提交;
|
||||
* 由 download.submit 在授权通过后转为 reserved 再提交。
|
||||
*/
|
||||
draft?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface EvaluateOutcome {
|
||||
@@ -216,6 +221,7 @@ interface Ctx {
|
||||
d: AoDeps;
|
||||
now: Date;
|
||||
out: EvaluateOutcome;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
|
||||
@@ -223,7 +229,7 @@ async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
|
||||
}
|
||||
|
||||
export async function evaluateAcquisition(d: AoDeps, input: EvaluateInput): Promise<EvaluateOutcome> {
|
||||
const c: Ctx = { d, now: d.ports.now(), out: { evaluated: 0, plans: [], skipped: [] } };
|
||||
const c: Ctx = { d, now: d.ports.now(), out: { evaluated: 0, plans: [], skipped: [] }, draft: input.draft === true };
|
||||
const db = d.ctx.db;
|
||||
if (input.sourceReleaseId) {
|
||||
const rel = await db
|
||||
@@ -488,6 +494,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
rejected: [],
|
||||
reasons: flat.reasons,
|
||||
});
|
||||
if (c.draft) continue;
|
||||
await enqueueTracked(d, {
|
||||
kind: "submitDownload",
|
||||
idempotencyKey: `plan:${planId}`,
|
||||
@@ -593,6 +600,54 @@ async function reserve(
|
||||
savePath: p.savePath,
|
||||
mode: p.mode,
|
||||
};
|
||||
const evaluation = jsonb({
|
||||
...toContractSlotEvaluation(p.ev),
|
||||
mode: p.mode,
|
||||
instanceId: p.instanceId,
|
||||
volumeKey: p.volumeKey,
|
||||
savePath: p.savePath,
|
||||
});
|
||||
if (c.draft) {
|
||||
// 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1
|
||||
const key = `draft:${p.slot.id}:${p.rel.id}`;
|
||||
const hit = await d.ctx.db
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["id", "status"])
|
||||
.where("idempotency_key", "=", key)
|
||||
.executeTakeFirst();
|
||||
if (hit) {
|
||||
if (hit.status !== "draft") return null;
|
||||
await d.ctx.db
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ evaluation, payload_hash: hashJson(payload), expected_revision: p.slot.revision + 1, priority })
|
||||
.where("id", "=", hit.id)
|
||||
.execute();
|
||||
return hit.id;
|
||||
}
|
||||
const row = await d.ctx.db
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: p.slot.id,
|
||||
source_release_id: p.rel.id,
|
||||
purpose: p.mode === "upgrade" ? "upgrade" : "fill",
|
||||
status: "draft",
|
||||
priority,
|
||||
evaluation,
|
||||
reserved_bytes: p.rel.size_bytes ?? 0,
|
||||
budget_reservation: jsonb({
|
||||
bytes: p.rel.size_bytes,
|
||||
countsAsNewTitle: p.mode === "new_title",
|
||||
day: startOfDay(now).toISOString().slice(0, 10),
|
||||
}),
|
||||
payload_hash: hashJson(payload),
|
||||
expected_revision: p.slot.revision + 1,
|
||||
idempotency_key: key,
|
||||
})
|
||||
.onConflict((oc) => oc.column("idempotency_key").doNothing())
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
return row?.id ?? null;
|
||||
}
|
||||
try {
|
||||
return await d.ctx.db.transaction().execute(async (trx) => {
|
||||
const slot = await trx
|
||||
@@ -613,13 +668,7 @@ async function reserve(
|
||||
purpose: p.mode === "upgrade" ? "upgrade" : "fill",
|
||||
status: "reserved",
|
||||
priority,
|
||||
evaluation: jsonb({
|
||||
...toContractSlotEvaluation(p.ev),
|
||||
mode: p.mode,
|
||||
instanceId: p.instanceId,
|
||||
volumeKey: p.volumeKey,
|
||||
savePath: p.savePath,
|
||||
}),
|
||||
evaluation,
|
||||
reserved_bytes: p.rel.size_bytes ?? 0,
|
||||
budget_reservation: jsonb({
|
||||
bytes: p.rel.size_bytes,
|
||||
|
||||
Reference in new issue
Block a user