baa-conductor


baa-conductor / apps / status-api / src
im_wower  ·  2026-03-26

service.ts

  1import {
  2  type StatusApiHandler,
  3  type StatusApiRequest,
  4  type StatusApiResponse,
  5  type StatusApiRoute,
  6  type StatusSnapshotLoader
  7} from "./contracts.js";
  8import { renderStatusPage } from "./render.js";
  9
 10const JSON_HEADERS = {
 11  "content-type": "application/json; charset=utf-8",
 12  "cache-control": "no-store"
 13} as const;
 14
 15const HTML_HEADERS = {
 16  "content-type": "text/html; charset=utf-8",
 17  "cache-control": "no-store"
 18} as const;
 19
 20const TEXT_HEADERS = {
 21  "content-type": "text/plain; charset=utf-8",
 22  "cache-control": "no-store"
 23} as const;
 24
 25type StatusApiRouteId = "describe" | "healthz" | "status" | "ui";
 26
 27type StatusApiRouteDefinition = StatusApiRoute & {
 28  id: StatusApiRouteId;
 29};
 30
 31export interface StatusApiHandlerOptions {
 32  truthSourceBaseUrl?: string;
 33  controlApiBase?: string;
 34  publicBaseUrl?: string;
 35  version?: string;
 36}
 37
 38const STATUS_API_ROUTE_DEFINITIONS: ReadonlyArray<StatusApiRouteDefinition> = [
 39  {
 40    id: "describe",
 41    method: "GET",
 42    path: "/describe",
 43    summary: "读取状态服务自描述 JSON",
 44    contentType: "application/json"
 45  },
 46  {
 47    id: "healthz",
 48    method: "GET",
 49    path: "/healthz",
 50    summary: "状态服务健康检查",
 51    contentType: "text/plain"
 52  },
 53  {
 54    id: "status",
 55    method: "GET",
 56    path: "/v1/status",
 57    summary: "读取全局自动化状态快照",
 58    contentType: "application/json"
 59  },
 60  {
 61    id: "ui",
 62    method: "GET",
 63    path: "/v1/status/ui",
 64    aliases: ["/", "/ui"],
 65    summary: "读取最小 HTML 状态面板",
 66    contentType: "text/html"
 67  }
 68];
 69
 70const STATUS_API_ROUTE_LOOKUP = createStatusApiRouteLookup();
 71
 72export const STATUS_API_ROUTES: StatusApiRoute[] = STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
 73  method: route.method,
 74  path: route.path,
 75  summary: route.summary,
 76  contentType: route.contentType,
 77  ...(route.aliases == null ? {} : { aliases: [...route.aliases] })
 78}));
 79
 80export function describeStatusApiSurface(): string[] {
 81  return STATUS_API_ROUTE_DEFINITIONS.map((route) => {
 82    const paths = [route.path, ...(route.aliases ?? [])].join(", ");
 83
 84    return `${route.method} ${paths} - ${route.summary}`;
 85  });
 86}
 87
 88export function createStatusApiHandler(
 89  snapshotLoader: StatusSnapshotLoader,
 90  options: StatusApiHandlerOptions = {}
 91): StatusApiHandler {
 92  return {
 93    routes: STATUS_API_ROUTES,
 94    handle: (request) => handleStatusApiRequest(request, snapshotLoader, options)
 95  };
 96}
 97
 98export async function handleStatusApiRequest(
 99  request: StatusApiRequest,
100  snapshotLoader: StatusSnapshotLoader,
101  options: StatusApiHandlerOptions = {}
102): Promise<StatusApiResponse> {
103  const method = request.method.toUpperCase();
104  const path = normalizePath(request.path);
105
106  if (method !== "GET") {
107    return jsonResponse(
108      405,
109      {
110        ok: false,
111        error: "method_not_allowed",
112        message: "Status API is read-only and only accepts GET requests."
113      },
114      { Allow: "GET" }
115    );
116  }
117
118  const route = resolveStatusApiRoute(path);
119
120  if (route == null) {
121    return jsonResponse(404, {
122      ok: false,
123      error: "not_found",
124      message: `No status route matches "${path}".`
125    });
126  }
127
128  switch (route.id) {
129    case "describe":
130      return jsonResponse(200, {
131        ok: true,
132        data: buildStatusApiDescribeData(options)
133      });
134
135    case "healthz":
136      return {
137        status: 200,
138        headers: { ...TEXT_HEADERS },
139        body: "ok"
140      };
141
142    case "status":
143      return jsonResponse(200, {
144        ok: true,
145        data: await snapshotLoader.loadSnapshot()
146      });
147
148    case "ui":
149      return {
150        status: 200,
151        headers: { ...HTML_HEADERS },
152        body: renderStatusPage(await snapshotLoader.loadSnapshot())
153      };
154  }
155}
156
157function buildStatusApiDescribeData(options: StatusApiHandlerOptions): Record<string, unknown> {
158  const processInfo = getProcessInfo();
159  const truthSourceBaseUrl = options.truthSourceBaseUrl ?? options.controlApiBase ?? "http://100.71.210.78:4317";
160
161  return {
162    name: "baa-conductor-status-api",
163    version: resolveStatusApiVersion(options.version),
164    description:
165      "Read-only compatibility status view service. It does not own conductor truth; new callers should prefer conductor /v1/status while this service preserves the legacy local status-api contract.",
166    pid: processInfo.pid,
167    uptime_sec: processInfo.uptimeSec,
168    cwd: processInfo.cwd,
169    truth_source: {
170      summary: "Current truth comes from conductor /v1/system/state.",
171      type: "conductor-api",
172      base_url: truthSourceBaseUrl,
173      endpoint: "/v1/system/state"
174    },
175    endpoints: STATUS_API_ROUTE_DEFINITIONS.map((route) => ({
176      method: route.method,
177      path: route.path,
178      aliases: route.aliases ?? [],
179      summary: route.summary,
180      content_type: route.contentType
181    })),
182    responses: {
183      "/healthz": "plain text ok",
184      "/v1/status": "{ ok, data } JSON snapshot",
185      "/v1/status/ui": "HTML status panel",
186      "/describe": "{ ok, data } JSON service description"
187    },
188    examples: [
189      `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/describe'`,
190      `curl '${options.publicBaseUrl ?? "https://conductor.makefile.so"}/v1/status'`
191    ],
192    notes: [
193      "Status API is read-only.",
194      "Preferred entry lives on conductor: /v1/status and /v1/status/ui.",
195      "Default truth source comes from BAA_CONDUCTOR_LOCAL_API.",
196      "Use BAA_CONTROL_API_BASE only for legacy ad-hoc compatibility overrides."
197    ]
198  };
199}
200
201function resolveStatusApiVersion(value: string | undefined): string {
202  if (value == null || value.trim() === "") {
203    return "dev";
204  }
205
206  return value.trim();
207}
208
209function getProcessInfo(): { cwd: string | null; pid: number | null; uptimeSec: number | null } {
210  const runtimeProcess = (typeof process !== "undefined" ? process : undefined) as
211    | {
212        cwd?: () => string;
213        pid?: number;
214        uptime?: () => number;
215      }
216    | undefined;
217
218  return {
219    pid: typeof runtimeProcess?.pid === "number" ? runtimeProcess.pid : null,
220    uptimeSec: typeof runtimeProcess?.uptime === "function" ? Math.floor(runtimeProcess.uptime()) : null,
221    cwd: typeof runtimeProcess?.cwd === "function" ? runtimeProcess.cwd() : null
222  };
223}
224
225function jsonResponse(
226  status: number,
227  payload: unknown,
228  extraHeaders: Record<string, string> = {}
229): StatusApiResponse {
230  return {
231    status,
232    headers: {
233      ...JSON_HEADERS,
234      ...extraHeaders
235    },
236    body: `${JSON.stringify(payload, null, 2)}\n`
237  };
238}
239
240function normalizePath(value: string): string {
241  const baseUrl = "http://status.local";
242  const url = new URL(value || "/", baseUrl);
243  const normalized = url.pathname.replace(/\/+$/, "");
244
245  return normalized === "" ? "/" : normalized;
246}
247
248function createStatusApiRouteLookup(): Map<string, StatusApiRouteDefinition> {
249  const lookup = new Map<string, StatusApiRouteDefinition>();
250
251  for (const route of STATUS_API_ROUTE_DEFINITIONS) {
252    lookup.set(route.path, route);
253
254    for (const alias of route.aliases ?? []) {
255      lookup.set(alias, route);
256    }
257  }
258
259  return lookup;
260}
261
262function resolveStatusApiRoute(path: string): StatusApiRouteDefinition | null {
263  return STATUS_API_ROUTE_LOOKUP.get(path) ?? null;
264}