feat(observatory): expose attempt-bound recorded progress
This commit is contained in:
@@ -26,6 +26,7 @@ export interface ObservatoryRecordedJob {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly claimGeneration: number;
|
||||
readonly state: ObservatoryRecordedJobState;
|
||||
readonly restartFromZero: boolean;
|
||||
readonly resultId: string | null;
|
||||
@@ -229,6 +230,7 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
sourceSessionId: text(source.session_id, "source.session_id"),
|
||||
setupId: text(setup.setup_id, "setup.setup_id"),
|
||||
definitionSha256: String(setup.definition_sha256),
|
||||
claimGeneration: nonNegativeInteger(row.claim_generation, "claim_generation"),
|
||||
state,
|
||||
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
|
||||
resultId: result === null ? null : text(result.result_id, "result.result_id"),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ObservatoryRecordedJob } from "./recordedJobs";
|
||||
|
||||
const PHASES = {
|
||||
"source-transfer": "Передаём входные данные",
|
||||
"source-preparation": "Подготавливаем вход",
|
||||
computing: "Обрабатываем запись",
|
||||
"result-assembly": "Собираем результат",
|
||||
"result-transfer": "Передаём результат",
|
||||
} as const;
|
||||
const UNITS = { frames: "кадров", members: "файлов", steps: "операций" } as const;
|
||||
type Phase = keyof typeof PHASES;
|
||||
type Unit = keyof typeof UNITS;
|
||||
|
||||
export interface RecordedProgressView {
|
||||
readonly jobId: string;
|
||||
readonly claimGeneration: number;
|
||||
readonly sequence: number;
|
||||
readonly phase: Phase;
|
||||
readonly unit: Unit;
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly ageSeconds: number;
|
||||
}
|
||||
|
||||
export async function fetchRecordedProgress(
|
||||
job: ObservatoryRecordedJob,
|
||||
{ signal, fetcher = globalThis.fetch }: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<RecordedProgressView | null> {
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort();
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) request.abort();
|
||||
const timer = globalThis.setTimeout(abort, 2_500);
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`/api/v1/observatory/runs/${encodeURIComponent(job.jobId)}/progress`,
|
||||
{ signal: request.signal, headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!response.ok) throw new Error("Прогресс расчёта недоступен.");
|
||||
return decodeRecordedProgress(await response.json(), job);
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeRecordedProgress(
|
||||
value: unknown, job: ObservatoryRecordedJob,
|
||||
): RecordedProgressView | null {
|
||||
const row = object(value);
|
||||
keys(row, ["schema_version", "job_id", "source_session_id", "setup_id",
|
||||
"definition_sha256", "claim_generation", "state", "received_at_utc", "age_seconds", "progress"]);
|
||||
if (row.schema_version !== "missioncore.observatory-recorded-progress-view/v1"
|
||||
|| row.job_id !== job.jobId || row.source_session_id !== job.sourceSessionId
|
||||
|| row.setup_id !== job.setupId || row.definition_sha256 !== job.definitionSha256
|
||||
|| row.claim_generation !== job.claimGeneration) throw new Error("Прогресс другой попытки.");
|
||||
if (row.progress === null || row.state !== job.state) return null;
|
||||
const progress = object(row.progress);
|
||||
keys(progress, ["schema_version", "claim_generation", "sequence", "phase_index",
|
||||
"phase", "unit", "completed", "total", "elapsed_seconds", "phase_elapsed_seconds"]);
|
||||
if (progress.schema_version !== "missioncore.observatory-recorded-progress/v1"
|
||||
|| progress.claim_generation !== row.claim_generation
|
||||
|| !(typeof progress.phase === "string" && Object.hasOwn(PHASES, progress.phase))
|
||||
|| !(typeof progress.unit === "string" && Object.hasOwn(UNITS, progress.unit))
|
||||
|| typeof row.received_at_utc !== "string" || !Number.isFinite(Date.parse(row.received_at_utc))) {
|
||||
throw new Error("Некорректный прогресс.");
|
||||
}
|
||||
const completed = integer(progress.completed);
|
||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||
const elapsed = finite(progress.elapsed_seconds);
|
||||
if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) {
|
||||
throw new Error("Некорректные счётчики прогресса.");
|
||||
}
|
||||
integer(progress.phase_index);
|
||||
return {
|
||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordedProgressLabel(
|
||||
job: ObservatoryRecordedJob | null, progress: RecordedProgressView | null,
|
||||
): string {
|
||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||
if (!progress || progress.jobId !== job.jobId
|
||||
|| progress.claimGeneration !== job.claimGeneration) return "Ожидаем данные расчёта";
|
||||
if (progress.ageSeconds > 15) return "Ожидаем обновление прогресса";
|
||||
const count = progress.total === null
|
||||
? (progress.completed > 0 ? ` · ${progress.completed.toLocaleString("ru-RU")} ${UNITS[progress.unit]}` : "")
|
||||
: ` · ${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} ${UNITS[progress.unit]}`;
|
||||
return PHASES[progress.phase] + count;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Прогресс: ожидался объект.");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function keys(row: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(row).length !== expected.length || expected.some((key) => !Object.hasOwn(row, key))) {
|
||||
throw new Error("Прогресс: неизвестные поля.");
|
||||
}
|
||||
}
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Прогресс: некорректное число.");
|
||||
return value;
|
||||
}
|
||||
function integer(value: unknown, minimum = 0): number {
|
||||
const number = finite(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum) throw new Error("Прогресс: некорректный счётчик.");
|
||||
return number;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
|
||||
|
||||
import {
|
||||
fetchObservatoryRecordedJobs,
|
||||
@@ -21,6 +22,7 @@ interface JobSnapshot {
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly state: RecordedJobsState;
|
||||
readonly error: string | null;
|
||||
readonly progress?: RecordedProgressView | null;
|
||||
}
|
||||
const EMPTY_JOBS = [] as const;
|
||||
const OPEN_STATES = new Set([
|
||||
@@ -63,14 +65,19 @@ export function useObservatoryRecordedJobs(
|
||||
setSnapshot({
|
||||
selectionKey, jobs,
|
||||
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
|
||||
progress: current?.progress ?? null,
|
||||
});
|
||||
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
|
||||
definitionSha256, signal: request.signal,
|
||||
}).then((next) => {
|
||||
}).then(async (next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
const pending = next.find((job) => OPEN_STATES.has(job.state));
|
||||
const progress = pending
|
||||
? await fetchRecordedProgress(pending, { signal: request.signal }).catch(() => null)
|
||||
: null;
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
if (pending) observedJobId.current = pending.jobId;
|
||||
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
|
||||
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null, progress });
|
||||
}).catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setSnapshot({
|
||||
@@ -95,7 +102,7 @@ export function useObservatoryRecordedJobs(
|
||||
&& latestJob.jobId === observedJobId.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || (!activeJob && !publicationPending)) return;
|
||||
if ((state !== "ready" && state !== "error") || (!activeJob && !publicationPending)) return;
|
||||
const timer = globalThis.setTimeout(
|
||||
() => setRevision((value) => value + 1),
|
||||
POLL_INTERVAL_MS,
|
||||
@@ -185,6 +192,7 @@ export function useObservatoryRecordedJobs(
|
||||
|
||||
return {
|
||||
jobs, latestJob, activeJob, publicationPending, computationFailed,
|
||||
progress: current?.progress ?? null,
|
||||
state, error, refresh, submit, retryPublication,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||
import { recordedProgressLabel } from "../../core/observatory/recordedProgress";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -466,7 +467,12 @@ export function ObservatoryWorkspace({
|
||||
</Button>
|
||||
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||
{calculationPending ? (
|
||||
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
|
||||
<>
|
||||
<ActivityIndicator size="compact" />
|
||||
<span role="status">
|
||||
{recordedProgressLabel(presentedJob, recordedJobsController.progress)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{showCalculate ? (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decode;
|
||||
let label;
|
||||
let fetchProgress;
|
||||
const job = {
|
||||
jobId: "observatory-run-" + "1".repeat(32),
|
||||
sourceSessionId: "source-a", setupId: "m49-tgs",
|
||||
definitionSha256: "a".repeat(64), claimGeneration: 2, state: "running",
|
||||
publication: { state: "not-required" },
|
||||
};
|
||||
function view() {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-recorded-progress-view/v1",
|
||||
job_id: job.jobId, source_session_id: job.sourceSessionId, setup_id: job.setupId,
|
||||
definition_sha256: job.definitionSha256, claim_generation: 2, state: "running",
|
||||
received_at_utc: "2026-09-03T12:00:00Z", age_seconds: 0.5,
|
||||
progress: {
|
||||
schema_version: "missioncore.observatory-recorded-progress/v1",
|
||||
claim_generation: 2, sequence: 4, phase_index: 2,
|
||||
phase: "computing", unit: "frames", completed: 3, total: 10,
|
||||
elapsed_seconds: 8, phase_elapsed_seconds: 5,
|
||||
},
|
||||
};
|
||||
}
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, logLevel: "silent", server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule("/src/core/observatory/recordedProgress.ts");
|
||||
decode = module.decodeRecordedProgress;
|
||||
label = module.recordedProgressLabel;
|
||||
fetchProgress = module.fetchRecordedProgress;
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
test("actual frame count is separate from completion and publication", () => {
|
||||
const progress = decode(view(), job);
|
||||
assert.equal(label(job, progress), "Обрабатываем запись · 3 / 10 кадров");
|
||||
assert.equal(label({ ...job, publication: { state: "pending" } }, progress), "Сохраняем результат");
|
||||
assert.doesNotMatch(label(job, progress), /%|завершён|FPS|ETA/);
|
||||
});
|
||||
test("unknown total never becomes a synthetic percentage", () => {
|
||||
const value = view();
|
||||
value.progress.total = null;
|
||||
assert.equal(label(job, decode(value, job)), "Обрабатываем запись · 3 кадров");
|
||||
value.progress.completed = 0;
|
||||
assert.equal(label(job, decode(value, job)), "Обрабатываем запись");
|
||||
});
|
||||
test("old source definition or attempt cannot flash as current progress", () => {
|
||||
for (const delta of [
|
||||
{ job_id: "other" }, { source_session_id: "other" }, { setup_id: "other" },
|
||||
{ definition_sha256: "b".repeat(64) }, { claim_generation: 1 },
|
||||
]) assert.throws(() => decode({ ...view(), ...delta }, job));
|
||||
assert.equal(decode({ ...view(), state: "succeeded" }, job), null);
|
||||
assert.equal(label({ ...job, claimGeneration: 3 }, decode(view(), job)), "Ожидаем данные расчёта");
|
||||
});
|
||||
test("invalid counters, invented phase and extra fields fail closed", () => {
|
||||
for (const delta of [
|
||||
{ completed: true }, { completed: 11 }, { completed: -1 }, { total: 0 },
|
||||
{ completed: Number.MAX_SAFE_INTEGER + 1 }, { phase: "done" }, { path: "/private" },
|
||||
{ phase_elapsed_seconds: 9 }, { claim_generation: 3 },
|
||||
]) assert.throws(() => decode({ ...view(), progress: { ...view().progress, ...delta } }, job));
|
||||
});
|
||||
test("missing and stale telemetry show no fabricated advancement", () => {
|
||||
assert.equal(decode({ ...view(), progress: null, age_seconds: null, received_at_utc: null }, job), null);
|
||||
assert.equal(label(job, null), "Ожидаем данные расчёта");
|
||||
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
|
||||
});
|
||||
test("progress is a read-only request bound to the active job", async () => {
|
||||
const calls = [];
|
||||
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
|
||||
calls.push([path, init.method ?? "GET"]);
|
||||
return new Response(JSON.stringify(view()));
|
||||
} });
|
||||
assert.deepEqual(calls, [[`/api/v1/observatory/runs/${job.jobId}/progress`, "GET"]]);
|
||||
assert.equal(progress.completed, 3);
|
||||
});
|
||||
Reference in New Issue
Block a user