From d67e86176e508f2eaedeb45df389c6e8d9be7682 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 31 Aug 2026 01:04:25 +0300 Subject: [PATCH] feat(observatory): add durable recorded compute queue --- .../src/core/observatory/laboratorySetups.ts | 15 +- .../src/core/observatory/recordedJobs.ts | 241 ++ .../useObservatoryLaboratorySetups.ts | 5 + .../observatory/useObservatoryRecordedJobs.ts | 112 + .../src/styles/observatory.css | 197 +- .../observatory/ObservatorySetupDetail.tsx | 139 -- .../observatory/ObservatoryWorkspace.tsx | 102 +- .../test/observatoryLaboratorySetups.test.mjs | 34 + .../test/observatoryRecordedJobs.test.mjs | 179 ++ .../test/observatoryWorkspace.test.mjs | 64 +- config/observatory-laboratory-setups.json | 8 +- ...bservatory-m49-recorded-queue-binding.json | 61 + docs/15_LABORATORY_RUN_CANON.md | 75 +- docs/24_M5_1_OBSERVATORY_SURFACE_BRIEF.md | 4 +- ...ory-recorded-queue-and-live-k1-priority.md | 138 ++ src/k1link/observatory/m49_queue_binding.py | 1201 +++++++++ src/k1link/observatory/recorded_jobs.py | 2164 +++++++++++++++++ src/k1link/web/app.py | 40 + src/k1link/web/observatory_api.py | 351 ++- tests/test_observatory_m49_queue_binding.py | 555 +++++ tests/test_observatory_recorded_jobs.py | 728 ++++++ tests/test_observatory_recorded_run_api.py | 253 ++ tests/test_observatory_setups.py | 3 + 23 files changed, 6300 insertions(+), 369 deletions(-) create mode 100644 apps/control-station/src/core/observatory/recordedJobs.ts create mode 100644 apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts delete mode 100644 apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx create mode 100644 apps/control-station/test/observatoryRecordedJobs.test.mjs create mode 100644 config/observatory-m49-recorded-queue-binding.json create mode 100644 docs/adr/0046-durable-observatory-recorded-queue-and-live-k1-priority.md create mode 100644 src/k1link/observatory/m49_queue_binding.py create mode 100644 src/k1link/observatory/recorded_jobs.py create mode 100644 tests/test_observatory_m49_queue_binding.py create mode 100644 tests/test_observatory_recorded_jobs.py create mode 100644 tests/test_observatory_recorded_run_api.py diff --git a/apps/control-station/src/core/observatory/laboratorySetups.ts b/apps/control-station/src/core/observatory/laboratorySetups.ts index 4574bcd..e7149ca 100644 --- a/apps/control-station/src/core/observatory/laboratorySetups.ts +++ b/apps/control-station/src/core/observatory/laboratorySetups.ts @@ -61,8 +61,8 @@ export interface ObservatoryLaboratoryRunPreflight { readonly sourceSessionId: string; readonly setupId: string; readonly definitionSha256: string | null; - readonly outcome: "existing" | "blocked"; - readonly submissionAllowed: false; + readonly outcome: "existing" | "queueable" | "blocked"; + readonly submissionAllowed: boolean; readonly checks: readonly { readonly checkId: string; readonly outcome: "pass" | "fail" | "not-applicable"; @@ -287,8 +287,15 @@ function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight { sourceSessionId: text(row.source_session_id, "preflight source_session_id"), setupId: text(row.setup_id, "preflight setup_id"), definitionSha256: digest, - outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"), - submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"), + outcome: oneOf( + row.outcome, + ["existing", "queueable", "blocked"] as const, + "preflight outcome", + ), + submissionAllowed: boolean( + row.submission_allowed, + "preflight submission_allowed", + ), checks: array(row.checks, "preflight checks").map((item) => { const check = record(item, "preflight check"); exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check"); diff --git a/apps/control-station/src/core/observatory/recordedJobs.ts b/apps/control-station/src/core/observatory/recordedJobs.ts new file mode 100644 index 0000000..25bc18c --- /dev/null +++ b/apps/control-station/src/core/observatory/recordedJobs.ts @@ -0,0 +1,241 @@ +const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1"; +const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1"; +const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1"; +const SHA256 = /^[a-f0-9]{64}$/; + +export type ObservatoryRecordedJobState = + | "accepted" + | "queued" + | "claimed" + | "running" + | "paused" + | "preemption-pending" + | "succeeded" + | "failed" + | "reconciliation-required"; + +export interface ObservatoryRecordedJob { + readonly jobId: string; + readonly idempotencyKey: string; + readonly sourceSessionId: string; + readonly setupId: string; + readonly definitionSha256: string; + readonly state: ObservatoryRecordedJobState; + readonly restartFromZero: boolean; + readonly resultId: string | null; + readonly terminalMessage: string | null; + readonly createdAtUtc: string; + readonly updatedAtUtc: string; +} + +export type ObservatoryRecordedJobFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export class ObservatoryRecordedJobContractError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null = null) { + super(message); + this.name = "ObservatoryRecordedJobContractError"; + this.status = status; + } +} + +export async function fetchObservatoryRecordedJobs( + sourceSessionId: string, + setupId: string, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryRecordedJobFetch; + } = {}, +): Promise { + const query = new URLSearchParams({ + source_session_id: sourceSessionId, + setup_id: setupId, + limit: "20", + }); + const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + const body = await responseBody(response); + if (!response.ok) throw apiError(body, response.status); + const row = record(body, "список расчётов"); + exactKeys(row, ["authority", "items", "schema_version"], "список расчётов"); + exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов"); + observationAuthority(row.authority); + return array(row.items, "items").map(decodeJob).filter((job) => ( + job.sourceSessionId === sourceSessionId && job.setupId === setupId + )); +} + +export async function submitObservatoryRecordedJob( + sourceSessionId: string, + setupId: string, + idempotencyKey: string, + { + signal, + fetcher = globalThis.fetch, + }: { + signal?: AbortSignal; + fetcher?: ObservatoryRecordedJobFetch; + } = {}, +): Promise { + const response = await request(fetcher, "/api/v1/observatory/runs", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: SUBMIT_SCHEMA, + idempotency_key: idempotencyKey, + source_session_id: sourceSessionId, + setup_id: setupId, + }), + signal, + }); + const body = await responseBody(response); + if (!response.ok) throw apiError(body, response.status); + const job = decodeJob(body); + if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId) { + throw new ObservatoryRecordedJobContractError( + "Расчёт относится к другой сессии или сетапу.", + ); + } + return job; +} + +function decodeJob(value: unknown): ObservatoryRecordedJob { + const row = record(value, "расчёт"); + exactKeys(row, [ + "authority", "checkpoint_policy", "claim_generation", "created_at_utc", "executor", + "idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256", + "preemption_requested", "priority", "request_sha256", "restart_from_zero", "result", + "schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal", + "updated_at_utc", + ], "расчёт"); + exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта"); + observationAuthority(row.authority); + digest(row.request_sha256, "request_sha256"); + digest(row.identity_sha256, "identity_sha256"); + digest(row.submission_receipt_sha256, "submission_receipt_sha256"); + const source = record(row.source, "source"); + exactKeys(source, [ + "adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id", + ], "source"); + digest(source.catalog_sha256, "source.catalog_sha256"); + digest(source.bundle_sha256, "source.bundle_sha256"); + digest(source.capability_manifest_sha256, "source.capability_manifest_sha256"); + const setup = record(row.setup, "setup"); + exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup"); + digest(setup.definition_sha256, "setup.definition_sha256"); + const state = oneOf(row.state, [ + "accepted", "queued", "claimed", "running", "paused", "preemption-pending", + "succeeded", "failed", "reconciliation-required", + ] as const, "state"); + const result = row.result === null ? null : record(row.result, "result"); + if (result !== null) exactKeys(result, ["result_id", "sha256"], "result"); + const terminal = row.terminal === null ? null : record(row.terminal, "terminal"); + if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal"); + return { + jobId: text(row.job_id, "job_id"), + idempotencyKey: text(row.idempotency_key, "idempotency_key"), + sourceSessionId: text(source.session_id, "source.session_id"), + setupId: text(setup.setup_id, "setup.setup_id"), + definitionSha256: String(setup.definition_sha256), + state, + restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"), + resultId: result === null ? null : text(result.result_id, "result.result_id"), + terminalMessage: terminal === null || terminal.message === null + ? null + : text(terminal.message, "terminal.message"), + createdAtUtc: text(row.created_at_utc, "created_at_utc"), + updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"), + }; +} + +function observationAuthority(value: unknown): void { + const row = record(value, "authority"); + const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"]; + exactKeys(row, keys, "authority"); + keys.forEach((key) => exact(row[key], false, `authority.${key}`)); +} + +async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise { + try { + return await fetcher(input, init); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна."); + } +} + +async function responseBody(response: Response): Promise { + const body = await response.text(); + if (!body) return undefined; + try { return JSON.parse(body) as unknown; } catch { return body; } +} + +function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError { + const detail = body && typeof body === "object" && !Array.isArray(body) + ? (body as Record).detail + : null; + return new ObservatoryRecordedJobContractError( + typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`, + status, + ); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`); + return value; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`); + } + return value; +} + +function digest(value: unknown, label: string): string { + const valueText = text(value, label); + if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`); + return valueText; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`); + return value; +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`); + return expected; +} + +function oneOf(value: unknown, allowed: T, label: string): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) { + throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`); + } + return value as T[number]; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) { + throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`); + } +} diff --git a/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts index e97eec1..cb7d575 100644 --- a/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts +++ b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts @@ -111,6 +111,11 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) { } }, [preflight.kind, selectedSetup, sourceSessionId]); + useEffect(() => { + if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return; + void check(); + }, [check, preflight.kind, selectedSetup, state]); + return { catalog: activeCatalog, state, diff --git a/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts b/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts new file mode 100644 index 0000000..3efa279 --- /dev/null +++ b/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + fetchObservatoryRecordedJobs, + submitObservatoryRecordedJob, + type ObservatoryRecordedJob, +} from "./recordedJobs"; + +type RecordedJobsState = "idle" | "loading" | "ready" | "refreshing" | "submitting" | "error"; + +const OPEN_STATES = new Set([ + "accepted", "queued", "claimed", "running", "paused", "preemption-pending", + "reconciliation-required", +]); + +export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) { + const [jobs, setJobs] = useState([]); + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const [revision, setRevision] = useState(0); + const requestRef = useRef(null); + const requestSequence = useRef(0); + const idempotencyKeys = useRef(new Map()); + const selectionKey = `${sourceSessionId}\u0000${setupId}`; + + useEffect(() => { + requestRef.current?.abort(); + requestRef.current = null; + if (!sourceSessionId || !setupId) { + setJobs([]); + setState("idle"); + setError(null); + return; + } + const sequence = ++requestSequence.current; + const request = new AbortController(); + requestRef.current = request; + setState((current) => jobs.length > 0 && current !== "idle" ? "refreshing" : "loading"); + setError(null); + void fetchObservatoryRecordedJobs(sourceSessionId, setupId, { signal: request.signal }) + .then((next) => { + if (request.signal.aborted || requestSequence.current !== sequence) return; + setJobs(next); + setState("ready"); + }) + .catch((caught: unknown) => { + if (request.signal.aborted || requestSequence.current !== sequence) return; + setState("error"); + setError(caught instanceof Error && caught.message.trim() + ? caught.message + : "Очередь расчётов недоступна."); + }); + return () => request.abort(); + }, [revision, selectionKey]); + + useEffect(() => () => requestRef.current?.abort(), []); + + const latestJob = jobs[0] ?? null; + const activeJob = useMemo( + () => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null, + [jobs], + ); + + useEffect(() => { + if (!latestJob || activeJob) return; + idempotencyKeys.current.delete(selectionKey); + }, [activeJob, latestJob, selectionKey]); + + const refresh = useCallback(() => setRevision((value) => value + 1), []); + + const submit = useCallback(async (): Promise => { + if (!sourceSessionId || !setupId || state === "submitting") return null; + if (activeJob) return activeJob; + requestRef.current?.abort(); + const request = new AbortController(); + requestRef.current = request; + const key = idempotencyKeys.current.get(selectionKey) + ?? createIdempotencyKey(); + idempotencyKeys.current.set(selectionKey, key); + setState("submitting"); + setError(null); + try { + const job = await submitObservatoryRecordedJob(sourceSessionId, setupId, key, { + signal: request.signal, + }); + if (request.signal.aborted || requestRef.current !== request) return null; + setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]); + setState("ready"); + return job; + } catch (caught) { + if (request.signal.aborted || requestRef.current !== request) return null; + setState("error"); + setError(caught instanceof Error && caught.message.trim() + ? caught.message + : "Не удалось поставить расчёт в очередь."); + return null; + } finally { + if (requestRef.current === request) requestRef.current = null; + } + }, [activeJob, selectionKey, setupId, sourceSessionId, state]); + + return { jobs, latestJob, activeJob, state, error, refresh, submit }; +} + +function createIdempotencyKey(): string { + const entropy = typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`; + return `observatory-ui:${entropy}`; +} + +export type ObservatoryRecordedJobsController = ReturnType; diff --git a/apps/control-station/src/styles/observatory.css b/apps/control-station/src/styles/observatory.css index 7416f76..3399438 100644 --- a/apps/control-station/src/styles/observatory.css +++ b/apps/control-station/src/styles/observatory.css @@ -20,8 +20,6 @@ } .observatory-lead, -.observatory-catalog-bar, -.observatory-catalog-bar__controls, .observatory-notice { flex-wrap: wrap; } @@ -35,14 +33,12 @@ .observatory-lead h2, .observatory-catalog-bar h3, .observatory-session-summary h3, -.observatory-setup-detail h3, .observatory-evidence h3 { margin: 0.3rem 0 0; } .observatory-catalog-bar h3, .observatory-session-summary h3, -.observatory-setup-detail h3, .observatory-evidence h3 { font-size: var(--nodedc-font-size-lg); letter-spacing: -0.02em; @@ -50,7 +46,6 @@ } .observatory-lead p, -.observatory-catalog-bar p, .observatory-state p, .observatory-evidence-empty p { max-width: 52rem; @@ -67,17 +62,37 @@ } .observatory-catalog-bar__copy { - flex: 1 1 auto; + display: grid; + flex: 0 0 auto; + align-content: center; + gap: 0.1rem; +} + +.observatory-catalog-bar h3 { + margin-top: 0.1rem; } .observatory-catalog-bar__controls { - flex: 2 1 48rem; + min-width: 0; + flex: 1 1 auto; + flex-wrap: nowrap; justify-content: flex-end; } .observatory-catalog-bar__controls .nodedc-select-anchor { flex: 1 1 18rem; - min-width: min(22rem, 100%); + min-width: 0; +} + +.observatory-catalog-bar__controls > .nodedc-button { + flex: 0 0 auto; +} + +.observatory-catalog-bar__run { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.55rem; } .observatory-state { @@ -105,9 +120,9 @@ .observatory-session-summary { display: grid; - grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto); + grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto); align-items: center; - gap: 1rem; + gap: 0.75rem; } .observatory-session-summary__identity { @@ -131,7 +146,7 @@ .observatory-session-summary__facts { display: grid; grid-template-columns: repeat(3, minmax(6.5rem, 1fr)); - gap: 1rem; + gap: 0.75rem; margin: 0; } @@ -159,7 +174,7 @@ display: grid; min-width: 0; justify-items: end; - gap: 0.6rem; + gap: 0.4rem; } .observatory-modalities { @@ -171,135 +186,6 @@ color: var(--nodedc-text-muted); } -.observatory-setup-detail { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.85rem 1rem; - container-name: observatory-setup; - container-type: inline-size; -} - -.observatory-setup-detail__identity { - min-width: 0; -} - -.observatory-setup-detail__identity h3 { - margin: 0.15rem 0 0; -} - -.observatory-setup-detail__identity p { - max-width: 68rem; - margin: 0.3rem 0 0; - color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-sm); - line-height: 1.35; -} - -.observatory-setup-detail__badges, -.observatory-setup-detail__actions, -.observatory-setup-detail__reason { - display: flex; - align-items: center; - gap: 0.55rem; -} - -.observatory-setup-detail__badges { - align-self: start; - justify-content: flex-end; - flex-wrap: wrap; -} - -.observatory-setup-detail__facts { - display: grid; - grid-column: 1 / -1; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.75rem; - margin: 0; -} - -.observatory-setup-detail__facts > div { - display: grid; - min-width: 0; - gap: 0.2rem; - padding: 0.65rem 0.75rem; - border-radius: var(--nodedc-radius-control-compact); - background: var(--nodedc-glass-control-bg); -} - -.observatory-setup-detail__facts dt { - color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-xs); -} - -.observatory-setup-detail__facts dd { - min-width: 0; - margin: 0; - overflow-wrap: anywhere; - color: var(--nodedc-text-secondary); - font-size: var(--nodedc-font-size-sm); - line-height: 1.35; -} - -.observatory-setup-results { - display: grid; - grid-column: 1 / -1; - gap: 0.35rem; - margin: 0; - padding: 0; - list-style: none; -} - -.observatory-setup-results li { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 0.65rem; - min-height: 3.15rem; - padding: 0.5rem 0.7rem; - border-radius: var(--nodedc-radius-control-compact); - background: var(--nodedc-glass-control-bg); - color: var(--nodedc-text-secondary); -} - -.observatory-setup-results span { - display: grid; - min-width: 0; - gap: 0.1rem; -} - -.observatory-setup-results strong, -.observatory-setup-results small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.observatory-setup-results small { - color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-sm); - line-height: 1.35; -} - -.observatory-setup-detail__footer { - display: flex; - grid-column: 1 / -1; - align-items: center; - justify-content: space-between; - gap: 0.8rem; - padding-top: 0.1rem; -} - -.observatory-setup-detail__reason { - min-width: 0; - color: var(--nodedc-text-muted); - font-size: var(--nodedc-font-size-sm); - line-height: 1.4; -} - -.observatory-setup-detail__actions { - flex: 0 0 auto; -} - .observatory-evidence { display: grid; gap: 0.65rem; @@ -454,7 +340,7 @@ font-size: var(--nodedc-font-size-sm); } -@container observatory-session (max-width: 56rem) { +@container observatory-session (max-width: 46rem) { .observatory-session-summary { grid-template-columns: minmax(0, 1fr); align-items: start; @@ -467,29 +353,6 @@ } } -@container observatory-setup (max-width: 48rem) { - .observatory-setup-detail { - grid-template-columns: minmax(0, 1fr); - } - - .observatory-setup-detail__badges { - justify-content: flex-start; - } - - .observatory-setup-detail__facts { - grid-template-columns: minmax(0, 1fr); - } - - .observatory-setup-detail__footer { - align-items: stretch; - flex-direction: column; - } - - .observatory-setup-detail__actions { - flex-wrap: wrap; - } -} - @container observatory-session (max-width: 48rem) { .observatory-evidence-card { grid-template-columns: auto minmax(0, 1fr) auto; @@ -535,4 +398,8 @@ min-width: 0; } + .observatory-catalog-bar__run { + justify-content: flex-end; + } + } diff --git a/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx b/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx deleted file mode 100644 index cdc2cca..0000000 --- a/apps/control-station/src/workspaces/observatory/ObservatorySetupDetail.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { - ActivityIndicator, - Button, - GlassSurface, - Icon, - StatusBadge, -} from "@nodedc/ui-react"; - -import type { ObservatoryEvidence } from "../../core/observatory/catalog"; -import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups"; -import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun"; -import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups"; - -const relationLabel: Readonly> = { - "primary-visual": "Основной визуальный разбор", - "compute-successor": "Связанное вычислительное доказательство", - "canonical-projection": "Готовый записанный разбор", -}; - -function workerLabel(value: string): string { - const match = /^worker-(\d+)$/.exec(value); - return match ? `Worker ${match[1]}` : value; -} - -export function ObservatorySetupDetail({ - setup, - evidence, - preflight, - onCheck, - onOpenExisting, -}: { - setup: ObservatoryLaboratorySetup; - evidence: readonly ObservatoryEvidence[]; - preflight: SetupPreflightState; - onCheck: () => void; - onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void; -}) { - const exactExisting = evidence.find( - (candidate) => candidate.recordedRun - && setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId), - )?.recordedRun ?? null; - const displayedReason = preflight.kind === "ready" - ? preflight.value.checks.find((check) => check.checkId === "executor")?.message - ?? setup.executor.reason - : preflight.kind === "error" - ? preflight.message - : setup.preflight.reason; - - return ( - -
- СЕТАП ЛАБОРАТОРИИ -

{setup.displayName}

-

{setup.description}

-
-
- - {setup.compatibility.compatible ? "Совместим" : "Другая сессия"} - - - {setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"} - -
-
-
-
Идентичность
-
- {setup.runDefinition - ? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована` - : "Готовый записанный результат без повторно запускаемой конфигурации"} -
-
-
-
Исполнитель
-
- {setup.preflight.outcome === "existing" - ? "Не требуется для просмотра" - : `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`} -
-
-
-
Сохранено
-
{setup.preservedResults.length} неизменяемых результатов
-
-
-
    - {setup.preservedResults.map((result) => ( -
  1. - - - {relationLabel[result.relation] ?? "Сохранённый результат"} - - {result.access === "observatory" - ? "Доступен в Обсерватории" - : result.access === "legacy-lab" - ? "Сохранён в legacy LAB" - : "Сохранено как связанное доказательство"} - - - - {result.access === "observatory" - ? result.observatoryProjectionAvailable ? "Готов" : "Сохранён" - : result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"} - -
  2. - ))} -
-
-
- - {displayedReason} -
-
- {exactExisting ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index 35cc30d..c0634f3 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -32,8 +32,9 @@ import { } from "../../core/observatory/catalogMutations"; import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog"; import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups"; +import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs"; +import type { ObservatoryRecordedJobState } from "../../core/observatory/recordedJobs"; import type { WorkspaceDefinition } from "../../productModel"; -import { ObservatorySetupDetail } from "./ObservatorySetupDetail"; const MAX_PRESENTED_EVIDENCE = 6; const EMPTY_OBSERVATORY_ITEMS = [] as const; @@ -87,6 +88,27 @@ const modalityLabel: Record = { telemetry: "Телеметрия", }; +const recordedJobStatus: Record< + ObservatoryRecordedJobState, + { + readonly label: string; + readonly tone: "success" | "accent" | "warning" | "danger" | "neutral"; + } +> = { + accepted: { label: "Принят", tone: "neutral" }, + queued: { label: "Ждёт Worker", tone: "neutral" }, + claimed: { label: "Назначен Worker", tone: "accent" }, + running: { label: "Выполняется", tone: "accent" }, + paused: { label: "Пауза: live-поток", tone: "warning" }, + "preemption-pending": { + label: "Ждём подтверждения остановки", + tone: "warning", + }, + succeeded: { label: "Готово", tone: "success" }, + failed: { label: "Ошибка расчёта", tone: "danger" }, + "reconciliation-required": { label: "Нужна сверка", tone: "warning" }, +}; + function statusTone( status: ObservationSessionStatus, ): "success" | "accent" | "warning" | "danger" | "neutral" { @@ -136,6 +158,10 @@ export function ObservatoryWorkspace({ const controller = useObservatoryCatalog(); const [selectedSessionId, setSelectedSessionId] = useState(""); const setupController = useObservatoryLaboratorySetups(selectedSessionId); + const recordedJobsController = useObservatoryRecordedJobs( + selectedSessionId, + setupController.selectedSetupId, + ); const [replay, setReplay] = useState({ kind: "closed" }); const [renameTarget, setRenameTarget] = useState(null); const [renameValue, setRenameValue] = useState(""); @@ -188,6 +214,29 @@ export function ObservatoryWorkspace({ : "Несовместим с выбранной сессией", })) ?? [] ), [setupController.catalog]); + const runPreflight = setupController.preflight.kind === "ready" + ? setupController.preflight.value + : null; + const queueSubmissionAllowed = Boolean( + setupController.selectedSetup?.runDefinition + && setupController.selectedSetup.compatibility.compatible + && runPreflight?.outcome === "queueable" + && runPreflight.submissionAllowed, + ); + const presentedJob = recordedJobsController.activeJob + ?? recordedJobsController.latestJob; + const presentedJobStatus = presentedJob + ? recordedJobStatus[presentedJob.state] + : null; + const queueStatusError = setupController.preflight.kind === "error" + ? setupController.preflight.message + : recordedJobsController.error; + const queueStateBusy = recordedJobsController.state === "loading" + || recordedJobsController.state === "refreshing" + || recordedJobsController.state === "submitting"; + const canSubmitRecordedJob = queueSubmissionAllowed + && recordedJobsController.state === "ready" + && recordedJobsController.activeJob === null; const initialLoading = !controller.catalog && ["idle", "loading"].includes(controller.state); const unavailable = !controller.catalog && controller.state === "error"; @@ -374,11 +423,10 @@ export function ObservatoryWorkspace({ - +
ИСТОЧНИК ДОКАЗАТЕЛЬСТВ

Сохранённая сессия

-

Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.