Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c92ca67
Archive v0.9: add audited candidate promotion events
WangEn Oct 1, 2026
497e0e7
Archive v0.9: add explicit candidate promotion workflow
WangEn Oct 1, 2026
e45b7f9
Archive v0.9: register promoted models from existing provenance
WangEn Oct 1, 2026
3971d01
Archive v0.9: require provider API evidence for promotion
WangEn Oct 1, 2026
676ffb8
Archive v0.9: expose controlled discovery inbox API
WangEn Oct 1, 2026
f3a1111
Archive v0.9: add catalog admin API dependency
WangEn Oct 1, 2026
b2a635d
Archive v0.9: wire catalog discovery into API runtime
WangEn Oct 1, 2026
21858ee
Archive v0.9: add server-gated Catalog Inbox actions
WangEn Oct 1, 2026
f954aab
Archive v0.9: add operator Catalog Inbox UI
WangEn Oct 1, 2026
1e16742
Archive v0.9: style Catalog Inbox review workflow
WangEn Oct 1, 2026
a5adf2f
Archive v0.9: link operator Catalog Inbox
WangEn Oct 1, 2026
9685271
Archive v0.9: cover Catalog Inbox control API
WangEn Oct 1, 2026
34d4b62
Archive v0.9: verify explicit promotion provenance and audit
WangEn Oct 1, 2026
d0a49b8
Archive v0.9: validate promotion across audited transition
WangEn Oct 1, 2026
592a540
Archive v0.9: document Candidate Inbox and promotion
WangEn Oct 1, 2026
620088d
Archive v0.9: avoid holding promotion connection across registration
WangEn Oct 1, 2026
ce22000
Archive v0.9: reject registration provenance conflicts
WangEn Oct 1, 2026
136cb61
Archive v0.9: require matched state for promotion audit
WangEn Oct 1, 2026
a826d50
Archive v0.9: expose provider models for reconciliation review
WangEn Oct 1, 2026
cceda45
Archive v0.9: expose same-provider reconciliation models
WangEn Oct 1, 2026
0b6fbc6
Archive v0.9: support match-existing Inbox reconciliation
WangEn Oct 1, 2026
d705698
Archive v0.9: add match-existing review action
WangEn Oct 1, 2026
3b0de30
Archive v0.9: style existing-model reconciliation
WangEn Oct 1, 2026
6a6066e
Archive v0.9: satisfy provider-model control contract
WangEn Oct 1, 2026
68cd2d3
Archive v0.9: scope strict provenance to explicit source reuse
WangEn Oct 1, 2026
003ca5e
Archive v0.9: align promotion evidence with catalog source type
WangEn Oct 1, 2026
5f7c9aa
Archive v0.9: document provider catalog evidence type
WangEn Oct 1, 2026
9cb8fcf
Archive v0.9: preserve bootstrap registration idempotency
WangEn Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"@hono/node-server": "2.1.1",
"@modelapse/control-plane": "*",
"@modelapse/persistence": "*",
"hono": "4.13.8"
"hono": "4.13.8",
"@modelapse/catalog-admin": "*"
},
"devDependencies": {
"vitest": "3.0.8"
Expand Down
179 changes: 179 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import type {
RunRepository,
RunView,
} from "@modelapse/persistence";
import type {
CatalogDiscoveryStatus,
PgCatalogDiscovery,
} from "@modelapse/catalog-admin";

const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
Expand All @@ -24,6 +28,11 @@ type ControlPlanner = Pick<
PgRunPlanner,
"ping" | "listModels" | "listTests" | "plan"
>;
type CatalogDiscoveryRepository = Pick<
PgCatalogDiscovery,
"listCandidates" | "listProviderModels" | "reconcileCandidate" | "promoteCandidate"
>;

type ArchiveRepository = Pick<
PgArchiveRepository,
| "ping"
Expand All @@ -43,6 +52,7 @@ export interface AppDependencies {
readonly jobs?: ControlQueue;
readonly planner?: ControlPlanner;
readonly archive?: ArchiveRepository;
readonly catalogDiscovery?: CatalogDiscoveryRepository;
readonly controlToken?: string;
}

Expand Down Expand Up @@ -372,6 +382,175 @@ export function createApp(deps: AppDependencies) {
return c.json({ run: publicRun(run) });
});

app.get("/v1/control/catalog/discoveries", async (c) => {
if (!deps.catalogDiscovery || !controlToken) {
return c.json({ error: "control_plane_disabled" }, 503);
}
const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken);
if (authIssue) return c.json(controlAuthError(authIssue), 401);

const provider = c.req.query("provider");
const status = c.req.query("status") as CatalogDiscoveryStatus | undefined;
const rawLimit = c.req.query("limit");
if (provider && !PROVIDER_SLUG_RE.test(provider)) {
return c.json({ error: "invalid_provider" }, 400);
}
if (
status &&
!["discovered", "matched", "ignored", "promotion_ready"].includes(status)
) {
return c.json({ error: "invalid_discovery_status" }, 400);
}
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
if (
limit !== undefined &&
(!Number.isInteger(limit) || limit < 1 || limit > 200)
) {
return c.json({ error: "invalid_limit" }, 400);
}

return c.json({
candidates: await deps.catalogDiscovery.listCandidates({
...(provider ? { providerSlug: provider } : {}),
...(status ? { status } : {}),
...(limit !== undefined ? { limit } : {}),
}),
});
});

app.get("/v1/control/catalog/providers/:providerId/models", async (c) => {
if (!deps.catalogDiscovery || !controlToken) {
return c.json({ error: "control_plane_disabled" }, 503);
}
const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken);
if (authIssue) return c.json(controlAuthError(authIssue), 401);

const providerId = c.req.param("providerId");
if (!UUID_RE.test(providerId)) {
return c.json({ error: "invalid_provider_id" }, 400);
}
return c.json({
models: await deps.catalogDiscovery.listProviderModels(providerId),
});
});

app.post("/v1/control/catalog/discoveries/:candidateId/reconcile", async (c) => {
if (!deps.catalogDiscovery || !controlToken) {
return c.json({ error: "control_plane_disabled" }, 503);
}
const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken);
if (authIssue) return c.json(controlAuthError(authIssue), 401);

const candidateId = c.req.param("candidateId");
if (!UUID_RE.test(candidateId)) {
return c.json({ error: "invalid_candidate_id" }, 400);
}
let raw: unknown;
try {
raw = await c.req.json();
} catch {
return c.json({ error: "invalid_json" }, 400);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return c.json({ error: "invalid_reconciliation" }, 400);
}
const body = raw as Record<string, unknown>;
const action = body.action;
const actor = body.actor;
const resolvedModelId = body.resolvedModelId;
const note = body.note;
if (
typeof action !== "string" ||
!["match_existing", "ignore", "mark_promotion_ready", "reopen"].includes(action) ||
typeof actor !== "string" ||
!actor.trim() ||
(resolvedModelId !== undefined &&
(typeof resolvedModelId !== "string" || !UUID_RE.test(resolvedModelId))) ||
(note !== undefined && typeof note !== "string")
) {
return c.json({ error: "invalid_reconciliation" }, 400);
}

try {
const result = await deps.catalogDiscovery.reconcileCandidate({
candidateId,
action: action as "match_existing" | "ignore" | "mark_promotion_ready" | "reopen",
actor,
...(typeof resolvedModelId === "string" ? { resolvedModelId } : {}),
...(typeof note === "string" ? { note } : {}),
});
return c.json(result);
} catch (error) {
return c.json(
{
error: "reconciliation_rejected",
message: error instanceof Error ? error.message : "Reconciliation rejected",
},
409,
);
}
});

app.post("/v1/control/catalog/discoveries/:candidateId/promote", async (c) => {
if (!deps.catalogDiscovery || !controlToken) {
return c.json({ error: "control_plane_disabled" }, 503);
}
const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken);
if (authIssue) return c.json(controlAuthError(authIssue), 401);

const candidateId = c.req.param("candidateId");
if (!UUID_RE.test(candidateId)) {
return c.json({ error: "invalid_candidate_id" }, 400);
}
let raw: unknown;
try {
raw = await c.req.json();
} catch {
return c.json({ error: "invalid_json" }, 400);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return c.json({ error: "invalid_promotion" }, 400);
}
const body = raw as Record<string, unknown>;
const canonicalSlug = body.canonicalSlug;
const marketingName = body.marketingName;
const status = body.status;
const actor = body.actor;
const note = body.note;
if (
typeof canonicalSlug !== "string" ||
!PROVIDER_SLUG_RE.test(canonicalSlug) ||
typeof marketingName !== "string" ||
!marketingName.trim() ||
(status !== undefined && status !== "preview" && status !== "active") ||
typeof actor !== "string" ||
!actor.trim() ||
(note !== undefined && typeof note !== "string")
) {
return c.json({ error: "invalid_promotion" }, 400);
}

try {
const result = await deps.catalogDiscovery.promoteCandidate({
candidateId,
canonicalSlug,
marketingName,
...(status === "preview" || status === "active" ? { status } : {}),
actor,
...(typeof note === "string" ? { note } : {}),
});
return c.json(result, 201);
} catch (error) {
return c.json(
{
error: "promotion_rejected",
message: error instanceof Error ? error.message : "Promotion rejected",
},
409,
);
}
});

app.get("/v1/control/catalog/models", async (c) => {
if (!deps.planner || !controlToken) {
return c.json({ error: "control_plane_disabled" }, 503);
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { serve } from "@hono/node-server";
import { PgCatalogDiscovery } from "@modelapse/catalog-admin";
import {
PgRunJobQueue,
PgRunPlanner,
Expand All @@ -18,13 +19,15 @@ const runs = PgRunRepository.connect(databaseUrl);
const jobs = PgRunJobQueue.connect(databaseUrl);
const planner = PgRunPlanner.connect(databaseUrl);
const archive = PgArchiveRepository.connect(databaseUrl);
const catalogDiscovery = PgCatalogDiscovery.connect(databaseUrl);
const controlToken = process.env.MODELAPSE_CONTROL_TOKEN;
const port = Number(process.env.PORT ?? "3000");
const app = createApp({
runs,
jobs,
planner,
archive,
catalogDiscovery,
...(controlToken ? { controlToken } : {}),
});

Expand All @@ -46,6 +49,7 @@ function shutdown(signal: string): void {
jobs.close(),
planner.close(),
archive.close(),
catalogDiscovery.close(),
]).finally(() => {
if (error) {
console.error(error);
Expand Down
104 changes: 104 additions & 0 deletions apps/api/test/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,107 @@ describe("Run job control API", () => {
});
});
});


describe("Catalog Discovery control API", () => {
const candidateId = "00000000-0000-4000-8000-000000000003";

function candidate() {
return {
id: candidateId,
provider: { id: "p", slug: "deepseek", name: "DeepSeek" },
remoteModelId: "deepseek-next",
firstSeenAt: "2026-10-01T00:00:00.000Z",
lastSeenAt: "2026-10-02T00:00:00.000Z",
latestProviderSnapshotId: null,
observationCount: 2,
status: "discovered" as const,
resolvedModel: null,
resolvedAt: null,
lastSource: {
id: "s",
sourceType: "provider_api",
url: "https://api.deepseek.com/models",
title: "DeepSeek Models API",
retrievedAt: "2026-10-02T00:00:00.000Z",
contentSha256: "a".repeat(64),
},
promotion: null,
latestDecision: null,
};
}

it("keeps discovery reads and writes behind control authentication", async () => {
let reconciled = false;
let promoted = false;
const app = createApp({
runs: baseRuns(),
controlToken: "control-secret",
catalogDiscovery: {
listCandidates: async () => [candidate()],
listProviderModels: async () => [],
reconcileCandidate: async () => {
reconciled = true;
return { eventId: "event", status: "promotion_ready" };
},
promoteCandidate: async () => {
promoted = true;
return {
candidateId,
modelId: "00000000-0000-4000-8000-000000000004",
promotionEventId: "promotion",
reconciliationEventId: "reconciliation",
};
},
},
});

const unauthorized = await app.request("/v1/control/catalog/discoveries");
expect(unauthorized.status).toBe(401);

const listed = await app.request(
"/v1/control/catalog/discoveries?status=discovered",
{ headers: { authorization: "Bearer control-secret" } },
);
expect(listed.status).toBe(200);
await expect(listed.json()).resolves.toMatchObject({
candidates: [{ remoteModelId: "deepseek-next" }],
});

const reconciledResponse = await app.request(
`/v1/control/catalog/discoveries/${candidateId}/reconcile`,
{
method: "POST",
headers: {
authorization: "Bearer control-secret",
"content-type": "application/json",
},
body: JSON.stringify({
action: "mark_promotion_ready",
actor: "web-operator",
}),
},
);
expect(reconciledResponse.status).toBe(200);
expect(reconciled).toBe(true);

const promotedResponse = await app.request(
`/v1/control/catalog/discoveries/${candidateId}/promote`,
{
method: "POST",
headers: {
authorization: "Bearer control-secret",
"content-type": "application/json",
},
body: JSON.stringify({
canonicalSlug: "deepseek-next",
marketingName: "DeepSeek Next",
status: "active",
actor: "web-operator",
}),
},
);
expect(promotedResponse.status).toBe(201);
expect(promoted).toBe(true);
});
});
Loading
Loading