(dimension === "aimock_wiring") return aimockInvoker;
// Other cron-only dimensions require an external webhook trigger to
// supply signal data. See diffCronSchedules JSDoc for rationale.
return null;
};
}
/**
* Hydrate scheduler `lastRun*` bookkeeping from the PocketBase `probe_runs`
* collection at boot time so the dashboard doesn't show "never run" for
* probes that haven't ticked since the last restart.
*
* Non-fatal: PB being down at boot must NOT prevent the orchestrator from
* starting. Individual fetch failures are logged at warn level and skipped.
*/
export async function hydrateProbeLastRuns(deps: {
scheduler: Scheduler;
runWriter: ProbeRunWriter;
logger: Logger;
}): Promise<void> {
const { scheduler, runWriter, logger: log } = deps;
const entries = scheduler.list();
const probeIds = entries
.filter((e) => e.id.startsWith("probe:"))
.map((e) => e.id);
const results = await Promise.allSettled(
probeIds.map(async (schedulerId) => {
const probeId = schedulerId.slice("probe:".length);
const runs = await runWriter.recent(probeId, 1);
return { schedulerId, runs };
}),
);
let seeded = 0;
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "rejected") {
log.warn("orchestrator.hydrate-lastrun-failed", {
probeId: probeIds[i],
err: String(result.reason),
});
continue;
}
const { schedulerId, runs } = result.value;
if (runs.length === 0) continue;
const run = runs[0];
if (!run.finishedAt || run.durationMs === null) continue;
const startedMs = Date.parse(run.startedAt);
const finishedMs = Date.parse(run.finishedAt);
if (!Number.isFinite(startedMs) || !Number.isFinite(finishedMs)) continue;
scheduler.seedEntryLastRun(schedulerId, {
startedAt: startedMs,
finishedAt: finishedMs,
durationMs: run.durationMs,
summary: run.summary ?? null,
});
seeded++;
}
if (seeded > 0) {
log.info("orchestrator.hydrate-lastrun", {
seeded,
total: probeIds.length,
});
}
}
/**
* Minimal Railway GraphQL adapter used by the aimock-wiring probe.
* Lists services in a project and fetches per-service env-var values
* for a given environment. Endpoint: https://backboard.railway.app/graphql/v2.
*
* Routes through the shared `makeGql` helper exported from
* `probes/discovery/railway-services.ts` so error taxonomy (Auth /
* Backend / Schema / Transport class hierarchy) and partial-success
* envelope handling stay aligned with the discovery source. Pre-fix,
* the orchestrator's inline gql threw on any non-empty `errors[]` even
* when `data` was present, and surfaced raw `SyntaxError` from
* `res.json()` on HTML edge-proxy error pages — both diverged from
* makeGql's behaviour.
*
* `listServices()` is TTL-cached for 60s (`cachedListServices`): the
* intra-tick fan-out (N `getServiceEnv()` calls) collapses into one
* GraphQL roundtrip, while cross-tick reads stay fresh at cron cadence
* so renamed/added Railway services surface without orchestrator restart.
*
* Exported for unit-test access. Production callers go through
* `buildCronProbeResolver`.
*/
export function createRailwayAdapter(
opts: {
token: string;
projectId: string;
environmentId: string;
},
deps: { fetchImpl?: typeof fetch } = {},
): {
listServices: () => Promise<{ name: string; id: string }[]>;
getServiceEnv: (name: string) => Promise<Record<string, string | undefined>>;
}
| 2313 | |
| 2314 | return (dimension: string) => { |
| 2315 | if (dimension === "aimock_wiring") return aimockInvoker; |
| 2316 | // Other cron-only dimensions require an external webhook trigger to |
| 2317 | // supply signal data. See diffCronSchedules JSDoc for rationale. |
| 2318 | return null; |
| 2319 | }; |
| 2320 | } |
| 2321 | |
| 2322 | /** |
| 2323 | * Hydrate scheduler `lastRun*` bookkeeping from the PocketBase `probe_runs` |
| 2324 | * collection at boot time so the dashboard doesn't show "never run" for |
| 2325 | * probes that haven't ticked since the last restart. |
| 2326 | * |
| 2327 | * Non-fatal: PB being down at boot must NOT prevent the orchestrator from |
| 2328 | * starting. Individual fetch failures are logged at warn level and skipped. |
| 2329 | */ |
| 2330 | export async function hydrateProbeLastRuns(deps: { |
| 2331 | scheduler: Scheduler; |
| 2332 | runWriter: ProbeRunWriter; |
| 2333 | logger: Logger; |
| 2334 | }): Promise<void> { |
| 2335 | const { scheduler, runWriter, logger: log } = deps; |
| 2336 | const entries = scheduler.list(); |
| 2337 | const probeIds = entries |
| 2338 | .filter((e) => e.id.startsWith("probe:")) |
| 2339 | .map((e) => e.id); |
| 2340 | |
| 2341 | const results = await Promise.allSettled( |
| 2342 | probeIds.map(async (schedulerId) => { |
| 2343 | const probeId = schedulerId.slice("probe:".length); |
| 2344 | const runs = await runWriter.recent(probeId, 1); |
| 2345 | return { schedulerId, runs }; |
| 2346 | }), |
| 2347 | ); |
| 2348 | |
| 2349 | let seeded = 0; |
| 2350 | for (let i = 0; i < results.length; i++) { |
| 2351 | const result = results[i]; |
| 2352 | if (result.status === "rejected") { |
| 2353 | log.warn("orchestrator.hydrate-lastrun-failed", { |
| 2354 | probeId: probeIds[i], |
| 2355 | err: String(result.reason), |
| 2356 | }); |
| 2357 | continue; |
| 2358 | } |
| 2359 | const { schedulerId, runs } = result.value; |
| 2360 | if (runs.length === 0) continue; |
| 2361 | const run = runs[0]; |
| 2362 | if (!run.finishedAt || run.durationMs === null) continue; |
| 2363 | const startedMs = Date.parse(run.startedAt); |
| 2364 | const finishedMs = Date.parse(run.finishedAt); |
| 2365 | if (!Number.isFinite(startedMs) || !Number.isFinite(finishedMs)) continue; |
| 2366 | scheduler.seedEntryLastRun(schedulerId, { |
| 2367 | startedAt: startedMs, |
| 2368 | finishedAt: finishedMs, |
| 2369 | durationMs: run.durationMs, |
| 2370 | summary: run.summary ?? null, |
| 2371 | }); |
| 2372 | seeded++; |
nothing calls this directly
no test coverage detected