feat(server): W4-A integration endpoints and contract gaps
- POST /acquisition-plans: manual submit via download.plan -> download.submit
(same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
/reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ea4c384664
commit
d3ab54f2bd
34 files changed
+1083
-123
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { type EventEnvelope, EventType } from "@umt/contracts";
|
||||
import { Hono } from "hono";
|
||||
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { Api, apiResponse, type EventEnvelope, type EventReplayPage, EventType } from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { ApiException } from "./errors.ts";
|
||||
|
||||
@@ -79,14 +80,60 @@ export interface SseOptions {
|
||||
keepaliveMs?: number;
|
||||
}
|
||||
|
||||
/** GET /events?after=<seq>(也接受 Last-Event-ID 头) */
|
||||
const REPLAY_DEFAULT_LIMIT = 100;
|
||||
|
||||
/** JSON 补读还是 SSE:带 limit,或 Accept 只要 JSON(不含 text/event-stream)时走 JSON */
|
||||
function wantsJson(accept: string | undefined, limit: string | undefined): boolean {
|
||||
if (limit !== undefined) return true;
|
||||
const a = (accept ?? "").toLowerCase();
|
||||
return a.includes("application/json") && !a.includes("text/event-stream");
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events?after=<seq>(也接受 Last-Event-ID 头):
|
||||
* - 控制台(cookie 会话)用 EventSource 走 SSE 流;
|
||||
* - API Key 调用者用 `GET /events?after=<seq>&limit=<n>` 取 JSON 页(同一 sse_event 表),按 nextAfter 续读。
|
||||
*/
|
||||
export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
|
||||
const app = new Hono();
|
||||
app.get("/events", (c) => {
|
||||
const app = new OpenAPIHono();
|
||||
const def = Api.events.list;
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: def.path,
|
||||
tags: ["events"],
|
||||
summary: def.summary,
|
||||
responses: {
|
||||
200: {
|
||||
description: "JSON 补读页(带 limit 或 Accept: application/json);否则为 text/event-stream",
|
||||
content: {
|
||||
"application/json": { schema: apiResponse(def.response) },
|
||||
"text/event-stream": { schema: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
app.openapi(route, (async (c: Context) => {
|
||||
const raw = c.req.query("after") ?? c.req.header("last-event-id") ?? "0";
|
||||
const after = Number(raw);
|
||||
if (!Number.isInteger(after) || after < 0) throw new ApiException("bad_request", "after 必须是非负整数");
|
||||
|
||||
const limitRaw = c.req.query("limit");
|
||||
if (wantsJson(c.req.header("accept"), limitRaw)) {
|
||||
const limit = limitRaw === undefined ? REPLAY_DEFAULT_LIMIT : Number(limitRaw);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
||||
throw new ApiException("bad_request", "limit 必须是 1–1000 的整数");
|
||||
const r = await bus.since(after, limit + 1);
|
||||
const items = r.events.slice(0, limit);
|
||||
const data: EventReplayPage = {
|
||||
items,
|
||||
nextAfter: items.at(-1)?.seq ?? after,
|
||||
hasMore: r.events.length > limit,
|
||||
latestSeq: r.latestSeq,
|
||||
gap: r.gap,
|
||||
};
|
||||
return c.json({ ok: true as const, data });
|
||||
}
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const queue: EventEnvelope[] = [];
|
||||
let wake: (() => void) | null = null;
|
||||
@@ -140,6 +187,6 @@ export function sseRoutes({ bus, keepaliveMs = 15_000 }: SseOptions) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
}) as never);
|
||||
return app;
|
||||
}
|
||||
Reference in new issue
Block a user