Fix ClaudeQQ event API artifact contract

This commit is contained in:
Codex
2026-05-21 12:59:54 +00:00
parent 5ebef54936
commit dba4a7fd80
3 changed files with 309 additions and 3 deletions
@@ -0,0 +1,143 @@
import { readFileSync } from "node:fs";
import { rootPath } from "./src/config";
import { runArtifactRegistryCommand } from "./src/artifact-registry";
const serviceId = "claudeqq";
const desiredCommit = "203b1f46684c91340ecbbd8a74502bd55e4f2011";
const sourceRepo = "https://gitee.com/lyon1998/agent_skills";
const dockerfile = "claudeqq/Dockerfile";
const eventPaths = ["/api/events/recent", "/api/events/subscriptions"];
type JsonRecord = Record<string, unknown>;
function assertCondition(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
function asRecord(value: unknown, path: string): JsonRecord {
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${path} must be an object`);
return value as JsonRecord;
}
function asArray(value: unknown, path: string): unknown[] {
assertCondition(Array.isArray(value), `${path} must be an array`);
return value;
}
function stringField(value: unknown, path: string): string {
assertCondition(typeof value === "string" && value.length > 0, `${path} must be a non-empty string`);
return value;
}
function serviceById(environment: JsonRecord, id: string, path: string): JsonRecord {
const services = asArray(environment.services, `${path}.services`).map((item, index) => asRecord(item, `${path}.services[${index}]`));
const service = services.find((item) => item.id === id);
assertCondition(service !== undefined, `${path}.services must include ${id}`);
return service!;
}
function assertDeployJson(): void {
const deploy = asRecord(JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")), "deploy.json");
const environments = asRecord(deploy.environments, "deploy.json.environments");
for (const environment of ["dev", "prod"] as const) {
const service = serviceById(asRecord(environments[environment], `deploy.json.environments.${environment}`), serviceId, `deploy.json.environments.${environment}`);
assertCondition(service.repo === sourceRepo, `${environment} deploy.json claudeqq repo must match source repo`);
assertCondition(service.commitId === desiredCommit, `${environment} deploy.json claudeqq commit must match desired commit`);
}
}
function assertArtifactCatalog(): void {
const catalog = asRecord(JSON.parse(readFileSync(rootPath("CI.json"), "utf8")), "CI.json");
const artifacts = asArray(catalog.artifacts, "CI.json.artifacts").map((item, index) => asRecord(item, `CI.json.artifacts[${index}]`));
const artifact = artifacts.find((item) => item.serviceId === serviceId);
assertCondition(artifact !== undefined, "CI.json must include claudeqq artifact producer");
assertCondition(artifact!.kind === "source-build", "claudeqq artifact must be source-build");
assertCondition(artifact!.status === "supported", "claudeqq artifact must be supported");
assertCondition(artifact!.producer === "ci publish-user-service", "claudeqq artifact producer must be ci publish-user-service");
const source = asRecord(artifact!.source, "CI.json claudeqq source");
assertCondition(source.repo === sourceRepo, "claudeqq artifact source repo must match agent_skills");
assertCondition(source.dockerfile === dockerfile, "claudeqq artifact dockerfile must be claudeqq/Dockerfile");
const image = asRecord(artifact!.image, "CI.json claudeqq image");
assertCondition(image.repository === "unidesk/claudeqq", "claudeqq artifact image repository must be unidesk/claudeqq");
}
function assertAdapterSourceContract(): void {
const adapter = readFileSync(rootPath("src/components/microservices/claudeqq/adapter.js"), "utf8");
const imageDockerfile = readFileSync(rootPath("src/components/microservices/claudeqq/Dockerfile"), "utf8");
for (const eventPath of eventPaths) {
assertCondition(adapter.includes(eventPath), `adapter must expose ${eventPath}`);
}
assertCondition(adapter.includes("claudeqq-event-api-v1"), "adapter health metadata must name the event API contract");
assertCondition(adapter.includes("adapter-readonly-fallback"), "adapter must include read-only event fallback");
assertCondition(adapter.includes("CLAUDEQQ_HOST: upstreamHost"), "adapter must bind the upstream server to the private upstream host");
assertCondition(adapter.includes("CLAUDEQQ_PORT: String(upstreamPort)"), "adapter must bind the upstream server to the private upstream port");
assertCondition(adapter.includes("POST /api/events/subscriptions"), "adapter health metadata must document event subscription mutation paths");
assertCondition(imageDockerfile.includes('CMD ["node", "unidesk-adapter.cjs"]'), "claudeqq image must start the UniDesk adapter by default");
}
async function assertDryRun(environment: "dev" | "prod"): Promise<void> {
const plan = asRecord(await runArtifactRegistryCommand([
"deploy-service",
"--env",
environment,
"--service",
serviceId,
"--commit",
desiredCommit,
"--dry-run",
]), `artifact dry-run ${environment}`);
assertCondition(plan.ok === true, `${environment} dry-run must be ok`);
assertCondition(plan.supported === true, `${environment} dry-run must be supported`);
assertCondition(plan.dryRun === true, `${environment} dry-run must report dryRun=true`);
assertCondition(plan.mutation === false, `${environment} dry-run must not mutate`);
assertCondition(plan.serviceId === serviceId, `${environment} dry-run serviceId must be claudeqq`);
assertCondition(plan.commit === desiredCommit, `${environment} dry-run commit must match desired commit`);
assertCondition(plan.sourceRepo === sourceRepo, `${environment} dry-run source repo must match`);
const source = asRecord(plan.source, `${environment}.source`);
assertCondition(source.repo === sourceRepo, `${environment} dry-run source.repo must match`);
assertCondition(source.commit === desiredCommit, `${environment} dry-run source.commit must match`);
assertCondition(source.dockerfile === dockerfile, `${environment} dry-run source.dockerfile must match`);
const build = asRecord(plan.build, `${environment}.build`);
assertCondition(build.willCompile === false, `${environment} dry-run must not compile on the runtime target`);
assertCondition(build.willRunDockerBuild === false, `${environment} dry-run must not docker build on the runtime target`);
assertCondition(build.willRunDockerComposeBuild === false, `${environment} dry-run must not docker compose build on the runtime target`);
const labels = asRecord(plan.requiredLabels, `${environment}.requiredLabels`);
assertCondition(labels["unidesk.ai/service-id"] === serviceId, `${environment} labels must include service id`);
assertCondition(labels["unidesk.ai/source-repo"] === sourceRepo, `${environment} labels must include source repo`);
assertCondition(labels["unidesk.ai/source-commit"] === desiredCommit, `${environment} labels must include source commit`);
assertCondition(labels["unidesk.ai/dockerfile"] === dockerfile, `${environment} labels must include dockerfile`);
const target = asRecord(plan.target, `${environment}.target`);
assertCondition(target.kind === "d601-k3s", `${environment} target kind must be d601-k3s`);
assertCondition(target.namespace === (environment === "dev" ? "unidesk-dev" : "unidesk"), `${environment} namespace must match`);
assertCondition(target.deployment === (environment === "dev" ? "claudeqq-dev" : "claudeqq"), `${environment} deployment must match`);
assertCondition(target.service === (environment === "dev" ? "claudeqq-dev" : "claudeqq"), `${environment} service must match`);
assertCondition(target.runtimeImage === `unidesk-claudeqq:${desiredCommit}`, `${environment} runtime image must be commit-pinned`);
const validation = asArray(plan.validation, `${environment}.validation`).map((item, index) => stringField(item, `${environment}.validation[${index}]`));
assertCondition(validation.some((line) => line.includes("service health via Kubernetes API service proxy")), `${environment} dry-run must require API service proxy health`);
assertCondition(validation.some((line) => line.includes("source repo")), `${environment} dry-run must require source repo label validation`);
}
assertDeployJson();
assertArtifactCatalog();
assertAdapterSourceContract();
await assertDryRun("dev");
await assertDryRun("prod");
console.log(JSON.stringify({
ok: true,
serviceId,
desiredCommit,
eventPaths,
checks: [
"deploy.json desired dev/prod commit",
"CI.json artifact producer contract",
"adapter read-only event API fallback and health metadata",
"dev/prod artifact-registry deploy-service dry-runs",
],
}, null, 2));
@@ -23,4 +23,4 @@ COPY --from=build /app/unidesk-adapter.cjs ./scripts/src/server_ts/unidesk-adapt
WORKDIR /app/scripts/src/server_ts
RUN npm ci --omit=dev && npm cache clean --force
CMD ["node", "dist/index.js", "--max-restarts", "0"]
CMD ["node", "unidesk-adapter.cjs"]
@@ -1,4 +1,6 @@
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
@@ -10,6 +12,8 @@ const deployCommit = process.env.UNIDESK_DEPLOY_COMMIT || "";
const deployRequestedCommit = process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || deployCommit;
const deployRepo = process.env.UNIDESK_DEPLOY_REPO || "";
const serviceId = process.env.UNIDESK_DEPLOY_SERVICE_ID || "claudeqq";
const workspaceDir = process.env.CLAUDEQQ_WORKSPACE_DIR || "/bot_workspace";
const stateDir = process.env.CLAUDEQQ_STATE_DIR || "/app/.state";
const startedAt = new Date().toISOString();
const endpoints = [
"/health",
@@ -24,7 +28,11 @@ const endpoints = [
const child = spawn("node", ["dist/index.js", "--max-restarts", "0"], {
cwd: process.env.CLAUDEQQ_SERVER_CWD || process.cwd(),
env: process.env,
env: {
...process.env,
CLAUDEQQ_HOST: upstreamHost,
CLAUDEQQ_PORT: String(upstreamPort),
},
stdio: "inherit",
});
@@ -51,6 +59,148 @@ function readBody(req) {
});
}
function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function numberOrUndefined(value) {
const numberValue = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
return Number.isFinite(numberValue) ? numberValue : undefined;
}
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function clampLimit(value) {
const parsed = Number.parseInt(String(value || "50"), 10);
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
return Math.min(parsed, 200);
}
function textFromMessage(message) {
if (typeof message === "string") return message;
if (!Array.isArray(message)) return "";
return message
.map((segment) => {
const data = asRecord(asRecord(segment).data);
if (typeof data.text === "string") return data.text;
return "";
})
.join("");
}
function eventFromRecord(record, fallbackIndex) {
const item = asRecord(record);
const raw = asRecord(item.raw_message || item.raw || item);
const messageId = numberOrUndefined(raw.message_id ?? item.message_id);
const time = numberOrUndefined(raw.time ?? item.time);
const userId = raw.user_id ?? item.user_id;
const groupId = raw.group_id ?? item.group_id;
const eventId = typeof item.id === "string" && item.id.length > 0
? item.id
: [time || "unknown", messageId || fallbackIndex, userId || "unknown", groupId || "private"].join(":");
return {
id: eventId,
type: typeof item.type === "string" ? item.type : "message",
source: typeof item.source === "string" ? item.source : "messages-file",
timestamp: typeof item.timestamp === "string"
? item.timestamp
: time
? new Date(time * 1000).toISOString()
: new Date(0).toISOString(),
messageType: typeof raw.message_type === "string" ? raw.message_type : (groupId ? "group" : "private"),
userId: userId === undefined ? undefined : String(userId),
groupId: groupId === undefined ? undefined : String(groupId),
messageId,
text: textFromMessage(raw.message ?? item.message ?? item.text),
raw,
};
}
function readRecentEvents(searchParams) {
const limit = clampLimit(searchParams.get("limit"));
const since = searchParams.get("since");
const messagesFile = path.join(workspaceDir, "messages", "messages.jsonl");
try {
if (!fs.existsSync(messagesFile)) return [];
const lines = fs.readFileSync(messagesFile, "utf8").split(/\r?\n/).filter(Boolean);
const events = lines
.slice(-Math.max(limit * 2, limit))
.map((line, index) => eventFromRecord(parseJson(line, {}), index))
.filter((event) => !since || event.timestamp > since);
return events.slice(-limit).reverse();
} catch {
return [];
}
}
function readSubscriptions() {
const subscriptionsFile = path.join(stateDir, "event_subscriptions.json");
try {
if (!fs.existsSync(subscriptionsFile)) return [];
const parsed = parseJson(fs.readFileSync(subscriptionsFile, "utf8"), []);
return Array.isArray(parsed) ? parsed.map(asRecord) : [];
} catch {
return [];
}
}
function fallbackEventResponse(pathname, searchParams, upstream) {
if (pathname === "/api/events/recent") {
const events = readRecentEvents(searchParams);
return {
ok: true,
events,
count: events.length,
nextCursor: null,
source: "adapter-readonly-fallback",
degraded: true,
reason: "upstream-event-api-unavailable",
upstream: { ok: upstream.ok, status: upstream.status },
};
}
const subscriptions = readSubscriptions();
return {
ok: true,
subscriptions,
count: subscriptions.length,
source: "adapter-readonly-fallback",
degraded: true,
reason: "upstream-event-api-unavailable",
upstream: { ok: upstream.ok, status: upstream.status },
};
}
function normalizeEventApiBody(pathname, body, upstream) {
const record = asRecord(body);
if (record.ok === true) return record;
if (pathname === "/api/events/recent" && Array.isArray(record.events)) {
return { ...record, ok: true, count: record.events.length, source: "upstream", upstream: { ok: upstream.ok, status: upstream.status } };
}
if (pathname === "/api/events/subscriptions" && Array.isArray(record.subscriptions)) {
return { ...record, ok: true, count: record.subscriptions.length, source: "upstream", upstream: { ok: upstream.ok, status: upstream.status } };
}
return null;
}
async function eventApiRead(res, url) {
const upstream = await upstreamFetch(`${url.pathname}${url.search}`);
if (upstream.ok && typeof upstream.body === "string") {
const normalized = normalizeEventApiBody(url.pathname, parseJson(upstream.body, null), upstream);
if (normalized) {
json(res, 200, normalized);
return;
}
}
json(res, 200, fallbackEventResponse(url.pathname, url.searchParams, upstream));
}
function normalizePushPayload(raw) {
const body = JSON.parse(raw.length > 0 ? raw.toString("utf8") : "{}");
const message = typeof body.message === "string" ? body.message : "";
@@ -70,7 +220,10 @@ function upstreamFetch(path, method = "GET") {
const req = http.request({ host: upstreamHost, port: upstreamPort, path, method, timeout: 2000 }, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => resolve({ ok: (res.statusCode || 500) < 500, status: res.statusCode || 0, body: Buffer.concat(chunks).toString("utf8") }));
res.on("end", () => {
const status = res.statusCode || 0;
resolve({ ok: status >= 200 && status < 300, status, body: Buffer.concat(chunks).toString("utf8") });
});
});
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", (error) => resolve({ ok: false, status: 0, body: String(error?.message || error) }));
@@ -140,6 +293,12 @@ const server = http.createServer(async (req, res) => {
wsPort: statusNapcat.wsPort || 3001,
},
adapter: "unidesk-claudeqq-adapter",
eventApi: {
contract: "claudeqq-event-api-v1",
readonlyFallback: true,
validationPaths: ["/api/events/recent", "/api/events/subscriptions"],
mutationPaths: ["POST /api/events/subscriptions", "DELETE /api/events/subscriptions/{id}"],
},
startedAt,
upstream: { url: `http://${upstreamHost}:${upstreamPort}`, ok: upstreamHealth.ok, status: upstreamHealth.status },
deploy: {
@@ -155,6 +314,10 @@ const server = http.createServer(async (req, res) => {
await proxy({ ...req, method: "POST" }, res, "/api/send/text", body);
return;
}
if ((url.pathname === "/api/events/recent" || url.pathname === "/api/events/subscriptions") && req.method === "GET") {
await eventApiRead(res, url);
return;
}
await proxy(req, res, `${url.pathname}${url.search}`, undefined);
} catch (error) {
json(res, 400, { ok: false, success: false, service: serviceId, error: String(error?.message || error) });