From ee56b65fc4a32bc4ac764d559c778cc03fdae02b Mon Sep 17 00:00:00 2001 From: pcvantol Date: Sat, 12 Sep 2026 16:57:16 +0200 Subject: [PATCH 1/2] fix: centralize advisory analysis and execution context --- docs/engineering/ENGINEERING_REPORTING.md | 25 +-- .../P_CENTRAL_CONSOLE_AUTHORITY_MAP.md | 2 +- ...RANSPORT_CONSOLE_ROUTE_OWNERSHIP_MATRIX.md | 16 +- package-lock.json | 4 +- package.json | 2 +- pyproject.toml | 2 +- .../ENGINEERING_PLATFORM_CONFIG.json | 2 +- .../ENGINEERING_PLATFORM_VERSION.json | 8 +- src/engineering_platform/assets/dashboard.css | 2 +- src/engineering_platform/assets/dashboard.js | 54 +++--- .../assets/dashboard_locales.mjs | 16 +- .../console_route_ownership.py | 3 +- .../parity_lifecycle_dispatcher.py | 42 ++++- src/engineering_platform/platform_version.py | 2 +- src/engineering_platform/report_analysis.py | 53 ++++-- src/engineering_platform/server.py | 163 +++++++++++++++++- .../server_console_services.py | 8 +- .../templates/workspace-config.json | 2 +- tests/engineering/dashboard.spec.mjs | 50 ++++-- ...erver-http-surface.postman_collection.json | 49 ++++++ .../test_console_route_ownership.py | 7 + .../test_coverage_runtime_boundaries.py | 36 +++- .../test_parity_lifecycle_dispatcher.py | 13 +- .../console_route_ownership_guard.py | 3 +- .../ep_server_postman_surface.py | 2 + 25 files changed, 464 insertions(+), 102 deletions(-) diff --git a/docs/engineering/ENGINEERING_REPORTING.md b/docs/engineering/ENGINEERING_REPORTING.md index fb0fa799..beb6724a 100644 --- a/docs/engineering/ENGINEERING_REPORTING.md +++ b/docs/engineering/ENGINEERING_REPORTING.md @@ -213,11 +213,11 @@ reports never claim successful implementation or delivery. ## Advisory Codex analysis -After a terminal report is written, the runner may request one separate Codex -CLI analysis of that exact local report. The analysis is read-only, bounded and -stored locally per run under `.engineering/report-analysis/.md`. It -distils findings, issues, risks, next steps and advice for the Product -Architect. Its output is advisory and redacted before persistence. Every +After a terminal report is written, the lifecycle dispatcher may request one +separate Codex CLI analysis of that exact report. The analysis is read-only, +bounded, redacted before persistence, and registered as an integrity-verified +CENTRAL Markdown artifact for the same `(project_id, run_id)`. It distils +findings, issues, risks, next steps and advice for the Product Architect. Every analysis also records a bounded **Analyseverwerking** status. If the provider is unavailable, fails, or returns an invalid structured response, the matching safe reason is shown there; raw provider output and diagnostics are never @@ -230,15 +230,18 @@ and authoritative. The private Engineering Status dashboard exposes an **AI analysis** column in Prompt History next to the engineering report. View and download actions are -available only when the analysis file belongs to that exact Run ID; analyses -from another execution are never selected as a fallback. +available only for the matching verified CENTRAL artifact; an older local file +or another run is never selected as a fallback. A response that is unavailable +or is not Markdown is rendered as a localized unavailable message, never as +technical JSON. For a controlled temporary processing failure (`provider_failed`, `provider_unavailable` or `invalid_structured_response`), the analysis dialog -also offers **Generate analysis again**. It is bound to that same indexed -terminal report and regenerates only the advisory analysis; it never resumes, -retries or changes the Engineering execution, checkpoint, branch or pull -request. A successfully processed analysis is deliberately not retryable. +also offers **Generate analysis again**. It is bound to that same centrally +indexed terminal report, emits a redacted Console audit event, and regenerates +only the advisory analysis; it never resumes, retries or changes the +Engineering execution, checkpoint, branch or pull request. A successfully +processed analysis is deliberately not retryable. If a terminal report temporarily cannot be read in its dialog, the dashboard offers **Reload report**. That action repeats only the read-only retrieval of diff --git a/docs/engineering/P_CENTRAL_CONSOLE_AUTHORITY_MAP.md b/docs/engineering/P_CENTRAL_CONSOLE_AUTHORITY_MAP.md index 06880b00..1e7184a1 100644 --- a/docs/engineering/P_CENTRAL_CONSOLE_AUTHORITY_MAP.md +++ b/docs/engineering/P_CENTRAL_CONSOLE_AUTHORITY_MAP.md @@ -19,7 +19,7 @@ physical execution binding only; it is not Console authority. | Provider usage | SERVER_PLATFORM_NATIVE | Current single-runtime Codex capacity is a Server/CENTRAL projection. | | Evidence report downloads | CENTRAL_NATIVE | Report index and artifact path are authorized by `(project_id, run_id)` in CENTRAL. | | Prompt chat history | CENTRAL_NATIVE (read projection) | Immutable transcript lookup is scoped by CENTRAL project/run lineage. | -| Provider-backed chat mutation and report analysis | RETIRED/UNREACHABLE from migrated routes | No CENTRAL Server authority is invented for historical root-backed mutation. | +| Advisory report analysis and retry | CENTRAL_NATIVE (bounded project mutation) | An exact-run, integrity-verified Markdown artifact is read from CENTRAL. Retry is allowed only for a controlled transient analysis status and uses the matching CENTRAL report; it is audited and cannot change the execution. | | Configuration | CENTRAL_NATIVE (read projection) | Only CENTRAL maintenance/capacity policy is exposed; old root-local settings fail closed. | | Component logs | CENTRAL_NATIVE | Server-owned CENTRAL log index, explicitly `PLATFORM` scoped. | | Worktree, provider-login and update actions | RETIRED/UNREACHABLE | They are not supported installed Console actions in this phase. | diff --git a/docs/engineering/P_TRANSPORT_CONSOLE_ROUTE_OWNERSHIP_MATRIX.md b/docs/engineering/P_TRANSPORT_CONSOLE_ROUTE_OWNERSHIP_MATRIX.md index 0e115fbd..47d02e13 100644 --- a/docs/engineering/P_TRANSPORT_CONSOLE_ROUTE_OWNERSHIP_MATRIX.md +++ b/docs/engineering/P_TRANSPORT_CONSOLE_ROUTE_OWNERSHIP_MATRIX.md @@ -18,7 +18,7 @@ change authority, select a checkout, or delegate that route. | PLATFORM | `GET/POST /api/configuration`; `GET /api/central-data/export`; `POST /api/central-data/{relocate,relocate/browse,relocate/discard,import}`; `GET/POST /api/central-database/configuration` | Server settings and Central data operations; paths and imported bytes are never placed in the audit log | | HOST_ADMIN | `GET /api/host-admin/diagnostics` | Bounded installation-only disk and managed-runtime observation; no project, queue, execution or mutation authority | | PLATFORM | `GET /v1/operations/projects` | Operations Console platform listing | -| PROJECT | `GET /api/prompt-history`, `/api/prompt-history/{run}/{report,chat,details}`, `/api/telemetry/{date}`; `POST /api/telemetry/clear`, `/api/execution-{dismiss,retry}`, `/api/dashboard-translate` | Project history, telemetry and project actions; no valid selected project returns `409 CONSOLE_PROJECT_UNAVAILABLE` | +| PROJECT | `GET /api/prompt-history`, `/api/prompt-history/{run}/{report,analysis,chat,details}`, `/api/telemetry/{date}`; `POST /api/prompt-history/{run}/analysis-retry`, `/api/telemetry/clear`, `/api/execution-{dismiss,retry}`, `/api/dashboard-translate` | Project history, centrally indexed advisory analysis, telemetry and project actions; no valid selected project returns `409 CONSOLE_PROJECT_UNAVAILABLE` | | TRANSPORT_INTERNAL | `/diagnostics/topology`, `/healthz`, `/readyz`, `/v1/projects/{project}/submissions`, `/v1/agent/{pair,register,heartbeat,attachment}` | Transport probes and authenticated transport endpoints, not Console delegation | | HISTORICAL_UNREACHABLE | `POST /api/runtime-directory/open` | Explicitly retired checkout-bound runtime action (`410 RUNTIME_DIRECTORY_RETIRED`) | @@ -45,6 +45,11 @@ Mission, Engineering Action, correlation and target repository. For Forge, the accepted versioned provenance also supplies host, mission/intent revisions, runtime-prompt identifier and digest, and retry correlation when present. +The active card names two deliberately separate EP states: **EP execution +phase** is the state recorded for the Execution Host run; **EP dispatcher +state** is the FIFO dispatch record's orchestration state. They can both be +`RUNNING` during normal operation, but neither is inferred from the other. + The Console never derives a branch, checkout, tracked-file count, Mission summary or prompt content. Those remain absent until the Execution Host records run-specific evidence or Forge supplies a separately versioned context. @@ -57,6 +62,15 @@ audited, but the log contains only action, actor, outcome and canonical target identifiers: no prompt text, AI-chat content, queue reason, local path, import payload or downloaded bytes. +An advisory AI analysis is a separately indexed, integrity-verified CENTRAL +Markdown artifact for the exact terminal `(project_id, run_id)`. The Console +never falls back to a repository checkout or a legacy local analysis file. +Only the bounded retryable processing states may be regenerated from that +run's CENTRAL report; regeneration records a `dashboard_action_completed` +audit event and cannot change the execution outcome. Document responses that +are absent, malformed, or not Markdown are shown as a localized unavailable +message, never as a JSON payload in the Console. + ## Test and qualification coverage The matrix is not documentation-only. The following checks are the normal diff --git a/package-lock.json b/package-lock.json index 82bac5ca..163900e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "engineering-platform-browser-validation", - "version": "2.3.19", + "version": "2.3.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "engineering-platform-browser-validation", - "version": "2.3.19", + "version": "2.3.20", "devDependencies": { "@playwright/test": "1.62.1" } diff --git a/package.json b/package.json index de777f57..f06998b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "engineering-platform-browser-validation", "private": true, - "version": "2.3.19", + "version": "2.3.20", "scripts": { "test:engineering-dashboard": "PYTHONPATH=src python3 -m engineering_platform.dashboard_browser_validation", "test:engineering-dashboard-logic": "node --test tests/engineering/dashboard_status_store.test.mjs tests/engineering/ui_localization_contract.test.mjs", diff --git a/pyproject.toml b/pyproject.toml index ac5e6dff..58b549bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "engineering-platform" -version = "2.3.19" +version = "2.3.20" description = "Local-first Engineering Platform execution operations runtime" readme = "README.md" requires-python = ">=3.11" diff --git a/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json b/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json index 9e3d815b..e0bb2cf2 100644 --- a/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json +++ b/src/engineering_platform/ENGINEERING_PLATFORM_CONFIG.json @@ -3,7 +3,7 @@ "platform": { "id": "engineering-platform", "name": "Engineering Platform", - "version": "2.3.19", + "version": "2.3.20", "generation": 2, "documentation_namespace": "engineering-platform", "capability_registry_version": 1 diff --git a/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json b/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json index 3cc3a289..01530b49 100644 --- a/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json +++ b/src/engineering_platform/ENGINEERING_PLATFORM_VERSION.json @@ -1,15 +1,15 @@ { "bootstrap_contract": "2026.12", "checkpoint_format": 1, - "dashboard_version": "2.3.19", + "dashboard_version": "2.3.20", "handoff_protocol": 1, "memory_format": 2, "minimum_codex_cli": "0.146.0", "inbox_protocol": 1, - "platform_version": "2.3.19", + "platform_version": "2.3.20", "report_format": 2, - "runner_version": "2.3.19", + "runner_version": "2.3.20", "status_model": 1, "storage_schema": 44, - "watcher_version": "2.3.19" + "watcher_version": "2.3.20" } diff --git a/src/engineering_platform/assets/dashboard.css b/src/engineering_platform/assets/dashboard.css index 74965e0f..0e89d06e 100644 --- a/src/engineering_platform/assets/dashboard.css +++ b/src/engineering_platform/assets/dashboard.css @@ -406,7 +406,7 @@ html[data-theme="light"] :is(.dashboard-locale,.dashboard-project){color:#415168 /* Main sections */ .dashboard-grid{gap:var(--dashboard-section-gap)} -.execution-context--primary{background:#1f3140!important;border-color:#65c5d9!important}.execution-context--primary strong{color:#8de1f2!important}.execution-context--primary .field{border-top:1px solid #65c5d955;padding-top:8px}.execution-context--primary .field:first-of-type{border-top:0;padding-top:0}.execution-context__phase{border:1px solid currentColor;border-radius:999px;display:inline-block;font-weight:700;padding:4px 8px}.execution-context__planning-empty{border-top:1px solid #65c5d955;color:var(--dashboard-modal-muted);font-size:14px;line-height:1.45;margin:0;padding-top:10px} +.execution-context--primary{background:#1f3140!important;border-color:#65c5d9!important}.execution-context--primary strong{color:#8de1f2!important}.execution-context--primary .field{border-top:1px solid #65c5d955;padding-top:8px}.execution-context--primary .field:first-of-type{border-top:0;padding-top:0}.execution-context__planning-empty{border-top:1px solid #65c5d955;color:var(--dashboard-modal-muted);font-size:14px;line-height:1.45;margin:0;padding-top:10px} .execution-mode-field__value{align-items:center;display:flex;gap:10px;justify-content:space-between;min-width:0}.execution-mode-field__value>span{min-width:0;overflow-wrap:anywhere}.execution-mode-info{color:#65c5d9;flex:none;grid-area:auto}.execution-mode-info:hover{background:#65c5d9!important;border-color:#65c5d9!important;color:#162832!important} .workspace-card{margin-bottom:0} .workspace-card,.inbox-queue,.prompt-history,#rateLimits,#componentLogs,#codexChat,#engineering-dashboard-content>.technical-details:not(#componentLogs),.telemetry,.platform-health,.current-run{box-shadow:none} diff --git a/src/engineering_platform/assets/dashboard.js b/src/engineering_platform/assets/dashboard.js index cc58fc1b..3dd691af 100644 --- a/src/engineering_platform/assets/dashboard.js +++ b/src/engineering_platform/assets/dashboard.js @@ -1618,7 +1618,7 @@ function replaceWithLocalFilesystemLink(element, replacementValue = element?.tex element.replaceWith(link); return link; } -function executionContextField(label, value, badge = false, folder = false) { +function executionContextField(label, value, folder = false) { const field = document.createElement("p"), caption = document.createElement("span"), content = document.createElement("span"); field.className = "field"; caption.className = "label"; @@ -1626,10 +1626,17 @@ function executionContextField(label, value, badge = false, folder = false) { const supplied = executionContextValue(value); const output = folder && supplied.startsWith("/") ? localFilesystemLink(supplied) : content; if (output === content) content.textContent = supplied || t("execution_context.not_supplied"); - if (badge) output.classList.add("execution-context__phase"); field.append(caption, output); return field; } +function executionContextRuntimeStatus(value) { + const raw = executionContextValue(value); + return raw ? translate(raw) : ""; +} +function executionContextTimestamp(value) { + const raw = executionContextValue(value); + return raw ? formatTimestamp(raw) : ""; +} function inheritModalAccent(modal, trigger) { const source = trigger?.closest(".current-run,[data-modal-accent-source],.dashboard-modal-shell"); const sample = source && document.createElement("span"); @@ -1683,7 +1690,7 @@ function renderExecutionContext(context, execution = {}) { if (!context || typeof context !== "object") { card.replaceChildren( Object.assign(document.createElement("strong"), { textContent: t("ui.execution_context") }), - ...hostFields.map(([label, value, isExecutionMode]) => isExecutionMode ? executionModeField(value) : executionContextField(label, value, false, label === t("detail.target_checkout"))), + ...hostFields.map(([label, value, isExecutionMode]) => isExecutionMode ? executionModeField(value) : executionContextField(label, value, label === t("detail.target_checkout"))), Object.assign(document.createElement("p"), { textContent: t("execution_context.not_supplied") }), ); return; @@ -1696,15 +1703,15 @@ function renderExecutionContext(context, execution = {}) { [t("execution_context.engineering_summary"), context.engineering_summary], [t("execution_context.current_intent"), context.current_intent], [t("execution_context.current_engineering_action"), context.current_engineering_action], - [t("execution_context.execution_phase"), context.execution_phase, true], + [t("execution_context.execution_phase"), executionContextRuntimeStatus(context.execution_phase)], [t("execution_context.planning_confidence"), context.planning_confidence], [t("execution_context.current_iteration"), context.current_iteration], [t("execution_context.mission_progress"), context.mission_progress], - [t("execution_context.last_runtime_update"), context.last_runtime_update || context.last_updated_timestamp], + [t("execution_context.last_runtime_update"), executionContextTimestamp(context.last_runtime_update || context.last_updated_timestamp)], [t("execution_context.decision_evidence_reference"), context.decision_evidence_reference || context.decision_evidence], [t("execution_context.decision_type"), context.decision_type], [t("execution_context.execution_receipt_reference"), context.execution_receipt_reference || context.last_execution_receipt], - [t("execution_context.dispatcher_state"), context.dispatcher_state], + [t("execution_context.dispatcher_state"), executionContextRuntimeStatus(context.dispatcher_state)], [t("execution_context.approved_mission_queue_state"), context.approved_mission_queue_state], [t("execution_context.producer_host"), context.producer_host_id], [t("execution_context.mission_revision"), context.mission_revision], @@ -1731,10 +1738,10 @@ function renderExecutionContext(context, execution = {}) { ]; card.replaceChildren( Object.assign(document.createElement("strong"), { textContent: t("ui.execution_context") }), - ...hostFields.map(([label, value, isExecutionMode]) => isExecutionMode ? executionModeField(value) : executionContextField(label, value, false, label === t("detail.target_checkout"))), + ...hostFields.map(([label, value, isExecutionMode]) => isExecutionMode ? executionModeField(value) : executionContextField(label, value, label === t("detail.target_checkout"))), ...suppliedFields.map(([label, value]) => executionContextField(label, value)), ...(planningFields.length - ? planningFields.map(([label, value, badge]) => executionContextField(label, value, badge)) + ? planningFields.map(([label, value]) => executionContextField(label, value)) : [Object.assign(document.createElement("p"), { className: "execution-context__planning-empty", textContent: t("execution_context.planning_not_supplied"), @@ -7313,19 +7320,14 @@ function loadPromptHistoryDocument() { (promptHistoryDocumentKind === "analysis" ? "/analysis" : "/report"), { cache: "no-store" }, ) - .then((response) => - response.ok + .then((response) => { + const markdown = response.headers.get("content-type")?.toLowerCase().includes("text/markdown"); + return response.ok && markdown ? response.text() - : Promise.reject( - Error( - t( - promptHistoryDocumentKind === "analysis" - ? "history.analysis_unavailable" - : "history.report_unavailable", - ), - ), - ), - ) + : Promise.reject(Error( + t(promptHistoryDocumentKind === "analysis" ? "history.analysis_unavailable" : "history.report_unavailable"), + )); + }) .then((text) => { if (!text) throw Error( @@ -7382,19 +7384,15 @@ async function retryPromptHistoryAnalysis() { "/api/prompt-history/" + encodeURIComponent(promptHistoryReportRun) + "/analysis-retry", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, ); + const markdown = response.headers.get("content-type")?.toLowerCase().includes("text/markdown"); const text = await response.text(); - if (!response.ok) { - let detail = ""; - try { detail = JSON.parse(text).error || ""; } catch { /* controlled fallback below */ } - throw Error(detail || t("history.analysis_retry_failed")); - } + if (!response.ok || !markdown) throw Error(t("history.analysis_retry_failed")); promptHistoryReportText = text; renderMarkdownDocument(content, text); button.hidden = !reportAnalysisCanRetry(text); - void recordUserAction("prompt_history_analysis_regenerated", promptHistoryReportRun); } catch (error) { renderMarkdownDocument(content, promptHistoryReportText); - showDashboardError(error instanceof Error ? error.message : "", t("history.analysis_retry_failed")); + showDashboardError(t("history.analysis_retry_failed"), t("history.analysis_retry_failed")); } finally { button.disabled = false; } @@ -7532,7 +7530,7 @@ function promptDetailExecutionSections(history) { detailField(t("detail.mission_id"), executionContextValue(context.mission_id) || t("execution_context.not_supplied")), detailField(t("execution_context.business_summary"), executionContextValue(context.business_summary) || t("execution_context.not_supplied")), detailField(t("execution_context.engineering_summary"), executionContextValue(context.engineering_summary) || t("execution_context.not_supplied")), - detailField(t("execution_context.execution_phase"), executionContextValue(context.execution_phase) || t("execution_context.not_supplied")), + detailField(t("execution_context.execution_phase"), executionContextRuntimeStatus(context.execution_phase) || t("execution_context.not_supplied")), detailField(t("execution_context.mission_lifecycle"), executionContextValue(context.mission_lifecycle) || t("execution_context.not_supplied")), detailField(t("execution_context.decision_evidence_reference"), executionContextValue(context.decision_evidence_reference || context.decision_evidence) || t("execution_context.not_supplied")), detailField(t("execution_context.execution_receipt_reference"), executionContextValue(context.execution_receipt_reference || context.last_execution_receipt) || t("execution_context.not_supplied")), diff --git a/src/engineering_platform/assets/dashboard_locales.mjs b/src/engineering_platform/assets/dashboard_locales.mjs index 445deac3..fed0a470 100644 --- a/src/engineering_platform/assets/dashboard_locales.mjs +++ b/src/engineering_platform/assets/dashboard_locales.mjs @@ -3038,14 +3038,14 @@ export const DASHBOARD_MESSAGES = { }, }; -Object.assign(DASHBOARD_MESSAGES.en, {"execution_context.mission_title":"Mission title","execution_context.mission_lifecycle":"Mission lifecycle","execution_context.business_summary":"Business summary","execution_context.engineering_summary":"Engineering summary","execution_context.current_intent":"Current intent","execution_context.current_engineering_action":"Current engineering action","execution_context.execution_phase":"Execution phase","execution_context.planning_confidence":"Planning confidence","execution_context.current_iteration":"Current iteration","execution_context.mission_progress":"Mission progress","execution_context.last_runtime_update":"Last runtime update","execution_context.version":"Execution Context version","execution_context.decision_evidence_reference":"Decision Evidence reference","execution_context.decision_type":"Decision type","execution_context.execution_receipt_reference":"Execution Receipt reference","execution_context.dispatcher_state":"Dispatcher state","execution_context.approved_mission_queue_state":"Approved Mission Queue state","execution_context.producer_host":"Forge host","execution_context.mission_revision":"Mission revision","execution_context.intent_id":"Intent ID","execution_context.intent_revision":"Intent revision","execution_context.runtime_prompt_id":"Runtime prompt ID","execution_context.runtime_prompt_digest":"Runtime prompt digest","execution_context.retry_of_correlation_id":"Retry of correlation ID","execution_context.snapshot":"Execution Context snapshot","execution_context.not_supplied":"Not supplied by Producer","detail.producer_submission_contract":"Producer Submission Contract","detail.submission_id":"Submission ID"}); -Object.assign(DASHBOARD_MESSAGES.nl, {"execution_context.mission_title":"Missietitel","execution_context.mission_lifecycle":"Missielevenscyclus","execution_context.business_summary":"Zakelijke samenvatting","execution_context.engineering_summary":"Engineering-samenvatting","execution_context.current_intent":"Huidige intent","execution_context.current_engineering_action":"Huidige engineeringactie","execution_context.execution_phase":"Uitvoeringsfase","execution_context.planning_confidence":"Planningszekerheid","execution_context.current_iteration":"Huidige iteratie","execution_context.mission_progress":"Missievoortgang","execution_context.last_runtime_update":"Laatste runtime-update","execution_context.version":"Versie uitvoeringscontext","execution_context.decision_evidence_reference":"Beslisevidence-referentie","execution_context.decision_type":"Beslistype","execution_context.execution_receipt_reference":"Uitvoeringsbewijsreferentie","execution_context.dispatcher_state":"Dispatcherstatus","execution_context.approved_mission_queue_state":"Goedgekeurde missie-wachtrijstatus","execution_context.producer_host":"Forge-host","execution_context.mission_revision":"Missierevisie","execution_context.intent_id":"Intent-ID","execution_context.intent_revision":"Intent-revisie","execution_context.runtime_prompt_id":"Runtime-prompt-ID","execution_context.runtime_prompt_digest":"Runtime-prompt-digest","execution_context.retry_of_correlation_id":"Herhaling van correlatie-ID","execution_context.snapshot":"Momentopname uitvoeringscontext","execution_context.not_supplied":"Niet geleverd door Producer","detail.producer_submission_contract":"Producer Submission Contract","detail.submission_id":"Submission-ID"}); -Object.assign(DASHBOARD_MESSAGES.de, {"execution_context.mission_progress":"Missionsfortschritt","execution_context.decision_evidence_reference":"Entscheidungsnachweis-Referenz","execution_context.decision_type":"Entscheidungstyp","execution_context.execution_receipt_reference":"Ausführungsbeleg-Referenz","execution_context.dispatcher_state":"Dispatcher-Status","execution_context.approved_mission_queue_state":"Status der genehmigten Missionswarteschlange","execution_context.snapshot":"Ausführungskontext-Snapshot","execution_context.not_supplied":"Nicht vom Producer bereitgestellt","detail.producer_submission_contract":"Producer-Submission-Vertrag","detail.submission_id":"Submission-ID"}); -Object.assign(DASHBOARD_MESSAGES.fr, {"execution_context.mission_progress":"Progression de la mission","execution_context.decision_evidence_reference":"Référence de preuve de décision","execution_context.decision_type":"Type de décision","execution_context.execution_receipt_reference":"Référence de reçu d’exécution","execution_context.dispatcher_state":"État du répartiteur","execution_context.approved_mission_queue_state":"État de la file des missions approuvées","execution_context.snapshot":"Instantané du contexte d’exécution","execution_context.not_supplied":"Non fourni par le producteur","detail.producer_submission_contract":"Contrat de soumission du producteur","detail.submission_id":"ID de soumission"}); -Object.assign(DASHBOARD_MESSAGES.es, {"execution_context.mission_progress":"Progreso de la misión","execution_context.decision_evidence_reference":"Referencia de evidencia de decisión","execution_context.decision_type":"Tipo de decisión","execution_context.execution_receipt_reference":"Referencia de recibo de ejecución","execution_context.dispatcher_state":"Estado de Dispatcher","execution_context.approved_mission_queue_state":"Estado de la cola de misiones aprobadas","execution_context.snapshot":"Instantánea del contexto de ejecución","execution_context.not_supplied":"No proporcionado por el productor","detail.producer_submission_contract":"Contrato de envío del productor","detail.submission_id":"ID de envío"}); -Object.assign(DASHBOARD_MESSAGES.de, {"execution_context.mission_title":"Missionstitel","execution_context.mission_lifecycle":"Missionslebenszyklus","execution_context.business_summary":"Geschäftszusammenfassung","execution_context.engineering_summary":"Engineering-Zusammenfassung","execution_context.current_intent":"Aktuelle Absicht","execution_context.current_engineering_action":"Aktuelle Engineering-Aktion","execution_context.execution_phase":"Ausführungsphase","execution_context.planning_confidence":"Planungssicherheit","execution_context.current_iteration":"Aktuelle Iteration","execution_context.last_runtime_update":"Letztes Runtime-Update","execution_context.version":"Ausführungskontext-Version"}); -Object.assign(DASHBOARD_MESSAGES.fr, {"execution_context.mission_title":"Titre de la mission","execution_context.mission_lifecycle":"Cycle de vie de la mission","execution_context.business_summary":"Résumé métier","execution_context.engineering_summary":"Résumé d’ingénierie","execution_context.current_intent":"Intention actuelle","execution_context.current_engineering_action":"Action d’ingénierie actuelle","execution_context.execution_phase":"Phase d’exécution","execution_context.planning_confidence":"Confiance de planification","execution_context.current_iteration":"Itération actuelle","execution_context.last_runtime_update":"Dernière mise à jour runtime","execution_context.version":"Version du contexte d’exécution"}); -Object.assign(DASHBOARD_MESSAGES.es, {"execution_context.mission_title":"Título de la misión","execution_context.mission_lifecycle":"Ciclo de vida de la misión","execution_context.business_summary":"Resumen de negocio","execution_context.engineering_summary":"Resumen de ingeniería","execution_context.current_intent":"Intención actual","execution_context.current_engineering_action":"Acción de ingeniería actual","execution_context.execution_phase":"Fase de ejecución","execution_context.planning_confidence":"Confianza de planificación","execution_context.current_iteration":"Iteración actual","execution_context.last_runtime_update":"Última actualización de runtime","execution_context.version":"Versión del contexto de ejecución"}); +Object.assign(DASHBOARD_MESSAGES.en, {"execution_context.mission_title":"Mission title","execution_context.mission_lifecycle":"Mission lifecycle","execution_context.business_summary":"Business summary","execution_context.engineering_summary":"Engineering summary","execution_context.current_intent":"Current intent","execution_context.current_engineering_action":"Current engineering action","execution_context.execution_phase":"EP execution phase","execution_context.planning_confidence":"Planning confidence","execution_context.current_iteration":"Current iteration","execution_context.mission_progress":"Mission progress","execution_context.last_runtime_update":"Last runtime update","execution_context.version":"Execution Context version","execution_context.decision_evidence_reference":"Decision Evidence reference","execution_context.decision_type":"Decision type","execution_context.execution_receipt_reference":"Execution Receipt reference","execution_context.dispatcher_state":"EP dispatcher state","execution_context.approved_mission_queue_state":"Approved Mission Queue state","execution_context.producer_host":"Forge host","execution_context.mission_revision":"Mission revision","execution_context.intent_id":"Intent ID","execution_context.intent_revision":"Intent revision","execution_context.runtime_prompt_id":"Runtime prompt ID","execution_context.runtime_prompt_digest":"Runtime prompt digest","execution_context.retry_of_correlation_id":"Retry of correlation ID","execution_context.snapshot":"Execution Context snapshot","execution_context.not_supplied":"Not supplied by Producer","detail.producer_submission_contract":"Producer Submission Contract","detail.submission_id":"Submission ID"}); +Object.assign(DASHBOARD_MESSAGES.nl, {"execution_context.mission_title":"Missietitel","execution_context.mission_lifecycle":"Missielevenscyclus","execution_context.business_summary":"Zakelijke samenvatting","execution_context.engineering_summary":"Engineering-samenvatting","execution_context.current_intent":"Huidige intent","execution_context.current_engineering_action":"Huidige engineeringactie","execution_context.execution_phase":"EP-uitvoeringsfase","execution_context.planning_confidence":"Planningszekerheid","execution_context.current_iteration":"Huidige iteratie","execution_context.mission_progress":"Missievoortgang","execution_context.last_runtime_update":"Laatste runtime-update","execution_context.version":"Versie uitvoeringscontext","execution_context.decision_evidence_reference":"Beslisevidence-referentie","execution_context.decision_type":"Beslistype","execution_context.execution_receipt_reference":"Uitvoeringsbewijsreferentie","execution_context.dispatcher_state":"EP-dispatcherstatus","execution_context.approved_mission_queue_state":"Goedgekeurde missie-wachtrijstatus","execution_context.producer_host":"Forge-host","execution_context.mission_revision":"Missierevisie","execution_context.intent_id":"Intent-ID","execution_context.intent_revision":"Intent-revisie","execution_context.runtime_prompt_id":"Runtime-prompt-ID","execution_context.runtime_prompt_digest":"Runtime-prompt-digest","execution_context.retry_of_correlation_id":"Herhaling van correlatie-ID","execution_context.snapshot":"Momentopname uitvoeringscontext","execution_context.not_supplied":"Niet geleverd door Producer","detail.producer_submission_contract":"Producer Submission Contract","detail.submission_id":"Submission-ID"}); +Object.assign(DASHBOARD_MESSAGES.de, {"execution_context.mission_progress":"Missionsfortschritt","execution_context.decision_evidence_reference":"Entscheidungsnachweis-Referenz","execution_context.decision_type":"Entscheidungstyp","execution_context.execution_receipt_reference":"Ausführungsbeleg-Referenz","execution_context.dispatcher_state":"EP-Dispatcher-Status","execution_context.approved_mission_queue_state":"Status der genehmigten Missionswarteschlange","execution_context.snapshot":"Ausführungskontext-Snapshot","execution_context.not_supplied":"Nicht vom Producer bereitgestellt","detail.producer_submission_contract":"Producer-Submission-Vertrag","detail.submission_id":"Submission-ID"}); +Object.assign(DASHBOARD_MESSAGES.fr, {"execution_context.mission_progress":"Progression de la mission","execution_context.decision_evidence_reference":"Référence de preuve de décision","execution_context.decision_type":"Type de décision","execution_context.execution_receipt_reference":"Référence de reçu d’exécution","execution_context.dispatcher_state":"État du répartiteur EP","execution_context.approved_mission_queue_state":"État de la file des missions approuvées","execution_context.snapshot":"Instantané du contexte d’exécution","execution_context.not_supplied":"Non fourni par le producteur","detail.producer_submission_contract":"Contrat de soumission du producteur","detail.submission_id":"ID de soumission"}); +Object.assign(DASHBOARD_MESSAGES.es, {"execution_context.mission_progress":"Progreso de la misión","execution_context.decision_evidence_reference":"Referencia de evidencia de decisión","execution_context.decision_type":"Tipo de decisión","execution_context.execution_receipt_reference":"Referencia de recibo de ejecución","execution_context.dispatcher_state":"Estado del dispatcher de EP","execution_context.approved_mission_queue_state":"Estado de la cola de misiones aprobadas","execution_context.snapshot":"Instantánea del contexto de ejecución","execution_context.not_supplied":"No proporcionado por el productor","detail.producer_submission_contract":"Contrato de envío del productor","detail.submission_id":"ID de envío"}); +Object.assign(DASHBOARD_MESSAGES.de, {"execution_context.mission_title":"Missionstitel","execution_context.mission_lifecycle":"Missionslebenszyklus","execution_context.business_summary":"Geschäftszusammenfassung","execution_context.engineering_summary":"Engineering-Zusammenfassung","execution_context.current_intent":"Aktuelle Absicht","execution_context.current_engineering_action":"Aktuelle Engineering-Aktion","execution_context.execution_phase":"EP-Ausführungsphase","execution_context.planning_confidence":"Planungssicherheit","execution_context.current_iteration":"Aktuelle Iteration","execution_context.last_runtime_update":"Letztes Runtime-Update","execution_context.version":"Ausführungskontext-Version"}); +Object.assign(DASHBOARD_MESSAGES.fr, {"execution_context.mission_title":"Titre de la mission","execution_context.mission_lifecycle":"Cycle de vie de la mission","execution_context.business_summary":"Résumé métier","execution_context.engineering_summary":"Résumé d’ingénierie","execution_context.current_intent":"Intention actuelle","execution_context.current_engineering_action":"Action d’ingénierie actuelle","execution_context.execution_phase":"Phase d’exécution EP","execution_context.planning_confidence":"Confiance de planification","execution_context.current_iteration":"Itération actuelle","execution_context.last_runtime_update":"Dernière mise à jour runtime","execution_context.version":"Version du contexte d’exécution"}); +Object.assign(DASHBOARD_MESSAGES.es, {"execution_context.mission_title":"Título de la misión","execution_context.mission_lifecycle":"Ciclo de vida de la misión","execution_context.business_summary":"Resumen de negocio","execution_context.engineering_summary":"Resumen de ingeniería","execution_context.current_intent":"Intención actual","execution_context.current_engineering_action":"Acción de ingeniería actual","execution_context.execution_phase":"Fase de ejecución de EP","execution_context.planning_confidence":"Confianza de planificación","execution_context.current_iteration":"Iteración actual","execution_context.last_runtime_update":"Última actualización de runtime","execution_context.version":"Versión del contexto de ejecución"}); Object.assign(DASHBOARD_MESSAGES.de, {"execution_context.producer_host":"Forge-Host","execution_context.mission_revision":"Missionsrevision","execution_context.intent_id":"Intent-ID","execution_context.intent_revision":"Intent-Revision","execution_context.runtime_prompt_id":"Runtime-Prompt-ID","execution_context.runtime_prompt_digest":"Runtime-Prompt-Digest","execution_context.retry_of_correlation_id":"Wiederholung der Korrelations-ID"}); Object.assign(DASHBOARD_MESSAGES.fr, {"execution_context.producer_host":"Hôte Forge","execution_context.mission_revision":"Révision de mission","execution_context.intent_id":"ID d’intention","execution_context.intent_revision":"Révision d’intention","execution_context.runtime_prompt_id":"ID de prompt d’exécution","execution_context.runtime_prompt_digest":"Empreinte du prompt d’exécution","execution_context.retry_of_correlation_id":"Nouvelle tentative de l’ID de corrélation"}); Object.assign(DASHBOARD_MESSAGES.es, {"execution_context.producer_host":"Host de Forge","execution_context.mission_revision":"Revisión de misión","execution_context.intent_id":"ID de intención","execution_context.intent_revision":"Revisión de intención","execution_context.runtime_prompt_id":"ID de prompt de ejecución","execution_context.runtime_prompt_digest":"Resumen del prompt de ejecución","execution_context.retry_of_correlation_id":"Reintento del ID de correlación"}); diff --git a/src/engineering_platform/console_route_ownership.py b/src/engineering_platform/console_route_ownership.py index 818c7a1d..72c25819 100644 --- a/src/engineering_platform/console_route_ownership.py +++ b/src/engineering_platform/console_route_ownership.py @@ -57,7 +57,8 @@ def matches(self, method: str, path: str) -> bool: ConsoleRoute(("GET", "POST"), r"/api/central-database/configuration", PLATFORM, "server_settings", "Central database maintenance settings", True), ConsoleRoute(("GET",), r"/v1/operations/projects", PLATFORM, "operations", "Operations project listing"), ConsoleRoute(("GET",), r"/api/prompt-history", PROJECT, "project_history", "Project run history"), - ConsoleRoute(("GET",), r"/api/prompt-history/[a-z0-9][a-z0-9-]{0,63}/(?:report|chat|details)", PROJECT, "project_history", "Project run detail"), + ConsoleRoute(("GET",), r"/api/prompt-history/[a-z0-9][a-z0-9-]{0,63}/(?:report|analysis|chat|details)", PROJECT, "project_history", "Project run evidence detail"), + ConsoleRoute(("POST",), r"/api/prompt-history/[a-z0-9][a-z0-9-]{0,63}/analysis-retry", PROJECT, "project_history", "Project advisory analysis regeneration"), ConsoleRoute(("GET",), r"/api/telemetry/[0-9]{4}-[0-9]{2}-[0-9]{2}", PROJECT, "project_history", "Project telemetry detail"), ConsoleRoute(("POST",), r"/api/execution-(?:dismiss|retry)", PROJECT, "project_execution", "Project execution action"), ConsoleRoute(("POST",), r"/api/queue-disposition", PROJECT, "project_execution", "Project queue disposition action"), diff --git a/src/engineering_platform/parity_lifecycle_dispatcher.py b/src/engineering_platform/parity_lifecycle_dispatcher.py index 61ac6599..a4d479fc 100644 --- a/src/engineering_platform/parity_lifecycle_dispatcher.py +++ b/src/engineering_platform/parity_lifecycle_dispatcher.py @@ -17,6 +17,7 @@ import re import sqlite3 import uuid +from uuid import uuid4 from typing import Callable, Protocol from . import central_database, submission_service @@ -37,6 +38,7 @@ from .storage import ( CENTRAL_OPERATIONAL_DATABASE_ENVIRONMENT, ENGINEERING_STORAGE_SCHEMA_VERSION, + EngineeringStorageError, record_admission_decision, record_artifact, record_run_qualification_context, @@ -477,7 +479,45 @@ def _project_terminal_history(cls, repository_root: Path, state: TransactionStat created_at=_utcnow(), run_id=state.run_id, central_database=central_database.path(data_root), artifact_root=data_root / "artifacts", ) - analyze_terminal_report(repository_root, state.run_id, report) + try: + analysis = analyze_terminal_report( + repository_root, + state.run_id, + report, + output_directory=( + data_root / "artifacts" / "report-analysis" / state.run_id / uuid4().hex + ), + ) + artifact_id = f"report-analysis:{state.run_id}:{uuid4().hex}" + record_artifact( + repository_root, + analysis, + artifact_id=artifact_id, + artifact_type="ADVISORY_REPORT_ANALYSIS", + content_type="text/markdown", + created_at=_utcnow(), + run_id=state.run_id, + ep_run_id=state.run_id, + central_database=central_database.path(data_root), + artifact_root=data_root / "artifacts", + ) + log_event( + _lifecycle_logger(data_root), + logging.INFO, + "lifecycle_report_analysis_available", + run_id=state.run_id, + context={"artifact_id": artifact_id}, + ) + except (EngineeringStorageError, OSError, sqlite3.DatabaseError) as error: + # AI analysis is advisory. An unavailable analysis may never + # rewrite an already-terminal Engineering outcome. + log_event( + _lifecycle_logger(data_root), + logging.WARNING, + "lifecycle_report_analysis_unavailable", + run_id=state.run_id, + diagnostic=type(error).__name__, + ) # A terminal history row and the producer-facing terminal artifact are # independent durable projections. Reconciliation must repair the # latter even when the former was already indexed by an older runtime. diff --git a/src/engineering_platform/platform_version.py b/src/engineering_platform/platform_version.py index c1943f0c..a58d05d6 100644 --- a/src/engineering_platform/platform_version.py +++ b/src/engineering_platform/platform_version.py @@ -12,7 +12,7 @@ SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") CONTRACT = re.compile(r"^(\d{4})\.(0[1-9]|1[0-2])$") -CURRENT_PLATFORM_VERSION = "2.3.19" +CURRENT_PLATFORM_VERSION = "2.3.20" MANIFEST_FIELDS = frozenset( { "platform_version", diff --git a/src/engineering_platform/report_analysis.py b/src/engineering_platform/report_analysis.py index ebd6ab9f..5f7a252b 100644 --- a/src/engineering_platform/report_analysis.py +++ b/src/engineering_platform/report_analysis.py @@ -14,6 +14,9 @@ MAX_ANALYSIS_LENGTH = 8_000 MAX_REPORT_CONTEXT_LENGTH = 300_000 _RUN_ID = __import__("re").compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +RETRYABLE_REPORT_ANALYSIS_STATUSES = frozenset({ + "provider_failed", "provider_unavailable", "invalid_structured_response", +}) class AnalysisProcessingError(ValueError): @@ -108,8 +111,8 @@ def items(value: object) -> str: )[:MAX_ANALYSIS_LENGTH] -def _write(root: Path, run_id: str, value: str) -> Path: - directory = root / ".engineering" / "report-analysis" +def _write(root: Path, run_id: str, value: str, *, output_directory: Path | None = None) -> Path: + directory = output_directory or root / ".engineering" / "report-analysis" directory.mkdir(mode=0o700, parents=True, exist_ok=True) destination = directory / f"{run_id}.md" descriptor, temporary = tempfile.mkstemp(prefix=f".{run_id}.", suffix=".tmp", dir=directory) @@ -125,9 +128,21 @@ def _write(root: Path, run_id: str, value: str) -> Path: return destination -def _fallback(root: Path, run_id: str, status: str, summary: str) -> Path: +def _fallback( + root: Path, + run_id: str, + status: str, + summary: str, + *, + output_directory: Path | None = None, +) -> Path: """Write a controlled processing reason without persisting provider output.""" - return _write(root, run_id, _markdown({"summary": summary}, processing_status=status)) + return _write( + root, + run_id, + _markdown({"summary": summary}, processing_status=status), + output_directory=output_directory, + ) def _payload(output: str) -> dict[str, object]: @@ -156,14 +171,23 @@ def _bounded_report_context(report_text: str) -> str: ) -def analyze(root: Path, run_id: str, report: Path) -> Path: +def analyze( + root: Path, + run_id: str, + report: Path, + *, + output_directory: Path | None = None, +) -> Path: """Persist a bounded advisory analysis; failure never changes transaction outcome.""" if not _RUN_ID.fullmatch(run_id): raise ValueError("run_id is invalid") try: report_text = report.read_text(encoding="utf-8") except OSError: - return _fallback(root, run_id, "report_unavailable", "Engineeringrapport was niet beschikbaar voor analyse.") + return _fallback( + root, run_id, "report_unavailable", "Engineeringrapport was niet beschikbaar voor analyse.", + output_directory=output_directory, + ) prompt = """Analyseer uitsluitend het onderstaande Engineeringrapport. Voer geen commando's uit, wijzig geen bestanden, doe geen netwerkverzoeken en doe geen aannames buiten het rapport. Het resultaat is adviserend: repositorybewijs, commits, validatie en het terminale checkpoint zijn altijd leidend. Geef compacte, feitelijke Nederlandse tekst voor samenvatting, bevindingen, issues, risico's, volgende stappen en advies aan de Product Architect. Herhaal geen geheimen, promptinhoud of ruwe loguitvoer.\n\nENGINEERINGRAPPORT:\n""" + _bounded_report_context(report_text) schema_path: Path | None = None try: @@ -181,13 +205,22 @@ def analyze(root: Path, run_id: str, report: Path) -> Path: input_text=prompt, ) if completed.returncode: - return _fallback(root, run_id, "provider_failed", "Codex-analyse kon niet worden uitgevoerd. De Engineering-uitkomst blijft ongewijzigd.") + return _fallback( + root, run_id, "provider_failed", "Codex-analyse kon niet worden uitgevoerd. De Engineering-uitkomst blijft ongewijzigd.", + output_directory=output_directory, + ) raw = _payload(completed.stdout) - return _write(root, run_id, _markdown(raw)) + return _write(root, run_id, _markdown(raw), output_directory=output_directory) except OSError: - return _fallback(root, run_id, "provider_unavailable", "Codex-analyse kon niet worden uitgevoerd. De Engineering-uitkomst blijft ongewijzigd.") + return _fallback( + root, run_id, "provider_unavailable", "Codex-analyse kon niet worden uitgevoerd. De Engineering-uitkomst blijft ongewijzigd.", + output_directory=output_directory, + ) except AnalysisProcessingError: - return _fallback(root, run_id, "invalid_structured_response", "Codex-analyse kon niet veilig worden verwerkt. De Engineering-uitkomst blijft ongewijzigd.") + return _fallback( + root, run_id, "invalid_structured_response", "Codex-analyse kon niet veilig worden verwerkt. De Engineering-uitkomst blijft ongewijzigd.", + output_directory=output_directory, + ) finally: if schema_path is not None: schema_path.unlink(missing_ok=True) diff --git a/src/engineering_platform/server.py b/src/engineering_platform/server.py index 7ac37cf0..ccdac833 100644 --- a/src/engineering_platform/server.py +++ b/src/engineering_platform/server.py @@ -9,6 +9,7 @@ import argparse from dataclasses import asdict, dataclass from datetime import datetime, timezone +import hashlib from html import escape import http.server import json @@ -84,6 +85,7 @@ LocalProcessProvider, default_engineering_platform_codex_cli_prefix, ) +from .report_analysis import RETRYABLE_REPORT_ANALYSIS_STATUSES, analyze as analyze_terminal_report from .resources import package_path @@ -2147,6 +2149,7 @@ def execution_context( *, mission_id: str | None, action_id: str | None, + execution_phase: str | None, dispatch_state: str | None, updated_at: str | None, transport_receipt_id: str | None, @@ -2157,7 +2160,10 @@ def execution_context( "mission_id": mission_id, "current_intent": self.intent_id, "current_engineering_action": action_id, - "execution_phase": dispatch_state, + # The run state is owned by the Execution Host. The separately + # persisted dispatcher state describes its FIFO orchestration and + # may legitimately differ while a run is being finalized. + "execution_phase": execution_phase, "last_runtime_update": updated_at, "dispatcher_state": dispatch_state, "execution_receipt_reference": transport_receipt_id, @@ -2194,11 +2200,13 @@ def _central_run_record(row: sqlite3.Row, project_id: str) -> dict[str, object]: mission_id = _central_text(row["mission_id"]) or (forge.mission_id if forge else None) action_id = _central_text(row["engineering_action_id"]) or (forge.action_id if forge else None) correlation_id = _central_text(row["correlation_id"]) or (forge.correlation_id if forge else None) - dispatch_state = _central_text(row["dispatch_state"]) or _central_text(row["run_state"]) + execution_phase = _central_text(row["run_state"]) + dispatch_state = _central_text(row["dispatch_state"]) or execution_phase updated_at = _central_text(row["updated_at"]) execution_context = forge.execution_context( mission_id=mission_id, action_id=action_id, + execution_phase=execution_phase, dispatch_state=dispatch_state, updated_at=updated_at, transport_receipt_id=_central_text(row["transport_receipt_id"]), @@ -2236,6 +2244,7 @@ def _central_run_record(row: sqlite3.Row, project_id: str) -> dict[str, object]: "tracked_file_count": None, "execution_metadata": {}, "execution_context": execution_context, + "analysis_available": bool(row["analysis_available"]), "operator_resolution": _central_text(row["operator_resolution"]) or "NONE", } @@ -2249,7 +2258,14 @@ def _central_console_run_records(data_root: Path, project_id: str) -> list[dict[ d.submission_id,d.state AS dispatch_state,d.operator_resolution, s.repository_id,s.producer_id,s.producer_type,s.producer_version, s.constraints,s.correlation_id,s.mission_id,s.engineering_action_id, - s.transport_receipt_id + s.transport_receipt_id, + EXISTS ( + SELECT 1 FROM execution_artifact_records AS a + WHERE a.artifact_type='ADVISORY_REPORT_ANALYSIS' + AND a.content_type='text/markdown' + AND a.integrity_status='VERIFIED' + AND (a.run_id=r.run_id OR a.ep_run_id=r.run_id) + ) AS analysis_available FROM ep_execution_runs AS r LEFT JOIN ep_parity_lifecycle_dispatches AS d ON d.run_id=r.run_id LEFT JOIN ep_submissions AS s ON s.submission_id=d.submission_id @@ -2410,8 +2426,8 @@ def aggregate(values: list[int]) -> dict[str, int] | None: } -def _central_console_report(data_root: Path, project_id: str, run_id: str) -> bytes | None: - """Read one CENTRAL-indexed immutable report with project authorization.""" +def _central_console_report_path(data_root: Path, project_id: str, run_id: str) -> Path | None: + """Resolve one project-scoped CENTRAL report without a checkout fallback.""" with sqlite3.connect(data_root / SERVER_DATABASE_FILENAME) as connection: row = connection.execute( """SELECT h.report_path FROM prompt_execution_history AS h @@ -2424,11 +2440,95 @@ def _central_console_report(data_root: Path, project_id: str, run_id: str) -> by candidate = (data_root / "artifacts" / row[0].removeprefix("CENTRAL:")).resolve() try: candidate.relative_to((data_root / "artifacts").resolve()) - return candidate.read_bytes() if candidate.is_file() else None + return candidate if candidate.is_file() else None + except (OSError, ValueError): + return None + + +def _central_console_report(data_root: Path, project_id: str, run_id: str) -> bytes | None: + """Read one CENTRAL-indexed immutable report with project authorization.""" + path = _central_console_report_path(data_root, project_id, run_id) + try: + return path.read_bytes() if path is not None else None + except OSError: + return None + + +def _central_console_analysis_path(data_root: Path, project_id: str, run_id: str) -> Path | None: + """Resolve the latest verified advisory analysis for exactly one project run.""" + with sqlite3.connect(data_root / SERVER_DATABASE_FILENAME) as connection: + row = connection.execute( + """SELECT a.digest_algorithm,a.digest,a.storage_location + FROM execution_artifact_records AS a + WHERE a.artifact_type='ADVISORY_REPORT_ANALYSIS' + AND a.content_type='text/markdown' + AND a.integrity_status='VERIFIED' + AND (a.run_id=? OR a.ep_run_id=?) + AND EXISTS ( + SELECT 1 FROM ep_parity_lifecycle_dispatches AS d + WHERE d.project_id=? AND d.run_id=? + ) + ORDER BY a.created_at DESC,a.artifact_id DESC LIMIT 1""", + (run_id, run_id, project_id, run_id), + ).fetchone() + if row is None or row[0] != "sha256" or not isinstance(row[1], str) or not isinstance(row[2], str): + return None + candidate = (data_root / "artifacts" / row[2]).resolve() + try: + candidate.relative_to((data_root / "artifacts").resolve()) + if not candidate.is_file() or hashlib.sha256(candidate.read_bytes()).hexdigest() != row[1]: + return None + return candidate except (OSError, ValueError): return None +def _central_console_analysis(data_root: Path, project_id: str, run_id: str) -> bytes | None: + """Read only a verified, project-scoped advisory analysis artifact.""" + path = _central_console_analysis_path(data_root, project_id, run_id) + try: + return path.read_bytes() if path is not None else None + except OSError: + return None + + +def _central_analysis_processing_status(value: bytes | None) -> str | None: + """Parse the fixed status line without treating an analysis as a protocol.""" + if value is None: + return None + match = re.search(r"(?m)^- Status: `([a-z_]+)`$", value.decode("utf-8", errors="replace")) + return match.group(1) if match else None + + +def _retry_central_console_analysis(data_root: Path, project_id: str, run_id: str) -> bytes: + """Regenerate a retryable advisory analysis using CENTRAL evidence only.""" + if _central_analysis_processing_status(_central_console_analysis(data_root, project_id, run_id)) not in RETRYABLE_REPORT_ANALYSIS_STATUSES: + raise ValueError("ANALYSIS_RETRY_UNAVAILABLE") + report = _central_console_report_path(data_root, project_id, run_id) + if report is None: + raise ValueError("REPORT_NOT_FOUND") + analysis = analyze_terminal_report( + data_root, + run_id, + report, + output_directory=data_root / "artifacts" / "report-analysis" / run_id / uuid4().hex, + ) + artifact_id = f"report-analysis:{run_id}:{uuid4().hex}" + storage.record_artifact( + data_root, + analysis, + artifact_id=artifact_id, + artifact_type="ADVISORY_REPORT_ANALYSIS", + content_type="text/markdown", + created_at=datetime.now(timezone.utc).isoformat(), + run_id=run_id, + ep_run_id=run_id, + central_database=central_database.path(data_root), + artifact_root=data_root / "artifacts", + ) + return analysis.read_bytes() + + def _central_console_chat_history(data_root: Path, project_id: str, run_id: str) -> list[dict[str, object]] | None: """Return a project-authorized CENTRAL transcript; no root fallback exists.""" with sqlite3.connect(data_root / SERVER_DATABASE_FILENAME) as connection: @@ -3807,6 +3907,21 @@ def _delegate_dashboard(self, method: str) -> None: self.end_headers() self.wfile.write(content) return + analysis_match = re.fullmatch(r"/api/prompt-history/([a-z0-9][a-z0-9-]{0,63})/analysis", request.path) + if analysis_match: + content = _central_console_analysis(self.server.data_root, selected, analysis_match.group(1)) # type: ignore[attr-defined] + if content is None: + self._send(404, {"error": "ANALYSIS_NOT_FOUND"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/markdown; charset=utf-8") + self.send_header("Content-Disposition", _report_content_disposition(analysis_match.group(1))) + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(content) + return chat_match = re.fullmatch(r"/api/prompt-history/([a-z0-9][a-z0-9-]{0,63})/chat", request.path) if chat_match: messages = _central_console_chat_history(self.server.data_root, selected, chat_match.group(1)) # type: ignore[attr-defined] @@ -3957,6 +4072,42 @@ def _delegate_dashboard(self, method: str) -> None: self._send(400, {"error": "INVALID_REQUEST"}) return if isinstance(selected, str) and selected in project_ids: + analysis_retry_match = re.fullmatch( + r"/api/prompt-history/([a-z0-9][a-z0-9-]{0,63})/analysis-retry", + request.path, + ) + if method == "do_POST" and analysis_retry_match: + if not _same_origin(self.headers): + self._send(403, {"error": "INVALID_ORIGIN"}) + return + try: + if self.rfile.read(int(self.headers.get("Content-Length", "0"))) != b"{}": + raise ValueError("INVALID_REQUEST") + content = _retry_central_console_analysis( + self.server.data_root, # type: ignore[attr-defined] + selected, + analysis_retry_match.group(1), + ) + except ValueError: + self._send(409, {"error": "ANALYSIS_RETRY_UNAVAILABLE"}) + return + except (OSError, sqlite3.DatabaseError, storage.EngineeringStorageError): + self._send(503, {"error": "ANALYSIS_RETRY_FAILED"}) + return + _audit_dashboard_action( + self.server.data_root, # type: ignore[attr-defined] + action="prompt_history_analysis_regenerated", + project_id=selected, + run_id=analysis_retry_match.group(1), + ) + self.send_response(200) + self.send_header("Content-Type", "text/markdown; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(content) + return # No supported CENTRAL Console route may fall through to the # retained dashboard handler. New routes must be added above # with an explicit Server/CENTRAL authority classification. diff --git a/src/engineering_platform/server_console_services.py b/src/engineering_platform/server_console_services.py index 59e90e32..8ae36008 100644 --- a/src/engineering_platform/server_console_services.py +++ b/src/engineering_platform/server_console_services.py @@ -55,7 +55,10 @@ from .codex_capacity import read_remaining_percent from .telemetry import clear_telemetry, daily_statistics, daily_timing_detail, execution_timing, prune_telemetry from .prompt_history import prompt_history, report_for_prompt_history, report_path_for_prompt_history -from .report_analysis import analyze as analyze_terminal_report +from .report_analysis import ( + RETRYABLE_REPORT_ANALYSIS_STATUSES, + analyze as analyze_terminal_report, +) from .recommendation_handoff import handoff_from_report from .storage import ( EngineeringStorageError, @@ -80,9 +83,6 @@ LOOPBACK_ADDRESS = "127.0.0.1" CODEX_PROCESS = re.compile(r"(?:^|\s)(?:\S*/)?codex(?:\s|$)") RATE_LIMIT_CACHE_SECONDS = 60 -RETRYABLE_REPORT_ANALYSIS_STATUSES = frozenset({ - "provider_failed", "provider_unavailable", "invalid_structured_response", -}) _REPORT_ANALYSIS_RETRY_LOCK = Lock() _REPORT_ANALYSIS_RETRY_RUNS: set[str] = set() diff --git a/src/engineering_platform/templates/workspace-config.json b/src/engineering_platform/templates/workspace-config.json index ede7e2ba..74b88acf 100644 --- a/src/engineering_platform/templates/workspace-config.json +++ b/src/engineering_platform/templates/workspace-config.json @@ -3,7 +3,7 @@ "platform": { "id": "engineering-platform", "name": "Engineering Platform", - "version": "2.3.19", + "version": "2.3.20", "generation": 2, "documentation_namespace": "engineering-platform", "capability_registry_version": 1 diff --git a/tests/engineering/dashboard.spec.mjs b/tests/engineering/dashboard.spec.mjs index 6b99fc83..af95682a 100644 --- a/tests/engineering/dashboard.spec.mjs +++ b/tests/engineering/dashboard.spec.mjs @@ -2671,6 +2671,9 @@ test.describe("Engineering Status browser smoke", () => { intent_revision: "1", runtime_prompt_id: "prompt-0006", runtime_prompt_digest: "sha256:aaaaaaaa", + execution_phase: "RUNNING", + dispatcher_state: "RUNNING", + last_runtime_update: "2026-09-12T12:40:55.376838+00:00", }, lifecycle: { available: true, @@ -2685,6 +2688,11 @@ test.describe("Engineering Status browser smoke", () => { await expect(page.locator("#currentRun")).toHaveAttribute("open", ""); await expect(page.locator("#executionContext")).toContainText("MISSION-0006"); await expect(page.locator("#executionContext")).toContainText("forge-host-alpha"); + await expect(page.locator("#executionContext")).toContainText("EP-uitvoeringsfase"); + await expect(page.locator("#executionContext")).toContainText("In uitvoering"); + await expect(page.locator("#executionContext")).toContainText("12 september 2026"); + await expect(page.locator("#executionContext")).not.toContainText("2026-09-12T12:40:55.376838+00:00"); + await expect(page.locator("#executionContext .execution-context__phase")).toHaveCount(0); await expect(page.locator(".execution-lifecycle")).toHaveCount(1); await expect(page.locator("#executionContext")).toHaveCount(1); @@ -6535,14 +6543,13 @@ test.describe("Engineering Status browser smoke", () => { await expect(page.locator("#indicator")).toHaveCSS("animation-iteration-count", "infinite"); await expect(page.locator("#indicator")).toHaveCSS("border-top-color", "rgb(240, 182, 106)"); await expect(page.locator("#indicator")).toHaveCSS("will-change", "transform"); - const elapsed = await page.locator("#indicator").evaluate(async (element) => { - const [animation] = element.getAnimations(); - if (!animation) return null; - const before = Number(animation.currentTime); - await new Promise((resolve) => window.setTimeout(resolve, 150)); - return Number(animation.currentTime) - before; - }); - expect(elapsed).toBeGreaterThan(100); + // This test explicitly requests reduced motion. The visual animation + // contract stays present, but the browser may correctly freeze its clock + // for an accessibility preference; elapsed animation time is therefore + // not a portable assertion here. + expect(await page.locator("#indicator").evaluate((element) => + element.getAnimations().some((animation) => animation.animationName === "github-activity-ring"), + )).toBe(true); }); test("loads the initial status before serverpush connects", async ({ page }) => { @@ -7846,7 +7853,17 @@ test.describe("Engineering Status browser smoke", () => { }); test("confirms a relay restart from fresh health when its own connection closes", async ({ page }) => { + let restartRequested = false; + await page.route("**/health", (route) => route.fulfill({ json: { components: { + dashboard_relay: { healthy: true, uptime_seconds: restartRequested ? 1 : 480 }, + } } })); + await page.route("**/api/components/dashboard_relay/restart", (route) => { + restartRequested = true; + return route.abort("failed"); + }); + const initialHealth = page.waitForResponse("**/health"); await page.goto(dashboardUrl, { waitUntil: "domcontentloaded" }); + await initialHealth; await page.evaluate(() => { renderPlatformHealth({ components: { dashboard_relay: { healthy: true, uptime_seconds: 480 }, @@ -7859,12 +7876,6 @@ test.describe("Engineering Status browser smoke", () => { restart_supported: true, }); }); - await page.route("**/api/components/dashboard_relay/restart", (route) => - route.abort("failed"), - ); - await page.route("**/health", (route) => route.fulfill({ json: { components: { - dashboard_relay: { healthy: true, uptime_seconds: 1 }, - } } })); await page.locator("#componentModalRestart").click(); await page.locator("#confirmationModalConfirm").click(); @@ -9508,6 +9519,17 @@ test.describe("Engineering Status browser smoke", () => { await expect(page.locator("#promptHistoryReportContent")).toContainText("Opnieuw gegenereerd advies."); await expect(page.locator("#promptHistoryReportRetry")).toBeHidden(); await page.locator("#promptHistoryReportClose").click(); + await page.unroute("**/api/prompt-history/**/analysis"); + await page.route("**/api/prompt-history/**/analysis", (route) => route.fulfill({ + contentType: "application/json", + body: '{"error":"CENTRAL_CONSOLE_ROUTE_UNAVAILABLE"}', + })); + await analysisView.click(); + await expect(page.locator("#promptHistoryReportContent")) + .toHaveText(DASHBOARD_MESSAGES.nl["history.analysis_unavailable"]); + await expect(page.locator("#promptHistoryReportContent")).not.toContainText("CENTRAL_CONSOLE_ROUTE_UNAVAILABLE"); + await expect(page.locator("#promptHistoryReportContent")).not.toContainText("{"); + await page.locator("#promptHistoryReportClose").click(); const chat = page.locator("#promptHistoryRows .prompt-history-chat"); await expect(chat).toHaveCount(1); await expect(chat).toHaveText("⋯"); diff --git a/tests/engineering/postman/ep-server-http-surface.postman_collection.json b/tests/engineering/postman/ep-server-http-surface.postman_collection.json index 323187a8..8b5e3af3 100644 --- a/tests/engineering/postman/ep-server-http-surface.postman_collection.json +++ b/tests/engineering/postman/ep-server-http-surface.postman_collection.json @@ -688,6 +688,55 @@ } ] }, + { + "name": "Missing project advisory analysis", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/prompt-history/missing-run/analysis?project=postman-project" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('expected status', () => pm.response.to.have.status(404));" + ] + } + } + ] + }, + { + "name": "Unavailable advisory analysis retry", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Origin", + "value": "{{baseUrl}}" + } + ], + "url": "{{baseUrl}}/api/prompt-history/missing-run/analysis-retry?project=postman-project", + "body": { + "mode": "raw", + "raw": "{}" + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('expected status', () => pm.response.to.have.status(409));" + ] + } + } + ] + }, { "name": "Missing project chat", "request": { diff --git a/tests/engineering/test_console_route_ownership.py b/tests/engineering/test_console_route_ownership.py index 2908cd59..21b5018d 100644 --- a/tests/engineering/test_console_route_ownership.py +++ b/tests/engineering/test_console_route_ownership.py @@ -21,6 +21,13 @@ def test_platform_routes_keep_owner_when_project_is_selected(self) -> None: for method, path in (("GET", "/api/provider-login-status"), ("POST", "/api/provider-login/repair"), ("GET", "/api/execution-runtime-status"), ("POST", "/api/execution-runtime/repair"), ("GET", "/api/components/file_inbox_ingress/details"), ("POST", "/api/components/dashboard_relay/restart"), ("GET", "/api/logs/all"), ("GET", "/api/configuration")): self.assertEqual(route_owner(method, path).owner, PLATFORM, path) + def test_central_advisory_analysis_routes_are_project_scoped(self) -> None: + for method, path in ( + ("GET", "/api/prompt-history/run-a/analysis"), + ("POST", "/api/prompt-history/run-a/analysis-retry"), + ): + self.assertEqual(route_owner(method, path).owner, PROJECT, path) + def test_retired_component_log_routes_cannot_become_project_routes(self) -> None: for method, path in (("GET", "/api/logs/inbox"), ("POST", "/api/logs/dashboard")): self.assertEqual(route_owner(method, path).owner, HISTORICAL_UNREACHABLE, path) diff --git a/tests/engineering/test_coverage_runtime_boundaries.py b/tests/engineering/test_coverage_runtime_boundaries.py index 318eb830..f3c5c720 100644 --- a/tests/engineering/test_coverage_runtime_boundaries.py +++ b/tests/engineering/test_coverage_runtime_boundaries.py @@ -787,7 +787,7 @@ def test_selected_project_console_reads_remain_central_when_checkout_is_absent(s snapshots = {"runs": [{"run_id": "run-a"}]} routes = ( "/", "/api/configuration", "/api/dashboard-snapshot", "/api/status", - "/api/prompt-history", "/api/prompt-history/run-a/report", + "/api/prompt-history", "/api/prompt-history/run-a/report", "/api/prompt-history/run-a/analysis", "/api/prompt-history/run-a/chat", "/api/prompt-history/run-a/details", "/api/telemetry/2026-01-01", ) @@ -796,6 +796,8 @@ def test_selected_project_console_reads_remain_central_when_checkout_is_absent(s ), patch("engineering_platform.server._central_console_configuration", return_value={"scope": "CENTRAL"}), patch( "engineering_platform.server._central_console_project_snapshot", return_value=snapshots ), patch("engineering_platform.server._central_console_report", return_value=b"# central report"), patch( + "engineering_platform.server._central_console_analysis", return_value=b"# central analysis" + ), patch( "engineering_platform.server._central_console_chat_history", return_value=[{"role": "user"}] ), patch("engineering_platform.server._central_console_run_detail", return_value={"run_id": "run-a"}), patch( "engineering_platform.server._central_console_telemetry_detail", return_value={"date": "2026-01-01"} @@ -814,6 +816,34 @@ def test_selected_project_console_reads_remain_central_when_checkout_is_absent(s ): missing._delegate_dashboard("do_GET") self.assertEqual(responses[-1], (404, {"error": "REPORT_NOT_FOUND"})) + missing_analysis, responses = self._in_process_console_handler( + "/api/prompt-history/run-a/analysis", headers={"X-Engineering-Platform-Project": "project-a"} + ) + missing_analysis._central_database_configuration = lambda _method: False + with patch("engineering_platform.server._console_projects", return_value=projects), patch( + "engineering_platform.server._central_console_analysis", return_value=None + ): + missing_analysis._delegate_dashboard("do_GET") + self.assertEqual(responses[-1], (404, {"error": "ANALYSIS_NOT_FOUND"})) + retry_body = b"{}" + retry, responses = self._in_process_console_handler( + "/api/prompt-history/run-a/analysis-retry", + body=retry_body, + headers={"X-Engineering-Platform-Project": "project-a", "Content-Length": str(len(retry_body))}, + ) + retry._central_database_configuration = lambda _method: False + with patch("engineering_platform.server._console_projects", return_value=projects), patch( + "engineering_platform.server._retry_central_console_analysis", return_value=b"# Central analysis\n" + ) as regenerate, patch("engineering_platform.server._audit_dashboard_action") as audit: + retry._delegate_dashboard("do_POST") + self.assertEqual(retry.wfile.getvalue(), b"# Central analysis\n") + regenerate.assert_called_once_with(self.root, "project-a", "run-a") + audit.assert_called_once_with( + self.root, + action="prompt_history_analysis_regenerated", + project_id="project-a", + run_id="run-a", + ) def test_console_log_routes_are_central_and_mutations_reject_bad_origins(self) -> None: with patch("engineering_platform.server._central_console_component_logs", return_value={"entries": [{"event": "ready"}]}), patch( @@ -1584,7 +1614,7 @@ def test_central_forge_console_adapter_never_exposes_raw_constraints(self) -> No self.assertEqual(provenance.host_id, "forge-host-alpha") self.assertEqual(provenance.runtime_prompt_id, "prompt-0006") self.assertNotIn("unrelated_prompt_text", provenance.execution_context( - mission_id=None, action_id=None, dispatch_state="RUNNING", updated_at="now", transport_receipt_id=None, + mission_id=None, action_id=None, execution_phase="RUNNING", dispatch_state="RUNNING", updated_at="now", transport_receipt_id=None, )) versioned = server._CentralForgeProvenance.from_constraints(json.dumps({ "forge_execution": { @@ -1595,7 +1625,7 @@ def test_central_forge_console_adapter_never_exposes_raw_constraints(self) -> No self.assertIsNotNone(versioned) assert versioned is not None versioned_context = versioned.execution_context( - mission_id=None, action_id=None, dispatch_state="RUNNING", updated_at="now", transport_receipt_id=None, + mission_id=None, action_id=None, execution_phase="RUNNING", dispatch_state="RUNNING", updated_at="now", transport_receipt_id=None, ) self.assertEqual(versioned_context["producer_contract_version"], "1.0") self.assertEqual(versioned_context["forge_application_version"], "2.7.2") diff --git a/tests/engineering/test_parity_lifecycle_dispatcher.py b/tests/engineering/test_parity_lifecycle_dispatcher.py index 1df8ab16..639ecd19 100644 --- a/tests/engineering/test_parity_lifecycle_dispatcher.py +++ b/tests/engineering/test_parity_lifecycle_dispatcher.py @@ -524,6 +524,9 @@ def test_terminal_dispatch_projects_the_preserved_console_history(self) -> None: report.return_value = self.data / "artifacts" / "projects" / "alpha" / "runs" / "terminal.md" report.return_value.parent.mkdir(parents=True, exist_ok=True) report.return_value.write_text("terminal", encoding="utf-8") + analyze.return_value = self.data / "artifacts" / "report-analysis" / "terminal.md" + analyze.return_value.parent.mkdir(parents=True, exist_ok=True) + analyze.return_value.write_text("# analysis\n", encoding="utf-8") receipt = dispatcher.dispatch(submission) self.assertEqual(receipt.state, "COMPLETE") record.assert_called_once() @@ -532,7 +535,15 @@ def test_terminal_dispatch_projects_the_preserved_console_history(self) -> None: record.call_args.kwargs["central_database"], (self.data / server.SERVER_DATABASE_FILENAME).resolve(), ) - analyze.assert_called_once_with(self.roots["alpha"].resolve(), receipt.run_id, report.return_value) + analyze.assert_called_once() + self.assertEqual(analyze.call_args.args, (self.roots["alpha"].resolve(), receipt.run_id, report.return_value)) + self.assertIsInstance(analyze.call_args.kwargs["output_directory"], Path) + with sqlite3.connect(self.data / server.SERVER_DATABASE_FILENAME) as connection: + analysis = connection.execute( + "SELECT artifact_type,content_type,ep_run_id FROM execution_artifact_records " + "WHERE artifact_type='ADVISORY_REPORT_ANALYSIS'" + ).fetchone() + self.assertEqual(analysis, ("ADVISORY_REPORT_ANALYSIS", "text/markdown", receipt.run_id)) def test_terminal_history_reconciliation_ignores_a_retained_row_without_a_local_checkpoint(self) -> None: with sqlite3.connect(self.data / server.SERVER_DATABASE_FILENAME) as connection: diff --git a/tools/qualification/console_route_ownership_guard.py b/tools/qualification/console_route_ownership_guard.py index 52b13fb5..d4831418 100644 --- a/tools/qualification/console_route_ownership_guard.py +++ b/tools/qualification/console_route_ownership_guard.py @@ -16,7 +16,8 @@ ("GET", "/api/execution-runtime-status"), ("POST", "/api/execution-runtime/repair"), ("GET", "/api/host-admin/diagnostics"), ("GET", "/api/configuration"), ("POST", "/api/configuration"), - ("GET", "/api/prompt-history"), ("POST", "/api/execution-retry"), + ("GET", "/api/prompt-history"), ("GET", "/api/prompt-history/example-run/analysis"), + ("POST", "/api/prompt-history/example-run/analysis-retry"), ("POST", "/api/execution-retry"), ("GET", "/healthz"), ("POST", "/api/runtime-directory/open"), ) _PLATFORM_DISPATCH_MARKERS = ( diff --git a/tools/qualification/ep_server_postman_surface.py b/tools/qualification/ep_server_postman_surface.py index 36e77ced..12b39036 100644 --- a/tools/qualification/ep_server_postman_surface.py +++ b/tools/qualification/ep_server_postman_surface.py @@ -58,6 +58,8 @@ class Case: Case("Central database configuration rejects bad request", "POST", "/api/central-database/configuration", 400, "{}"), Case("Project history", "GET", "/api/prompt-history", 200, project=True), Case("Missing project report", "GET", "/api/prompt-history/missing-run/report", 404, project=True), + Case("Missing project advisory analysis", "GET", "/api/prompt-history/missing-run/analysis", 404, project=True), + Case("Unavailable advisory analysis retry", "POST", "/api/prompt-history/missing-run/analysis-retry", 409, "{}", True, True), Case("Missing project chat", "GET", "/api/prompt-history/missing-run/chat", 404, project=True), Case("Missing project detail", "GET", "/api/prompt-history/missing-run/details", 404, project=True), Case("Missing telemetry day", "GET", "/api/telemetry/2026-01-01", 404, project=True), From bc60a3e524d563469d5a75b3b4a8340df45999ee Mon Sep 17 00:00:00 2001 From: pcvantol Date: Sat, 12 Sep 2026 17:08:53 +0200 Subject: [PATCH 2/2] test: cover central advisory analysis recovery --- .../test_coverage_runtime_boundaries.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/engineering/test_coverage_runtime_boundaries.py b/tests/engineering/test_coverage_runtime_boundaries.py index f3c5c720..cf29c46a 100644 --- a/tests/engineering/test_coverage_runtime_boundaries.py +++ b/tests/engineering/test_coverage_runtime_boundaries.py @@ -845,6 +845,46 @@ def test_selected_project_console_reads_remain_central_when_checkout_is_absent(s run_id="run-a", ) + def test_central_analysis_retry_only_uses_verified_central_evidence(self) -> None: + """The recovery path never falls back to a project checkout or raw output.""" + unavailable = b"# Codex-analyse\n\n- Status: `provider_unavailable`\n" + self.assertEqual(server._central_analysis_processing_status(unavailable), "provider_unavailable") + self.assertIsNone(server._central_analysis_processing_status(b"# zonder vaste status")) + self.assertIsNone(server._central_analysis_processing_status(None)) + + with patch("engineering_platform.server._central_console_analysis", return_value=b"# analysis\n- Status: `processed`\n"): + with self.assertRaisesRegex(ValueError, "ANALYSIS_RETRY_UNAVAILABLE"): + server._retry_central_console_analysis(self.root, "project-a", "run-a") + + report = self.root / "artifacts" / "reports" / "run-a.md" + report.parent.mkdir(parents=True) + report.write_text("# Engineeringrapport\n", encoding="utf-8") + replacement = self.root / "artifacts" / "report-analysis" / "run-a" / "retry" / "run-a.md" + replacement.parent.mkdir(parents=True) + replacement.write_text("# Veilige centrale analyse\n", encoding="utf-8") + with patch("engineering_platform.server._central_console_analysis", return_value=unavailable), patch( + "engineering_platform.server._central_console_report_path", return_value=report + ), patch("engineering_platform.server.analyze_terminal_report", return_value=replacement) as analyze, patch( + "engineering_platform.server.storage.record_artifact" + ) as record: + self.assertEqual( + server._retry_central_console_analysis(self.root, "project-a", "run-a"), + b"# Veilige centrale analyse\n", + ) + analyze.assert_called_once() + self.assertEqual(analyze.call_args.args, (self.root, "run-a", report)) + self.assertTrue(str(analyze.call_args.kwargs["output_directory"]).startswith(str(self.root / "artifacts"))) + record.assert_called_once() + self.assertEqual(record.call_args.kwargs["artifact_type"], "ADVISORY_REPORT_ANALYSIS") + self.assertEqual(record.call_args.kwargs["content_type"], "text/markdown") + self.assertEqual(record.call_args.kwargs["ep_run_id"], "run-a") + + with patch("engineering_platform.server._central_console_analysis", return_value=unavailable), patch( + "engineering_platform.server._central_console_report_path", return_value=None + ): + with self.assertRaisesRegex(ValueError, "REPORT_NOT_FOUND"): + server._retry_central_console_analysis(self.root, "project-a", "run-a") + def test_console_log_routes_are_central_and_mutations_reject_bad_origins(self) -> None: with patch("engineering_platform.server._central_console_component_logs", return_value={"entries": [{"event": "ready"}]}), patch( "engineering_platform.server._console_projects", return_value=[]