PR 4 du chantier admin. Board de sante temps reel des dependances.
- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
"tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
ne throw jamais) appele au boot (index.ts), a chaque tick et apres
chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
tech-step-intent-service (/health), et le worker via son heartbeat.
Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
apps/admin-web/cypress/{screenshots,videos,downloads}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { env } from "./config.js";
|
|
|
|
/**
|
|
* Thin fetch wrapper around `apps/api`'s `/internal/tech-steps/*` and
|
|
* `/reference/tech-steps` — the only two surfaces this worker ever talks
|
|
* to (see `tech-step-worker.service.ts`/`tech-step-worker.routes.ts` on
|
|
* that side). No Prisma client, no direct database access at all: every
|
|
* read/write goes through here, over HTTP, authenticated with
|
|
* `INTERNAL_WORKER_SECRET` — see `requireInternalWorker`
|
|
* (`apps/api/src/middlewares/require-internal-worker.ts`) for why that's a
|
|
* separate mechanism from a user session.
|
|
*/
|
|
|
|
/** One reference technique, as returned by the public `GET /reference/tech-steps` — this worker's only source of the taxonomy it audits/labels against, never a hardcoded copy (see `tech-step-taxonomy.ts`). */
|
|
export interface TechStepReference {
|
|
id: number;
|
|
key: string;
|
|
}
|
|
|
|
/** Mirrors `TechStepAuditClauseView` (`packages/shared`) — duplicated here rather than importing from `@batch-cooking/shared`, since this worker deliberately lives outside the pnpm workspace (see `package.json`'s own doc comment) and so can't depend on a workspace package. */
|
|
export interface AuditClause {
|
|
stepId: number;
|
|
recipeId: number;
|
|
clauseText: string;
|
|
anchorKey: string | null;
|
|
intentKey: string | null;
|
|
score: number;
|
|
locale: string;
|
|
}
|
|
|
|
/** Mirrors `PendingTechStepCorrectionView` (`packages/shared`) — same "duplicated, not imported" reasoning as {@link AuditClause}. */
|
|
export interface PendingCorrection {
|
|
id: number;
|
|
stepId: number;
|
|
recipeId: number;
|
|
clauseText: string;
|
|
start: number;
|
|
end: number;
|
|
previousTechStepKey: string | null;
|
|
correctedTechStepKey: string | null;
|
|
}
|
|
|
|
/** One suggestion `postTrainingSuggestions` submits — mirrors one entry of `SubmitTrainingSuggestionsInput["suggestions"]` (`packages/shared`). */
|
|
export interface TrainingSuggestionInput {
|
|
techStepKey: string;
|
|
locale: string;
|
|
suggestedSynonyms: string[];
|
|
suggestedUtterances: string[];
|
|
sourceType: "correction" | "llm_audit";
|
|
sourceCorrectionId?: number | null;
|
|
}
|
|
|
|
async function request<TResponseBody>(
|
|
path: string,
|
|
init: RequestInit = {},
|
|
): Promise<TResponseBody> {
|
|
try {
|
|
const response = await fetch(`${env.API_BASE_URL}${path}`, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Internal-Worker-Secret": env.INTERNAL_WORKER_SECRET,
|
|
...init.headers,
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => "");
|
|
throw new Error(`${init.method ?? "GET"} ${path} failed: ${response.status} ${body}`);
|
|
}
|
|
return (await response.json()) as TResponseBody;
|
|
} catch (err) {
|
|
// Rethrown as-is — every caller (the scheduler's per-job try/catch,
|
|
// see `scheduler.ts`) already decides what to do with a failed run;
|
|
// this is just the one place the `await` itself has to sit inside a
|
|
// try/catch, same convention `apps/api` follows for the same reason.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** The full reference technique catalog — `apps/api`'s `TechStep` table, read fresh (never cached beyond one process's lifetime) so a catalog change is picked up on the worker's next restart without a code change here. */
|
|
export function getTechStepReference(): Promise<TechStepReference[]> {
|
|
return request("/reference/tech-steps");
|
|
}
|
|
|
|
/** Low-confidence clauses sampled from existing recipes — `audit-low-confidence`'s raw material. */
|
|
export function getAuditBatch(locale: string, limit: number): Promise<AuditClause[]> {
|
|
const params = new URLSearchParams({ locale, limit: String(limit) });
|
|
return request(`/internal/tech-steps/audit-batch?${params}`);
|
|
}
|
|
|
|
/** Corrections not yet turned into a suggestion — `transform-corrections`'s raw material. */
|
|
export function getPendingCorrections(limit: number): Promise<PendingCorrection[]> {
|
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
return request(`/internal/tech-steps/pending-corrections?${params}`);
|
|
}
|
|
|
|
/** Payload of `POST /internal/tech-steps/heartbeat` — mirrors `workerHeartbeatSchema` (`packages/shared`), duplicated here for the same "outside the workspace" reason as {@link AuditClause}. */
|
|
export interface HeartbeatPayload {
|
|
event: "boot" | "tick" | "job";
|
|
job?: string;
|
|
ok?: boolean;
|
|
counts?: Record<string, number>;
|
|
}
|
|
|
|
/**
|
|
* Best-effort liveness ping to `apps/api` so the admin monitoring board can
|
|
* see this worker (which has no inbound HTTP surface of its own). Sent on
|
|
* boot, on every scheduler tick, and after each job. **Never throws** — a
|
|
* failed heartbeat must never break or abort a run; it's logged and
|
|
* swallowed here.
|
|
*/
|
|
export async function postHeartbeat(payload: HeartbeatPayload): Promise<void> {
|
|
try {
|
|
await request("/internal/tech-steps/heartbeat", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
console.warn(
|
|
"[tech-step-llm-worker] heartbeat failed:",
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Submits a batch of suggestions — a no-op (resolves immediately) if `suggestions` is empty, so a job with nothing to report doesn't need its own guard at every call site. */
|
|
export function postTrainingSuggestions(
|
|
suggestions: TrainingSuggestionInput[],
|
|
): Promise<{ created: number }> {
|
|
if (suggestions.length === 0) return Promise.resolve({ created: 0 });
|
|
return request("/internal/tech-steps/training-suggestions", {
|
|
method: "POST",
|
|
body: JSON.stringify({ suggestions }),
|
|
});
|
|
}
|