diff --git a/.github/workflows/headless-embedding-contract.yml b/.github/workflows/headless-embedding-contract.yml index 943faad8e..fb53a8088 100644 --- a/.github/workflows/headless-embedding-contract.yml +++ b/.github/workflows/headless-embedding-contract.yml @@ -25,7 +25,12 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Install C# UniFFI generator - run: cargo install uniffi-bindgen-cs --version 0.9.0 --locked + # Official v0.9.0+v0.28.3 source; this version is not on crates.io. + run: >- + cargo install uniffi-bindgen-cs + --git https://github.com/NordSecurity/uniffi-bindgen-cs + --rev 2f4880f03ed08d960ad0ee14d11cf95444eee540 + --locked - name: Run workflow service tests run: cargo test -p pantograph-workflow-service diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 13f299463..6481af342 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Node toolchain uses: actions/setup-node@v4 @@ -30,7 +32,36 @@ jobs: - name: Install npm dependencies run: npm ci --include=optional + - name: Test decision traceability gate + run: node --test scripts/check-decision-traceability.test.mjs + + - name: Resolve explicit traceability range + id: traceability-range + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + EVENT_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + base="$(git rev-parse --verify --end-of-options "${EVENT_BASE}^{commit}")" + head="$(git rev-parse --verify --end-of-options "${EVENT_HEAD}^{commit}")" + if [[ "$EVENT_NAME" == pull_request ]]; then + # The PR owns changes since its fork point, not reverse changes + # from an independently advanced target branch. + base="$(git merge-base --all "$base" "$head")" + if [[ -z "$base" || "$base" == *$'\n'* ]]; then + echo "Traceability requires one unambiguous PR merge base" >&2 + exit 1 + fi + fi + printf 'base=%s\nhead=%s\n' "$base" "$head" >> "$GITHUB_OUTPUT" + - name: Run no-new-debt lint gate + env: + TRACEABILITY_MODE: range + TRACEABILITY_BASE_REF: ${{ steps.traceability-range.outputs.base }} + TRACEABILITY_HEAD_REF: ${{ steps.traceability-range.outputs.head }} run: npm run lint:no-new typecheck: @@ -97,21 +128,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Ubuntu native desktop dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - libayatana-appindicator3-dev \ - libglib2.0-dev \ - libgtk-3-dev \ - libjavascriptcoregtk-4.1-dev \ - librsvg2-dev \ - libsoup-3.0-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev \ - pkg-config + - name: Install Ubuntu native build dependencies + run: ./scripts/install-ubuntu-build-dependencies.sh - name: Checkout Pumas-Library uses: actions/checkout@v4 @@ -160,10 +178,18 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.92.0 + with: + components: clippy - name: Cache Cargo uses: Swatinem/rust-cache@v2 + - name: Run scheduler contract and accessor compatibility tests + run: cargo test -p pantograph-scheduler + + - name: Check scheduler standard-trait accessor lint + run: cargo clippy -p pantograph-scheduler --all-targets -- -D clippy::should_implement_trait + - name: Run node-engine tests run: cargo test -p node-engine --lib @@ -292,21 +318,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Ubuntu native desktop dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - libayatana-appindicator3-dev \ - libglib2.0-dev \ - libgtk-3-dev \ - libjavascriptcoregtk-4.1-dev \ - librsvg2-dev \ - libsoup-3.0-dev \ - libssl-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev \ - pkg-config + - name: Install Ubuntu native build dependencies + run: ./scripts/install-ubuntu-build-dependencies.sh - name: Checkout Pumas-Library uses: actions/checkout@v4 diff --git a/.github/workflows/runtime-separation-check.yml b/.github/workflows/runtime-separation-check.yml index 5907ec0a6..97611c2dc 100644 --- a/.github/workflows/runtime-separation-check.yml +++ b/.github/workflows/runtime-separation-check.yml @@ -13,6 +13,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install Ubuntu native build dependencies + run: ./scripts/install-ubuntu-build-dependencies.sh + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@1.92.0 diff --git a/bindings/beam/pantograph_native_smoke/README.md b/bindings/beam/pantograph_native_smoke/README.md index caa987685..b13dc7b5a 100644 --- a/bindings/beam/pantograph_native_smoke/README.md +++ b/bindings/beam/pantograph_native_smoke/README.md @@ -38,6 +38,11 @@ The same runner is used by the required `Rustler BEAM smoke` job in - The local `Pantograph.Native` shim defines generated NIF stubs for the full default Rustler export surface so `:erlang.load_nif/2` can load the compiled library before individual smoke tests call the narrower contract under test. + This includes the package-facts snapshot (arity 3), individual summary (arity 2), + and update-cursor query (arity 3) exports. Their stubs permit native loading; + this smoke harness does not claim model-library query behavior coverage. + The seven `frontend-http` exports are feature-gated and outside this default + build's export surface. - This harness now also pins a backend-owned validation error path by asserting that unknown edge endpoints round-trip back to BEAM as validation strings through `workflow_validate/1`. diff --git a/bindings/beam/pantograph_native_smoke/lib/pantograph/native.ex b/bindings/beam/pantograph_native_smoke/lib/pantograph/native.ex index f1b302ac9..88f3796d0 100644 --- a/bindings/beam/pantograph_native_smoke/lib/pantograph/native.ex +++ b/bindings/beam/pantograph_native_smoke/lib/pantograph/native.ex @@ -58,6 +58,9 @@ defmodule Pantograph.Native do {:pumas_list_models, 1}, {:pumas_search_models, 4}, {:pumas_get_model, 2}, + {:pumas_model_package_facts_summary_snapshot, 3}, + {:pumas_resolve_model_package_facts_summary, 2}, + {:pumas_list_model_library_updates_since, 3}, {:pumas_rebuild_index, 1}, {:pumas_search_hf, 4}, {:pumas_get_repo_files, 2}, diff --git a/bindings/beam/pantograph_native_smoke/test/pantograph_native_smoke_test.exs b/bindings/beam/pantograph_native_smoke/test/pantograph_native_smoke_test.exs index 6d7b0619e..e1b7f3f1d 100644 --- a/bindings/beam/pantograph_native_smoke/test/pantograph_native_smoke_test.exs +++ b/bindings/beam/pantograph_native_smoke/test/pantograph_native_smoke_test.exs @@ -20,13 +20,16 @@ defmodule PantographNativeSmokeTest do test "workflow_validate returns edge-reference errors for unknown nodes" do graph_json = Pantograph.Native.workflow_new("beam-invalid", "BEAM Invalid") - |> Pantograph.Native.workflow_add_edge("missing-source", "out", "missing-target", "in") + |> Pantograph.Native.workflow_add_node("known-source", "text-input", 0.0, 0.0, "{}") + |> Pantograph.Native.workflow_add_node("known-target", "text-output", 100.0, 0.0, "{}") + |> Pantograph.Native.workflow_add_edge("missing-source", "text", "known-target", "text") + |> Pantograph.Native.workflow_add_edge("known-source", "text", "missing-target", "text") errors = Pantograph.Native.workflow_validate(graph_json) assert is_list(errors) - assert Enum.any?(errors, &String.contains?(&1, "unknown node 'missing-source'")) - assert Enum.any?(errors, &String.contains?(&1, "unknown node 'missing-target'")) + assert Enum.any?(errors, &String.contains?(&1, "unknown source node 'missing-source'")) + assert Enum.any?(errors, &String.contains?(&1, "unknown target node 'missing-target'")) end test "workflow_from_json returns parse errors as BEAM tuples" do @@ -57,6 +60,7 @@ defmodule PantographNativeSmokeTest do queryable = Pantograph.Native.node_registry_queryable_ports(registry) assert String.contains?(queryable, ~s("node_type":"puma-lib")) - assert String.contains?(queryable, ~s("port_id":"model_path")) + assert String.contains?(queryable, ~s("port_id":"pumas_model_ref")) + refute String.contains?(queryable, ~s("port_id":"model_path")) end end diff --git a/bindings/csharp/Pantograph.DirectRuntimeQuickstart/Program.cs b/bindings/csharp/Pantograph.DirectRuntimeQuickstart/Program.cs index 3d0c78e9a..a3f544739 100644 --- a/bindings/csharp/Pantograph.DirectRuntimeQuickstart/Program.cs +++ b/bindings/csharp/Pantograph.DirectRuntimeQuickstart/Program.cs @@ -29,6 +29,7 @@ if (args.Contains("--run-session", StringComparer.Ordinal)) { + await PublishSavedWorkflow(runtime, workflowId, savedPath); await RunWorkflowSession(runtime, workflowId); } else @@ -173,6 +174,56 @@ await runtime.WorkflowGraphCloseEditSession( $$"""{"session_id":{{JsonSerializer.Serialize(editSessionId)}}}"""); } +static async Task PublishSavedWorkflow( + FfiPantographRuntime runtime, string workflowId, string savedPath) +{ + string fileJson = runtime.WorkflowGraphLoad(JsonSerializer.Serialize(new { path = savedPath })); + using JsonDocument file = JsonDocument.Parse(fileJson); + string editJson = await runtime.WorkflowGraphCreateEditSession(JsonSerializer.Serialize(new + { + workflow_id = workflowId, + graph = file.RootElement.GetProperty("graph"), + })); + string editSessionId = ReadString(editJson, "session_id"); + string graphRevision = ReadString(editJson, "graph_revision"); + try + { + string validationJson = await runtime.WorkflowGraphRefreshCurrentValidationSummary( + JsonSerializer.Serialize(new + { + graph_session_id = editSessionId, + graph_revision = graphRevision, + })); + using JsonDocument validation = JsonDocument.Parse(validationJson); + JsonElement summary = validation.RootElement.GetProperty("summary"); + if (summary.GetProperty("state").GetString() != "current" + || !summary.GetProperty("submit_gate").GetProperty("allowed").GetBoolean()) + { + throw new InvalidOperationException($"Workflow validation is not executable: {validationJson}"); + } + string validationSessionId = summary.GetProperty("validation_session_id").GetString() + ?? throw new InvalidOperationException("Missing validation session identity"); + string publicationJson = await runtime.PublishGraphSessionExecutableValidationSnapshot( + JsonSerializer.Serialize(new + { + workflow_id = workflowId, + workflow_semantic_version = "0.1.0", + graph_session_id = editSessionId, + validation_session_id = validationSessionId, + })); + if (ReadString(publicationJson, "validation_session_id") != validationSessionId + || string.IsNullOrWhiteSpace(ReadString(publicationJson, "validation_snapshot_id"))) + { + throw new InvalidOperationException($"Unexpected publication identity: {publicationJson}"); + } + } + finally + { + await runtime.WorkflowGraphCloseEditSession( + JsonSerializer.Serialize(new { session_id = editSessionId })); + } +} + static async Task RunWorkflowSession(FfiPantographRuntime runtime, string workflowId) { string createResponse = await runtime.WorkflowCreateSession( diff --git a/bindings/csharp/Pantograph.DirectRuntimeQuickstart/README.md b/bindings/csharp/Pantograph.DirectRuntimeQuickstart/README.md index e9b35c826..ca8bc7575 100644 --- a/bindings/csharp/Pantograph.DirectRuntimeQuickstart/README.md +++ b/bindings/csharp/Pantograph.DirectRuntimeQuickstart/README.md @@ -36,5 +36,11 @@ your workflow and append `--run-session`. - Loads the saved workflow through `WorkflowGraphLoad`. - Opens a graph edit session, mutates node data, saves the edited graph, and closes the graph edit session. -- When `--run-session` is passed: opens a workflow execution session, submits a +- When `--run-session` is passed: refreshes owner validation for the saved graph, + publishes its current executable snapshot, closes that edit session, opens a + workflow execution session, submits a session run, prints outputs, and closes the workflow execution session. + +The native runtime's attribution store is ephemeral. The example publishes again +each time it opens the runtime. Missing or stale validation is an error; the +example does not construct snapshot content or bypass admission. diff --git a/bindings/csharp/Pantograph.NativeSmoke/Program.cs b/bindings/csharp/Pantograph.NativeSmoke/Program.cs index a46504dec..6b32a1055 100644 --- a/bindings/csharp/Pantograph.NativeSmoke/Program.cs +++ b/bindings/csharp/Pantograph.NativeSmoke/Program.cs @@ -46,6 +46,8 @@ private static async Task RunTextSmoke(FfiPantographRuntime runtime, string proj { WriteTextWorkflow(projectRoot, TextWorkflowId); ExerciseGraphAuthoringDiscovery(runtime); + await PublishSavedWorkflow(runtime, TextWorkflowId, + Path.Combine(projectRoot, ".pantograph", "workflows", $"{TextWorkflowId}.json")); string createResponse = await runtime.WorkflowCreateSession( $$"""{"workflow_id":"{{TextWorkflowId}}","keep_alive":true}"""); @@ -113,6 +115,56 @@ private static void ExerciseGraphAuthoringDiscovery(FfiPantographRuntime runtime } } + private static async Task PublishSavedWorkflow( + FfiPantographRuntime runtime, string workflowId, string savedPath) + { + string fileJson = runtime.WorkflowGraphLoad(JsonSerializer.Serialize(new { path = savedPath })); + using JsonDocument file = JsonDocument.Parse(fileJson); + string editJson = await runtime.WorkflowGraphCreateEditSession(JsonSerializer.Serialize(new + { + workflow_id = workflowId, + graph = file.RootElement.GetProperty("graph"), + })); + string editSessionId = ReadString(editJson, "session_id"); + string graphRevision = ReadString(editJson, "graph_revision"); + try + { + string validationJson = await runtime.WorkflowGraphRefreshCurrentValidationSummary( + JsonSerializer.Serialize(new + { + graph_session_id = editSessionId, + graph_revision = graphRevision, + })); + using JsonDocument validation = JsonDocument.Parse(validationJson); + JsonElement summary = validation.RootElement.GetProperty("summary"); + if (summary.GetProperty("state").GetString() != "current" + || !summary.GetProperty("submit_gate").GetProperty("allowed").GetBoolean()) + { + throw new InvalidOperationException($"Workflow validation is not executable: {validationJson}"); + } + string validationSessionId = summary.GetProperty("validation_session_id").GetString() + ?? throw new InvalidOperationException("Missing validation session identity"); + string publicationJson = await runtime.PublishGraphSessionExecutableValidationSnapshot( + JsonSerializer.Serialize(new + { + workflow_id = workflowId, + workflow_semantic_version = "0.1.0", + graph_session_id = editSessionId, + validation_session_id = validationSessionId, + })); + if (ReadString(publicationJson, "validation_session_id") != validationSessionId + || string.IsNullOrWhiteSpace(ReadString(publicationJson, "validation_snapshot_id"))) + { + throw new InvalidOperationException($"Unexpected publication identity: {publicationJson}"); + } + } + finally + { + await runtime.WorkflowGraphCloseEditSession( + JsonSerializer.Serialize(new { session_id = editSessionId })); + } + } + private static async Task RunDiffusionSmoke(FfiPantographRuntime runtime, string projectRoot) { string modelId = RequireEnv("PANTOGRAPH_DIFFUSION_SMOKE_PUMAS_MODEL_ID"); @@ -123,6 +175,8 @@ private static async Task RunDiffusionSmoke(FfiPantographRuntime runtime, string string outputPath = Environment.GetEnvironmentVariable("PANTOGRAPH_DIFFUSION_SMOKE_OUTPUT") ?? ""; WriteDiffusionWorkflow(projectRoot, DiffusionWorkflowId, modelId, pumasArtifactId); + await PublishSavedWorkflow(runtime, DiffusionWorkflowId, + Path.Combine(projectRoot, ".pantograph", "workflows", $"{DiffusionWorkflowId}.json")); string createResponse = await runtime.WorkflowCreateSession( $$"""{"workflow_id":"{{DiffusionWorkflowId}}","keep_alive":true}"""); diff --git a/bindings/csharp/README.md b/bindings/csharp/README.md index 2abdfa44a..ff8566ece 100644 --- a/bindings/csharp/README.md +++ b/bindings/csharp/README.md @@ -10,6 +10,26 @@ Runtime smoke coverage for generated Pantograph C# bindings. | `Pantograph.DirectRuntimeQuickstart/` | Artifact-ready quickstart showing native save/list/load/edit-session/workflow-session usage from C#. | | `PACKAGE-README.md` | README copied to the generated C# binding artifact. | +## Generator prerequisite + +The maintained workflow uses UniFFI 0.28.3 and the official +[uniffi-bindgen-cs v0.9.0+v0.28.3 source](https://github.com/NordSecurity/uniffi-bindgen-cs/tree/2f4880f03ed08d960ad0ee14d11cf95444eee540). +The generator is not published as crates.io version 0.9.0. Install its exact +reviewed Git revision with the upstream lockfile: + +```bash +cargo install uniffi-bindgen-cs \ + --git https://github.com/NordSecurity/uniffi-bindgen-cs \ + --rev 2f4880f03ed08d960ad0ee14d11cf95444eee540 --locked +``` + +This provisions a development/CI tool; it does not change the application's +UniFFI dependency or hand-edit generated bindings. The headless workflow owns +its pin and generated-artifact qualification. Keep the upstream MPL-2.0 notices +with any redistributed generator source/binary; this repository does not vendor +or package the generator itself. Existing generated-binding/artifact licensing +obligations remain unchanged. + ## Usage Run the repository-level smoke script: diff --git a/crates/pantograph-diagnostics-ledger/src/event.rs b/crates/pantograph-diagnostics-ledger/src/event.rs index a017ae9ba..a608c9c9d 100644 --- a/crates/pantograph-diagnostics-ledger/src/event.rs +++ b/crates/pantograph-diagnostics-ledger/src/event.rs @@ -1062,6 +1062,10 @@ impl SchedulerModelCacheState { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct SchedulerModelLifecycleChangedPayload { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_execution_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unloaded_workflow_execution_session_id: Option, pub transition: SchedulerModelLifecycleTransition, #[serde(default)] pub cache_state: Option, @@ -1080,6 +1084,25 @@ pub struct SchedulerModelLifecycleChangedPayload { impl SchedulerModelLifecycleChangedPayload { fn validate(&self) -> Result<(), DiagnosticsLedgerError> { + match ( + &self.workflow_execution_session_id, + &self.unloaded_workflow_execution_session_id, + ) { + (Some(target), Some(unloaded)) => { + validate_required_text("workflow_execution_session_id", target, MAX_ID_LEN)?; + validate_required_text( + "unloaded_workflow_execution_session_id", + unloaded, + MAX_ID_LEN, + )?; + } + (None, None) => {} + _ => { + return Err(DiagnosticsLedgerError::MissingField { + field: "capacity_session_identity_pair", + }) + } + } if let Some(summary) = self.execution_plan_summary.as_ref() { summary.validate()?; } @@ -2316,6 +2339,7 @@ impl InferenceOptionDiagnosticSummary { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DiagnosticErrorScopeKind { + SessionRuntime, Run, Node, RuntimeModel, @@ -2397,6 +2421,8 @@ impl DiagnosticErrorLocation { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct DiagnosticErrorOccurredPayload { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_execution_session_id: Option, pub phase: String, pub scope: DiagnosticErrorScopeKind, pub severity: DiagnosticErrorSeverity, @@ -2415,6 +2441,18 @@ pub struct DiagnosticErrorOccurredPayload { impl DiagnosticErrorOccurredPayload { fn validate(&self) -> Result<(), DiagnosticsLedgerError> { + if self.scope == DiagnosticErrorScopeKind::SessionRuntime { + let session_id = self.workflow_execution_session_id.as_deref().ok_or( + DiagnosticsLedgerError::MissingField { + field: "workflow_execution_session_id", + }, + )?; + validate_required_text("workflow_execution_session_id", session_id, MAX_ID_LEN)?; + } else if self.workflow_execution_session_id.is_some() { + return Err(DiagnosticsLedgerError::InvalidField { + field: "workflow_execution_session_id", + }); + } validate_required_text("error_phase", &self.phase, MAX_ID_LEN)?; validate_required_text("error_code", &self.code, MAX_ID_LEN)?; validate_required_text( @@ -3465,7 +3503,6 @@ fn validate_event_scope( | DiagnosticEventKind::SchedulerQueuePlacement | DiagnosticEventKind::SchedulerQueueControl | DiagnosticEventKind::SchedulerRunDelayed - | DiagnosticEventKind::SchedulerModelLifecycleChanged | DiagnosticEventKind::SchedulerRunAdmitted | DiagnosticEventKind::SchedulerReservationChanged | DiagnosticEventKind::SchedulerTaskAttemptLifecycleChanged @@ -3501,9 +3538,26 @@ fn validate_event_scope( { return Err(DiagnosticsLedgerError::MissingField { field: "node_id" }); } - if request.payload.event_kind() == DiagnosticEventKind::SchedulerModelLifecycleChanged - && request.model_id.is_none() + } + DiagnosticEventKind::SchedulerModelLifecycleChanged => { + let DiagnosticEventPayload::SchedulerModelLifecycleChanged(payload) = &request.payload + else { + return Err(DiagnosticsLedgerError::InvalidField { + field: "event_kind", + }); + }; + if request.workflow_run_id.is_none() && payload.workflow_execution_session_id.is_none() { + return Err(DiagnosticsLedgerError::MissingField { + field: "workflow_run_id_or_execution_session_id", + }); + } + if request.workflow_id.is_none() { + return Err(DiagnosticsLedgerError::MissingField { + field: "workflow_id", + }); + } + if request.model_id.is_none() { return Err(DiagnosticsLedgerError::MissingField { field: "model_id" }); } } @@ -3515,7 +3569,9 @@ fn validate_event_scope( }; if !matches!( payload.scope, - DiagnosticErrorScopeKind::Transport | DiagnosticErrorScopeKind::Projection + DiagnosticErrorScopeKind::Transport + | DiagnosticErrorScopeKind::Projection + | DiagnosticErrorScopeKind::SessionRuntime ) { if request.workflow_run_id.is_none() { return Err(DiagnosticsLedgerError::MissingField { @@ -3569,6 +3625,19 @@ fn validate_diagnostic_error_scope( } Ok(()) } + DiagnosticErrorScopeKind::SessionRuntime => { + if request.workflow_id.is_none() { + return Err(DiagnosticsLedgerError::MissingField { + field: "workflow_id", + }); + } + if request.scheduler_policy_id.is_none() { + return Err(DiagnosticsLedgerError::MissingField { + field: "scheduler_policy_id", + }); + } + Ok(()) + } DiagnosticErrorScopeKind::Scheduler => { if request.scheduler_policy_id.is_none() { return Err(DiagnosticsLedgerError::MissingField { diff --git a/crates/pantograph-diagnostics-ledger/src/tests.rs b/crates/pantograph-diagnostics-ledger/src/tests.rs index 68d435032..4ea640319 100644 --- a/crates/pantograph-diagnostics-ledger/src/tests.rs +++ b/crates/pantograph-diagnostics-ledger/src/tests.rs @@ -1173,6 +1173,8 @@ fn model_lifecycle_projects_canonical_error_link_without_counting_new_error() { let mut lifecycle = sample_scheduler_model_lifecycle_event("workflow_run_alpha"); lifecycle.payload = DiagnosticEventPayload::SchedulerModelLifecycleChanged( SchedulerModelLifecycleChangedPayload { + workflow_execution_session_id: None, + unloaded_workflow_execution_session_id: None, transition: SchedulerModelLifecycleTransition::LoadFailed, cache_state: Some(SchedulerModelCacheState::Failed), execution_plan_summary: Some(SchedulerExecutionPlanSummary { @@ -5872,6 +5874,8 @@ fn sample_scheduler_model_lifecycle_event(workflow_run_id: &str) -> DiagnosticEv payload_ref: None, payload: DiagnosticEventPayload::SchedulerModelLifecycleChanged( SchedulerModelLifecycleChangedPayload { + workflow_execution_session_id: None, + unloaded_workflow_execution_session_id: None, transition: SchedulerModelLifecycleTransition::LoadRequested, cache_state: Some(SchedulerModelCacheState::CacheMiss), execution_plan_summary: None, @@ -5911,6 +5915,7 @@ fn sample_diagnostic_error_event(workflow_run_id: &str) -> DiagnosticEventAppend retention_class: DiagnosticEventRetentionClass::AuditMetadata, payload_ref: None, payload: DiagnosticEventPayload::DiagnosticErrorOccurred(DiagnosticErrorOccurredPayload { + workflow_execution_session_id: None, phase: "runtime_model_load".to_string(), scope: DiagnosticErrorScopeKind::RuntimeModel, severity: DiagnosticErrorSeverity::Fatal, @@ -6581,3 +6586,79 @@ fn sample_event( correlation_id: Some("corr-1".to_string()), } } + +#[test] +fn session_capacity_events_preserve_identity_without_creating_a_run() { + let mut ledger = SqliteDiagnosticsLedger::open_in_memory().expect("ledger"); + let mut event = sample_scheduler_model_lifecycle_event("unused"); + event.workflow_run_id = None; + event.workflow_version_id = None; + event.workflow_semantic_version = None; + let DiagnosticEventPayload::SchedulerModelLifecycleChanged(payload) = &mut event.payload else { + panic!("model lifecycle payload"); + }; + payload.workflow_execution_session_id = Some("session-target".to_string()); + payload.unloaded_workflow_execution_session_id = Some("session-victim".to_string()); + payload.transition = SchedulerModelLifecycleTransition::UnloadCompleted; + payload.duration_ms = Some(7); + let recorded = ledger + .append_diagnostic_event(event) + .expect("session event"); + assert!(recorded.workflow_run_id.is_none()); + let payload: serde_json::Value = serde_json::from_str(&recorded.payload_json).expect("payload"); + assert_eq!(payload["workflow_execution_session_id"], "session-target"); + ledger + .drain_run_list_projection(100) + .expect("list projection"); + ledger + .drain_run_detail_projection(100) + .expect("detail projection"); + assert!(ledger + .query_run_list_projection(RunListProjectionQuery::default()) + .expect("runs") + .is_empty()); +} + +#[test] +fn session_capacity_identity_does_not_relax_legacy_run_event_requirements() { + let mut event = sample_scheduler_model_lifecycle_event("run-real"); + let original = serde_json::to_value(&event.payload).expect("legacy payload"); + let decoded: DiagnosticEventPayload = + serde_json::from_value(original.clone()).expect("old payload parses"); + assert_eq!(serde_json::to_value(decoded).expect("round trip"), original); + event.workflow_run_id = None; + assert!(event.validate().is_err()); + let DiagnosticEventPayload::SchedulerModelLifecycleChanged(payload) = &mut event.payload else { + panic!("model lifecycle payload"); + }; + payload.workflow_execution_session_id = Some("session-target".to_string()); + assert!(event.validate().is_err()); + let DiagnosticEventPayload::SchedulerModelLifecycleChanged(payload) = &mut event.payload else { + panic!("model lifecycle payload"); + }; + payload.unloaded_workflow_execution_session_id = Some(" ".to_string()); + assert!(event.validate().is_err()); + let mut run_event = sample_run_started_event("run-real"); + run_event.workflow_run_id = None; + assert!(run_event.validate().is_err()); +} + +#[test] +fn session_runtime_error_requires_genuine_session_and_workflow_identity() { + let mut event = sample_diagnostic_error_event("unused"); + event.workflow_run_id = None; + event.source_component = DiagnosticEventSourceComponent::Scheduler; + let DiagnosticEventPayload::DiagnosticErrorOccurred(payload) = &mut event.payload else { + panic!("error payload"); + }; + payload.scope = DiagnosticErrorScopeKind::SessionRuntime; + payload.severity = DiagnosticErrorSeverity::Error; + assert!(event.validate().is_err()); + let DiagnosticEventPayload::DiagnosticErrorOccurred(payload) = &mut event.payload else { + panic!("error payload"); + }; + payload.workflow_execution_session_id = Some("session-target".to_string()); + event.validate().expect("session-scoped error"); + event.workflow_id = None; + assert!(event.validate().is_err()); +} diff --git a/crates/pantograph-embedded-runtime/src/embedded_workflow_graph_api.rs b/crates/pantograph-embedded-runtime/src/embedded_workflow_graph_api.rs index a70395234..162466fe6 100644 --- a/crates/pantograph-embedded-runtime/src/embedded_workflow_graph_api.rs +++ b/crates/pantograph-embedded-runtime/src/embedded_workflow_graph_api.rs @@ -1,15 +1,17 @@ use pantograph_workflow_service::{ ConnectionCandidatesResponse, ConnectionCommitResponse, EdgeInsertionPreviewResponse, FileSystemWorkflowGraphStore, InsertNodeConnectionResponse, InsertNodeOnEdgeResponse, - WorkflowFile, WorkflowGraphAddEdgeRequest, WorkflowGraphAddNodeRequest, - WorkflowGraphConnectRequest, WorkflowGraphEditSessionCloseRequest, - WorkflowGraphEditSessionCloseResponse, WorkflowGraphEditSessionCreateRequest, - WorkflowGraphEditSessionCreateResponse, WorkflowGraphEditSessionGraphRequest, - WorkflowGraphEditSessionGraphResponse, WorkflowGraphGetConnectionCandidatesRequest, - WorkflowGraphInsertNodeAndConnectRequest, WorkflowGraphInsertNodeOnEdgeRequest, - WorkflowGraphListResponse, WorkflowGraphLoadRequest, + ValidatedWorkflowExecutableValidationSnapshotRecord, WorkflowFile, WorkflowGraphAddEdgeRequest, + WorkflowGraphAddNodeRequest, WorkflowGraphConnectRequest, + WorkflowGraphCurrentValidationRefreshRequest, WorkflowGraphCurrentValidationRefreshResponse, + WorkflowGraphEditSessionCloseRequest, WorkflowGraphEditSessionCloseResponse, + WorkflowGraphEditSessionCreateRequest, WorkflowGraphEditSessionCreateResponse, + WorkflowGraphEditSessionGraphRequest, WorkflowGraphEditSessionGraphResponse, + WorkflowGraphGetConnectionCandidatesRequest, WorkflowGraphInsertNodeAndConnectRequest, + WorkflowGraphInsertNodeOnEdgeRequest, WorkflowGraphListResponse, WorkflowGraphLoadRequest, WorkflowGraphPreviewNodeInsertOnEdgeRequest, WorkflowGraphRemoveEdgeRequest, WorkflowGraphRemoveNodeRequest, WorkflowGraphSaveRequest, WorkflowGraphSaveResponse, + WorkflowGraphSessionExecutableValidationSnapshotPublishRequest, WorkflowGraphUndoRedoStateRequest, WorkflowGraphUndoRedoStateResponse, WorkflowGraphUpdateNodeDataRequest, WorkflowGraphUpdateNodePositionRequest, WorkflowServiceError, @@ -18,6 +20,26 @@ use pantograph_workflow_service::{ use crate::EmbeddedRuntime; impl EmbeddedRuntime { + /// Refresh validation from the current server-owned graph edit session. + pub async fn workflow_graph_refresh_current_validation_summary( + &self, + request: WorkflowGraphCurrentValidationRefreshRequest, + ) -> Result { + self.workflow_service + .workflow_graph_refresh_current_validation_summary(request) + .await + } + + /// Publish executable validation from current graph-session provenance. + pub async fn publish_graph_session_executable_validation_snapshot( + &self, + request: WorkflowGraphSessionExecutableValidationSnapshotPublishRequest, + ) -> Result { + self.workflow_service + .publish_graph_session_executable_validation_snapshot(request) + .await + } + pub(crate) fn graph_store(&self) -> FileSystemWorkflowGraphStore { FileSystemWorkflowGraphStore::new(self.config.project_root.clone()) } diff --git a/crates/pantograph-embedded-runtime/src/workflow_service_composition.rs b/crates/pantograph-embedded-runtime/src/workflow_service_composition.rs index f47e520d9..87b076248 100644 --- a/crates/pantograph-embedded-runtime/src/workflow_service_composition.rs +++ b/crates/pantograph-embedded-runtime/src/workflow_service_composition.rs @@ -1012,7 +1012,7 @@ mod tests { })); let projections = snapshot - .scheduler_inference_task_projections() + .scheduler_inference_task_projections(&connected_dependency_inference_graph(model_id)) .expect("scheduler projections"); let scheduler_node_id = "infer".parse().expect("scheduler node id"); let projection = projections diff --git a/crates/pantograph-frontend-http-adapter/src/lib.rs b/crates/pantograph-frontend-http-adapter/src/lib.rs index cbd9cb44a..ff924c8f6 100644 --- a/crates/pantograph-frontend-http-adapter/src/lib.rs +++ b/crates/pantograph-frontend-http-adapter/src/lib.rs @@ -356,56 +356,11 @@ fn normalize_base_url(raw_base_url: String) -> Result Result; - } - - #[async_trait::async_trait] - impl WorkflowServiceSessionRunExt for WorkflowService { - async fn workflow_run( - &self, - host: &FrontendHttpWorkflowHost, - request: WorkflowRunRequest, - ) -> Result { - let session = self - .create_workflow_execution_session( - host, - WorkflowExecutionSessionCreateRequest { - workflow_id: request.workflow_id, - usage_profile: None, - keep_alive: false, - }, - ) - .await?; - - self.run_workflow_execution_session( - host, - WorkflowExecutionSessionRunRequest { - session_id: session.session_id, - workflow_semantic_version: request.workflow_semantic_version, - inputs: request.inputs, - output_targets: request.output_targets, - override_selection: request.override_selection, - timeout_ms: request.timeout_ms, - priority: None, - }, - ) - .await - } - } + use std::time::{Duration, Instant}; #[test] fn parse_workflow_outputs_payload_rejects_missing_fields() { @@ -463,71 +418,62 @@ mod tests { ); } - fn create_temp_workflow_root_with_output(workflow_id: &str) -> std::path::PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let root = std::env::temp_dir().join(format!("pantograph-frontend-http-tests-{suffix}")); - let workflows_dir = root.join(".pantograph").join("workflows"); - std::fs::create_dir_all(&workflows_dir).expect("create workflows dir"); - - let workflow_json = serde_json::json!({ - "version": "1.0", - "metadata": { - "name": "Output Workflow" - }, - "graph": { - "nodes": [ - { - "id": "vector-output-1", - "node_type": "vector-output", - "data": { - "definition": { - "category": "output", - "io_binding_origin": "client_session", - "outputs": [ - { - "id": "vector", - "data_type": "embedding", - "required": false, - "multiple": false - } - ] - } - }, - "position": { "x": 0.0, "y": 0.0 } - } - ], - "edges": [] - } - }); - - let file_path = workflows_dir.join(format!("{}.json", workflow_id)); - std::fs::write( - file_path, - serde_json::to_vec(&workflow_json).expect("serialize workflow"), - ) - .expect("write workflow"); - root - } - fn spawn_single_workflow_server( status_code: u16, body: serde_json::Value, - ) -> (String, std::thread::JoinHandle<()>) { + ) -> (String, std::thread::JoinHandle) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("set nonblocking accept"); let addr = listener.local_addr().expect("local addr"); let body_text = body.to_string(); let reason = if status_code == 200 { "OK" } else { "ERROR" }; let handle = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < deadline, + "HTTP transport was never called before the test-server deadline" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("test server accept failed: {error}"), + } + }; + stream.set_nonblocking(false).expect("set blocking read"); stream .set_read_timeout(Some(Duration::from_secs(2))) .expect("set timeout"); - let mut request_buf = [0_u8; 8192]; - let _ = stream.read(&mut request_buf); + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).expect("read request line"); + assert_eq!(line, "POST /v1/workflow/run HTTP/1.1\r\n"); + let mut content_length = None; + loop { + line.clear(); + assert!(reader.read_line(&mut line).expect("read request header") > 0); + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_length = + Some(value.trim().parse::().expect("content length")); + } + } + } + let content_length = content_length.expect("JSON request has content length"); + assert!(content_length <= 8192, "unexpected test request size"); + let mut request_body = vec![0_u8; content_length]; + reader + .read_exact(&mut request_body) + .expect("read complete JSON request"); + drop(reader); let response = format!( "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", @@ -539,17 +485,15 @@ mod tests { stream .write_all(response.as_bytes()) .expect("write response"); + serde_json::from_slice(&request_body).expect("request body is JSON") }); (format!("http://{}", addr), handle) } #[tokio::test] - async fn workflow_run_returns_output_not_produced_for_missing_target_output() { + async fn transport_preserves_empty_outputs_for_a_requested_target() { let workflow_id = "wf-output-not-produced"; - let workflow_root = create_temp_workflow_root_with_output(workflow_id) - .join(".pantograph") - .join("workflows"); let payload = serde_json::json!({ "workflow_run_id": "adapter-run-1", @@ -561,7 +505,56 @@ mod tests { let host = FrontendHttpWorkflowHost::new( base_url, None, - vec![workflow_root], + Vec::new(), + DEFAULT_MAX_INPUT_BINDINGS, + DEFAULT_MAX_OUTPUT_TARGETS, + DEFAULT_MAX_VALUE_BYTES, + DEFAULT_BACKEND_NAME.to_string(), + ) + .expect("build frontend host"); + + let outputs = host + .run_workflow( + workflow_id, + &[], + Some(&[WorkflowOutputTarget { + node_id: "vector-output-1".to_string(), + port_id: "vector".to_string(), + }]), + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() + }, + WorkflowRunHandle::new(), + ) + .await + .expect("transport preserves a valid empty outputs array"); + + let request = server_thread.join().expect("join server"); + assert_eq!( + request, + serde_json::json!({ + "workflow_id": workflow_id, + "inputs": [], + "output_targets": [{"node_id": "vector-output-1", "port_id": "vector"}], + }) + ); + assert!(outputs.is_empty()); + } + + #[tokio::test] + async fn transport_maps_output_not_produced_error_envelope() { + let workflow_id = "wf-output-not-produced"; + let payload = serde_json::json!({ + "code": "output_not_produced", + "message": "requested output target 'vector-output-1.vector' was not produced" + }); + let (base_url, server_thread) = spawn_single_workflow_server(422, payload); + + let host = FrontendHttpWorkflowHost::new( + base_url, + None, + Vec::new(), DEFAULT_MAX_INPUT_BINDINGS, DEFAULT_MAX_OUTPUT_TARGETS, DEFAULT_MAX_VALUE_BYTES, @@ -569,34 +562,35 @@ mod tests { ) .expect("build frontend host"); - let err = WorkflowService::new() - .workflow_run( - &host, - WorkflowRunRequest { - workflow_id: workflow_id.to_string(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: Some(vec![WorkflowOutputTarget { - node_id: "vector-output-1".to_string(), - port_id: "vector".to_string(), - }]), - override_selection: None, - timeout_ms: None, + let err = host + .run_workflow( + workflow_id, + &[], + None, + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() }, + WorkflowRunHandle::new(), ) .await - .expect_err("missing output should return output_not_produced"); + .expect_err("422 envelope should map to output_not_produced"); server_thread.join().expect("join server"); - assert!(matches!(err, WorkflowServiceError::OutputNotProduced(_))); + match err { + WorkflowServiceError::OutputNotProduced(message) => { + assert_eq!( + message, + "requested output target 'vector-output-1.vector' was not produced" + ); + } + other => panic!("expected output_not_produced, got {}", other), + } } #[tokio::test] async fn workflow_run_maps_non_2xx_error_envelope_to_service_error() { let workflow_id = "wf-runtime-not-ready"; - let workflow_root = create_temp_workflow_root_with_output(workflow_id) - .join(".pantograph") - .join("workflows"); let payload = serde_json::json!({ "code": "runtime_not_ready", "message": "backend unavailable" @@ -606,7 +600,7 @@ mod tests { let host = FrontendHttpWorkflowHost::new( base_url, None, - vec![workflow_root], + Vec::new(), DEFAULT_MAX_INPUT_BINDINGS, DEFAULT_MAX_OUTPUT_TARGETS, DEFAULT_MAX_VALUE_BYTES, @@ -614,17 +608,16 @@ mod tests { ) .expect("build frontend host"); - let err = WorkflowService::new() - .workflow_run( - &host, - WorkflowRunRequest { - workflow_id: workflow_id.to_string(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, + let err = host + .run_workflow( + workflow_id, + &[], + None, + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() }, + WorkflowRunHandle::new(), ) .await .expect_err("503 envelope should map to runtime_not_ready"); @@ -641,9 +634,6 @@ mod tests { #[tokio::test] async fn workflow_run_maps_cancelled_error_envelope_to_service_error() { let workflow_id = "wf-cancelled"; - let workflow_root = create_temp_workflow_root_with_output(workflow_id) - .join(".pantograph") - .join("workflows"); let payload = serde_json::json!({ "code": "cancelled", "message": "workflow run cancelled" @@ -653,7 +643,7 @@ mod tests { let host = FrontendHttpWorkflowHost::new( base_url, None, - vec![workflow_root], + Vec::new(), DEFAULT_MAX_INPUT_BINDINGS, DEFAULT_MAX_OUTPUT_TARGETS, DEFAULT_MAX_VALUE_BYTES, @@ -661,17 +651,16 @@ mod tests { ) .expect("build frontend host"); - let err = WorkflowService::new() - .workflow_run( - &host, - WorkflowRunRequest { - workflow_id: workflow_id.to_string(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, + let err = host + .run_workflow( + workflow_id, + &[], + None, + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() }, + WorkflowRunHandle::new(), ) .await .expect_err("409 cancelled envelope should map to cancelled"); @@ -688,9 +677,6 @@ mod tests { #[tokio::test] async fn workflow_run_maps_invalid_request_error_envelope_to_service_error() { let workflow_id = "wf-invalid-request"; - let workflow_root = create_temp_workflow_root_with_output(workflow_id) - .join(".pantograph") - .join("workflows"); let payload = serde_json::json!({ "code": "invalid_request", "message": "workflow requires interactive input" @@ -700,7 +686,7 @@ mod tests { let host = FrontendHttpWorkflowHost::new( base_url, None, - vec![workflow_root], + Vec::new(), DEFAULT_MAX_INPUT_BINDINGS, DEFAULT_MAX_OUTPUT_TARGETS, DEFAULT_MAX_VALUE_BYTES, @@ -708,17 +694,16 @@ mod tests { ) .expect("build frontend host"); - let err = WorkflowService::new() - .workflow_run( - &host, - WorkflowRunRequest { - workflow_id: workflow_id.to_string(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, + let err = host + .run_workflow( + workflow_id, + &[], + None, + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() }, + WorkflowRunHandle::new(), ) .await .expect_err("400 invalid_request envelope should map to invalid request"); @@ -735,9 +720,6 @@ mod tests { #[tokio::test] async fn workflow_run_rejects_non_envelope_non_2xx_error_payload() { let workflow_id = "wf-malformed-error"; - let workflow_root = create_temp_workflow_root_with_output(workflow_id) - .join(".pantograph") - .join("workflows"); let payload = serde_json::json!({ "error": "backend unavailable" }); @@ -746,7 +728,7 @@ mod tests { let host = FrontendHttpWorkflowHost::new( base_url, None, - vec![workflow_root], + Vec::new(), DEFAULT_MAX_INPUT_BINDINGS, DEFAULT_MAX_OUTPUT_TARGETS, DEFAULT_MAX_VALUE_BYTES, @@ -754,17 +736,16 @@ mod tests { ) .expect("build frontend host"); - let err = WorkflowService::new() - .workflow_run( - &host, - WorkflowRunRequest { - workflow_id: workflow_id.to_string(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, + let err = host + .run_workflow( + workflow_id, + &[], + None, + WorkflowRunOptions { + timeout_ms: Some(2_000), + ..WorkflowRunOptions::default() }, + WorkflowRunHandle::new(), ) .await .expect_err("non-envelope errors must not be silently remapped"); diff --git a/crates/pantograph-managed-dependencies/src/redistributables/state.rs b/crates/pantograph-managed-dependencies/src/redistributables/state.rs index 3912e9024..5673aed1b 100644 --- a/crates/pantograph-managed-dependencies/src/redistributables/state.rs +++ b/crates/pantograph-managed-dependencies/src/redistributables/state.rs @@ -29,7 +29,7 @@ pub fn load_managed_redistributable_state( } fn load_state_file(path: &Path) -> Result { - let contents = fs::read_to_string(&path).map_err(|e| { + let contents = fs::read_to_string(path).map_err(|e| { format!( "Failed to read managed redistributable state {:?}: {}", path, e diff --git a/crates/pantograph-scheduler/src/batching.rs b/crates/pantograph-scheduler/src/batching.rs index 55d7bfcfc..a5bd7c4cd 100644 --- a/crates/pantograph-scheduler/src/batching.rs +++ b/crates/pantograph-scheduler/src/batching.rs @@ -180,12 +180,13 @@ impl SchedulerBatchPolicyDecision { #[must_use] pub struct ValidatedSchedulerBatchPolicyDecision(SchedulerBatchPolicyDecision); -impl ValidatedSchedulerBatchPolicyDecision { - #[must_use] - pub fn as_ref(&self) -> &SchedulerBatchPolicyDecision { +impl AsRef for ValidatedSchedulerBatchPolicyDecision { + fn as_ref(&self) -> &SchedulerBatchPolicyDecision { &self.0 } +} +impl ValidatedSchedulerBatchPolicyDecision { #[must_use] pub fn into_inner(self) -> SchedulerBatchPolicyDecision { self.0 diff --git a/crates/pantograph-scheduler/src/capability.rs b/crates/pantograph-scheduler/src/capability.rs index 8ff807fe7..d627a0d56 100644 --- a/crates/pantograph-scheduler/src/capability.rs +++ b/crates/pantograph-scheduler/src/capability.rs @@ -202,12 +202,13 @@ fn default_scheduler_capability_hint_contract_version() -> u16 { #[must_use] pub struct ValidatedSchedulerCapabilityHintSnapshot(SchedulerCapabilityHintSnapshot); -impl ValidatedSchedulerCapabilityHintSnapshot { - #[must_use] - pub fn as_ref(&self) -> &SchedulerCapabilityHintSnapshot { +impl AsRef for ValidatedSchedulerCapabilityHintSnapshot { + fn as_ref(&self) -> &SchedulerCapabilityHintSnapshot { &self.0 } +} +impl ValidatedSchedulerCapabilityHintSnapshot { #[must_use] pub fn into_inner(self) -> SchedulerCapabilityHintSnapshot { self.0 diff --git a/crates/pantograph-scheduler/src/dispatch.rs b/crates/pantograph-scheduler/src/dispatch.rs index 7b9655d24..3c45624fc 100644 --- a/crates/pantograph-scheduler/src/dispatch.rs +++ b/crates/pantograph-scheduler/src/dispatch.rs @@ -201,12 +201,13 @@ impl SchedulerDispatchDecision { #[must_use] pub struct ValidatedSchedulerDispatchDecision(SchedulerDispatchDecision); -impl ValidatedSchedulerDispatchDecision { - #[must_use] - pub fn as_ref(&self) -> &SchedulerDispatchDecision { +impl AsRef for ValidatedSchedulerDispatchDecision { + fn as_ref(&self) -> &SchedulerDispatchDecision { &self.0 } +} +impl ValidatedSchedulerDispatchDecision { #[must_use] pub fn into_inner(self) -> SchedulerDispatchDecision { self.0 diff --git a/crates/pantograph-scheduler/src/dispatch_selection.rs b/crates/pantograph-scheduler/src/dispatch_selection.rs index eaa74197d..2b368089f 100644 --- a/crates/pantograph-scheduler/src/dispatch_selection.rs +++ b/crates/pantograph-scheduler/src/dispatch_selection.rs @@ -262,12 +262,13 @@ impl SchedulerDispatchSelectionRequest { #[must_use] pub struct ValidatedSchedulerDispatchSelectionRequest(SchedulerDispatchSelectionRequest); -impl ValidatedSchedulerDispatchSelectionRequest { - #[must_use] - pub fn as_ref(&self) -> &SchedulerDispatchSelectionRequest { +impl AsRef for ValidatedSchedulerDispatchSelectionRequest { + fn as_ref(&self) -> &SchedulerDispatchSelectionRequest { &self.0 } +} +impl ValidatedSchedulerDispatchSelectionRequest { #[must_use] pub fn into_inner(self) -> SchedulerDispatchSelectionRequest { self.0 @@ -341,12 +342,13 @@ impl SchedulerDispatchSelectionDecision { #[must_use] pub struct ValidatedSchedulerDispatchSelectionDecision(SchedulerDispatchSelectionDecision); -impl ValidatedSchedulerDispatchSelectionDecision { - #[must_use] - pub fn as_ref(&self) -> &SchedulerDispatchSelectionDecision { +impl AsRef for ValidatedSchedulerDispatchSelectionDecision { + fn as_ref(&self) -> &SchedulerDispatchSelectionDecision { &self.0 } +} +impl ValidatedSchedulerDispatchSelectionDecision { #[must_use] pub fn into_inner(self) -> SchedulerDispatchSelectionDecision { self.0 diff --git a/crates/pantograph-scheduler/src/dispatch_selection_policy.rs b/crates/pantograph-scheduler/src/dispatch_selection_policy.rs index c8ec9a09b..91f64a41d 100644 --- a/crates/pantograph-scheduler/src/dispatch_selection_policy.rs +++ b/crates/pantograph-scheduler/src/dispatch_selection_policy.rs @@ -12,7 +12,6 @@ use crate::error::SchedulerContractError; use crate::intent::SchedulableTaskIntent; use crate::resource_types::SchedulerResourceFitState; -#[must_use] pub fn select_scheduler_dispatch( request: ValidatedSchedulerDispatchSelectionRequest, ) -> Result { diff --git a/crates/pantograph-scheduler/src/handoff.rs b/crates/pantograph-scheduler/src/handoff.rs index 3de212226..4bd1bbb0c 100644 --- a/crates/pantograph-scheduler/src/handoff.rs +++ b/crates/pantograph-scheduler/src/handoff.rs @@ -160,12 +160,13 @@ impl SchedulerRuntimeHandoff { #[must_use] pub struct ValidatedSchedulerRuntimeHandoff(SchedulerRuntimeHandoff); -impl ValidatedSchedulerRuntimeHandoff { - #[must_use] - pub fn as_ref(&self) -> &SchedulerRuntimeHandoff { +impl AsRef for ValidatedSchedulerRuntimeHandoff { + fn as_ref(&self) -> &SchedulerRuntimeHandoff { &self.0 } +} +impl ValidatedSchedulerRuntimeHandoff { #[must_use] pub fn into_inner(self) -> SchedulerRuntimeHandoff { self.0 diff --git a/crates/pantograph-scheduler/src/intent.rs b/crates/pantograph-scheduler/src/intent.rs index 3b8b45dca..c3a3d686f 100644 --- a/crates/pantograph-scheduler/src/intent.rs +++ b/crates/pantograph-scheduler/src/intent.rs @@ -245,12 +245,13 @@ fn default_schedulable_task_intent_contract_version() -> u16 { #[must_use] pub struct ValidatedSchedulableTaskIntent(SchedulableTaskIntent); -impl ValidatedSchedulableTaskIntent { - #[must_use] - pub fn as_ref(&self) -> &SchedulableTaskIntent { +impl AsRef for ValidatedSchedulableTaskIntent { + fn as_ref(&self) -> &SchedulableTaskIntent { &self.0 } +} +impl ValidatedSchedulableTaskIntent { #[must_use] pub fn into_inner(self) -> SchedulableTaskIntent { self.0 diff --git a/crates/pantograph-scheduler/src/lifecycle.rs b/crates/pantograph-scheduler/src/lifecycle.rs index 094750aa5..c8afae468 100644 --- a/crates/pantograph-scheduler/src/lifecycle.rs +++ b/crates/pantograph-scheduler/src/lifecycle.rs @@ -179,12 +179,15 @@ pub struct ValidatedSchedulerTaskLifecycleDiagnosticSnapshot( SchedulerTaskLifecycleDiagnosticSnapshot, ); -impl ValidatedSchedulerTaskLifecycleDiagnosticSnapshot { - #[must_use] - pub fn as_ref(&self) -> &SchedulerTaskLifecycleDiagnosticSnapshot { +impl AsRef + for ValidatedSchedulerTaskLifecycleDiagnosticSnapshot +{ + fn as_ref(&self) -> &SchedulerTaskLifecycleDiagnosticSnapshot { &self.0 } +} +impl ValidatedSchedulerTaskLifecycleDiagnosticSnapshot { #[must_use] pub fn into_inner(self) -> SchedulerTaskLifecycleDiagnosticSnapshot { self.0 diff --git a/crates/pantograph-scheduler/src/queue.rs b/crates/pantograph-scheduler/src/queue.rs index 8b6fbea74..53f5c3a7c 100644 --- a/crates/pantograph-scheduler/src/queue.rs +++ b/crates/pantograph-scheduler/src/queue.rs @@ -576,12 +576,13 @@ pub fn apply_scheduler_task_state_transition( #[must_use] pub struct ValidatedSchedulerTaskStateRecord(SchedulerTaskStateRecord); -impl ValidatedSchedulerTaskStateRecord { - #[must_use] - pub fn as_ref(&self) -> &SchedulerTaskStateRecord { +impl AsRef for ValidatedSchedulerTaskStateRecord { + fn as_ref(&self) -> &SchedulerTaskStateRecord { &self.0 } +} +impl ValidatedSchedulerTaskStateRecord { #[must_use] pub fn into_inner(self) -> SchedulerTaskStateRecord { self.0 @@ -601,12 +602,13 @@ impl TryFrom for ValidatedSchedulerTaskStateRecord { #[must_use] pub struct ValidatedSchedulerTaskStateTransition(SchedulerTaskStateTransition); -impl ValidatedSchedulerTaskStateTransition { - #[must_use] - pub fn as_ref(&self) -> &SchedulerTaskStateTransition { +impl AsRef for ValidatedSchedulerTaskStateTransition { + fn as_ref(&self) -> &SchedulerTaskStateTransition { &self.0 } +} +impl ValidatedSchedulerTaskStateTransition { #[must_use] pub fn into_inner(self) -> SchedulerTaskStateTransition { self.0 diff --git a/crates/pantograph-scheduler/src/readiness.rs b/crates/pantograph-scheduler/src/readiness.rs index f4dc238b6..9d2a625bc 100644 --- a/crates/pantograph-scheduler/src/readiness.rs +++ b/crates/pantograph-scheduler/src/readiness.rs @@ -248,12 +248,13 @@ pub(crate) fn validate_ready_proof_for_intent( #[must_use] pub struct ValidatedSchedulerReadinessAdmissionRequest(SchedulerReadinessAdmissionRequest); -impl ValidatedSchedulerReadinessAdmissionRequest { - #[must_use] - pub fn as_ref(&self) -> &SchedulerReadinessAdmissionRequest { +impl AsRef for ValidatedSchedulerReadinessAdmissionRequest { + fn as_ref(&self) -> &SchedulerReadinessAdmissionRequest { &self.0 } +} +impl ValidatedSchedulerReadinessAdmissionRequest { #[must_use] pub fn into_inner(self) -> SchedulerReadinessAdmissionRequest { self.0 @@ -274,12 +275,13 @@ impl TryFrom for ValidatedSchedulerReadiness #[must_use] pub struct ValidatedSchedulerReadinessAdmissionDecision(SchedulerReadinessAdmissionDecision); -impl ValidatedSchedulerReadinessAdmissionDecision { - #[must_use] - pub fn as_ref(&self) -> &SchedulerReadinessAdmissionDecision { +impl AsRef for ValidatedSchedulerReadinessAdmissionDecision { + fn as_ref(&self) -> &SchedulerReadinessAdmissionDecision { &self.0 } +} +impl ValidatedSchedulerReadinessAdmissionDecision { #[must_use] pub fn into_inner(self) -> SchedulerReadinessAdmissionDecision { self.0 diff --git a/crates/pantograph-scheduler/src/resource.rs b/crates/pantograph-scheduler/src/resource.rs index 8ce65ae1b..255128945 100644 --- a/crates/pantograph-scheduler/src/resource.rs +++ b/crates/pantograph-scheduler/src/resource.rs @@ -290,12 +290,13 @@ impl SchedulerResourceResidencySnapshot { #[must_use] pub struct ValidatedSchedulerResourceResidencySnapshot(SchedulerResourceResidencySnapshot); -impl ValidatedSchedulerResourceResidencySnapshot { - #[must_use] - pub fn as_ref(&self) -> &SchedulerResourceResidencySnapshot { +impl AsRef for ValidatedSchedulerResourceResidencySnapshot { + fn as_ref(&self) -> &SchedulerResourceResidencySnapshot { &self.0 } +} +impl ValidatedSchedulerResourceResidencySnapshot { #[must_use] pub fn into_inner(self) -> SchedulerResourceResidencySnapshot { self.0 diff --git a/crates/pantograph-scheduler/src/supervision.rs b/crates/pantograph-scheduler/src/supervision.rs index 6b08fe4a9..c99db0bcb 100644 --- a/crates/pantograph-scheduler/src/supervision.rs +++ b/crates/pantograph-scheduler/src/supervision.rs @@ -284,12 +284,13 @@ impl SchedulerLifecycleOwnerSnapshot { #[must_use] pub struct ValidatedSchedulerLifecycleOwnerSnapshot(SchedulerLifecycleOwnerSnapshot); -impl ValidatedSchedulerLifecycleOwnerSnapshot { - #[must_use] - pub fn as_ref(&self) -> &SchedulerLifecycleOwnerSnapshot { +impl AsRef for ValidatedSchedulerLifecycleOwnerSnapshot { + fn as_ref(&self) -> &SchedulerLifecycleOwnerSnapshot { &self.0 } +} +impl ValidatedSchedulerLifecycleOwnerSnapshot { #[must_use] pub fn into_inner(self) -> SchedulerLifecycleOwnerSnapshot { self.0 diff --git a/crates/pantograph-scheduler/tests/as_ref_compatibility.rs b/crates/pantograph-scheduler/tests/as_ref_compatibility.rs new file mode 100644 index 000000000..2e7dc1d26 --- /dev/null +++ b/crates/pantograph-scheduler/tests/as_ref_compatibility.rs @@ -0,0 +1,85 @@ +//! Compile coverage of public accessor call forms after migration to AsRef. +use pantograph_scheduler::*; + +macro_rules! accessor_call_forms { + ($name:ident, $wrapper:ty, $raw:ty) => { + #[test] + fn $name() { + let _: fn(&$wrapper) -> &$raw = |value| value.as_ref(); + let _: fn(&$wrapper) -> &$raw = |value| <$wrapper>::as_ref(value); + let _: fn(&$wrapper) -> &$raw = <$wrapper>::as_ref; + let _: fn(&$wrapper) -> &$raw = |value| <$wrapper as AsRef<$raw>>::as_ref(value); + } + }; +} + +accessor_call_forms!( + validated_scheduler_batch_policy_decision, + ValidatedSchedulerBatchPolicyDecision, + SchedulerBatchPolicyDecision +); +accessor_call_forms!( + validated_scheduler_capability_hint_snapshot, + ValidatedSchedulerCapabilityHintSnapshot, + SchedulerCapabilityHintSnapshot +); +accessor_call_forms!( + validated_scheduler_dispatch_decision, + ValidatedSchedulerDispatchDecision, + SchedulerDispatchDecision +); +accessor_call_forms!( + validated_scheduler_dispatch_selection_request, + ValidatedSchedulerDispatchSelectionRequest, + SchedulerDispatchSelectionRequest +); +accessor_call_forms!( + validated_scheduler_dispatch_selection_decision, + ValidatedSchedulerDispatchSelectionDecision, + SchedulerDispatchSelectionDecision +); +accessor_call_forms!( + validated_scheduler_runtime_handoff, + ValidatedSchedulerRuntimeHandoff, + SchedulerRuntimeHandoff +); +accessor_call_forms!( + validated_schedulable_task_intent, + ValidatedSchedulableTaskIntent, + SchedulableTaskIntent +); +accessor_call_forms!( + validated_scheduler_task_lifecycle_diagnostic_snapshot, + ValidatedSchedulerTaskLifecycleDiagnosticSnapshot, + SchedulerTaskLifecycleDiagnosticSnapshot +); +accessor_call_forms!( + validated_scheduler_task_state_record, + ValidatedSchedulerTaskStateRecord, + SchedulerTaskStateRecord +); +accessor_call_forms!( + validated_scheduler_task_state_transition, + ValidatedSchedulerTaskStateTransition, + SchedulerTaskStateTransition +); +accessor_call_forms!( + validated_scheduler_readiness_admission_request, + ValidatedSchedulerReadinessAdmissionRequest, + SchedulerReadinessAdmissionRequest +); +accessor_call_forms!( + validated_scheduler_readiness_admission_decision, + ValidatedSchedulerReadinessAdmissionDecision, + SchedulerReadinessAdmissionDecision +); +accessor_call_forms!( + validated_scheduler_resource_residency_snapshot, + ValidatedSchedulerResourceResidencySnapshot, + SchedulerResourceResidencySnapshot +); +accessor_call_forms!( + validated_scheduler_lifecycle_owner_snapshot, + ValidatedSchedulerLifecycleOwnerSnapshot, + SchedulerLifecycleOwnerSnapshot +); diff --git a/crates/pantograph-uniffi/src/lib_tests.rs b/crates/pantograph-uniffi/src/lib_tests.rs index 1e822f621..a7235a0c1 100644 --- a/crates/pantograph-uniffi/src/lib_tests.rs +++ b/crates/pantograph-uniffi/src/lib_tests.rs @@ -317,7 +317,18 @@ fn test_workflow_get_capabilities_contract_success() { assert_eq!(response.max_input_bindings, DEFAULT_MAX_INPUT_BINDINGS); assert_eq!(response.max_value_bytes, DEFAULT_MAX_VALUE_BYTES); assert_eq!(response.runtime_requirements.required_models.len(), 0); - assert_eq!(response.runtime_requirements.estimated_peak_ram_mb, Some(0)); + let peak_ram = response.runtime_requirements.resource_estimates.iter().find(|estimate| { + estimate.kind() == pantograph_workflow_service::WorkflowTechnicalFitResourceEstimateKind::PeakRamBytes + }); + assert_eq!( + peak_ram, + Some( + &pantograph_workflow_service::WorkflowTechnicalFitResourceEstimate::available( + pantograph_workflow_service::WorkflowTechnicalFitResourceEstimateKind::PeakRamBytes, + 0, + ) + ), + ); } #[test] diff --git a/crates/pantograph-uniffi/src/runtime.rs b/crates/pantograph-uniffi/src/runtime.rs index 39aff8266..1e4ab0b11 100644 --- a/crates/pantograph-uniffi/src/runtime.rs +++ b/crates/pantograph-uniffi/src/runtime.rs @@ -17,14 +17,17 @@ use pantograph_workflow_service::{ WorkflowExecutionSessionQueueListRequest, WorkflowExecutionSessionQueuePushFrontRequest, WorkflowExecutionSessionQueueReprioritizeRequest, WorkflowExecutionSessionRunRequest, WorkflowExecutionSessionStatusRequest, WorkflowGraphAddEdgeRequest, - WorkflowGraphAddNodeRequest, WorkflowGraphConnectRequest, WorkflowGraphEditSessionCloseRequest, + WorkflowGraphAddNodeRequest, WorkflowGraphConnectRequest, + WorkflowGraphCurrentValidationRefreshRequest, WorkflowGraphEditSessionCloseRequest, WorkflowGraphEditSessionCreateRequest, WorkflowGraphEditSessionGraphRequest, WorkflowGraphGetConnectionCandidatesRequest, WorkflowGraphInsertNodeAndConnectRequest, WorkflowGraphInsertNodeOnEdgeRequest, WorkflowGraphLoadRequest, WorkflowGraphPreviewNodeInsertOnEdgeRequest, WorkflowGraphRemoveEdgeRequest, - WorkflowGraphRemoveNodeRequest, WorkflowGraphSaveRequest, WorkflowGraphUndoRedoStateRequest, - WorkflowGraphUpdateNodeDataRequest, WorkflowGraphUpdateNodePositionRequest, WorkflowIoRequest, - WorkflowPreflightRequest, WorkflowService, WorkflowServiceError, + WorkflowGraphRemoveNodeRequest, WorkflowGraphSaveRequest, + WorkflowGraphSessionExecutableValidationSnapshotPublishRequest, + WorkflowGraphUndoRedoStateRequest, WorkflowGraphUpdateNodeDataRequest, + WorkflowGraphUpdateNodePositionRequest, WorkflowIoRequest, WorkflowPreflightRequest, + WorkflowService, WorkflowServiceError, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tokio::sync::RwLock; @@ -761,6 +764,38 @@ impl FfiPantographRuntime { serialize_response(&response) } + /// Refresh owner-derived graph validation and return WorkflowGraphCurrentValidationRefreshResponse JSON. + pub async fn workflow_graph_refresh_current_validation_summary( + &self, + request_json: String, + ) -> Result { + let request: WorkflowGraphCurrentValidationRefreshRequest = parse_request(request_json)?; + let response = self + .runtime + .workflow_graph_refresh_current_validation_summary(request) + .await + .map_err(map_workflow_service_error)?; + serialize_response(&response) + } + + /// Publish the current graph-session validation as WorkflowExecutableValidationSnapshotRecord JSON. + /// + /// Snapshot provenance is derived by the workflow service, never supplied by + /// the caller. This runtime's attribution store is ephemeral: republish after reopening it. + pub async fn publish_graph_session_executable_validation_snapshot( + &self, + request_json: String, + ) -> Result { + let request: WorkflowGraphSessionExecutableValidationSnapshotPublishRequest = + parse_request(request_json)?; + let response = self + .runtime + .publish_graph_session_executable_validation_snapshot(request) + .await + .map_err(map_workflow_service_error)?; + serialize_response(response.as_record()) + } + /// Close a graph edit session and return WorkflowGraphEditSessionCloseResponse JSON. pub async fn workflow_graph_close_edit_session( &self, @@ -1077,3 +1112,7 @@ fn workflow_adapter_error(code: WorkflowErrorCode, message: impl Into) - #[cfg(test)] #[path = "runtime_tests.rs"] mod runtime_tests; + +#[cfg(test)] +#[path = "runtime_validation_tests.rs"] +mod runtime_validation_tests; diff --git a/crates/pantograph-uniffi/src/runtime_tests.rs b/crates/pantograph-uniffi/src/runtime_tests.rs index 05c001124..9b3492f5e 100644 --- a/crates/pantograph-uniffi/src/runtime_tests.rs +++ b/crates/pantograph-uniffi/src/runtime_tests.rs @@ -10,7 +10,7 @@ use pantograph_workflow_service::{WorkflowErrorCode, WorkflowErrorEnvelope}; use super::{FfiEmbeddedRuntimeConfig, FfiPantographRuntime}; use crate::FfiError; -fn create_temp_root(workflow_id: &str) -> PathBuf { +pub(super) fn create_temp_root(workflow_id: &str) -> PathBuf { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") @@ -270,7 +270,7 @@ fn write_human_input_workflow(root: &Path, workflow_id: &str) { .expect("write workflow"); } -fn workflow_error_envelope(err: FfiError) -> WorkflowErrorEnvelope { +pub(super) fn workflow_error_envelope(err: FfiError) -> WorkflowErrorEnvelope { let message = match err { FfiError::Other { message } => message, other => panic!("expected FfiError::Other with envelope JSON, got {other:?}"), @@ -295,6 +295,8 @@ async fn direct_runtime_runs_workflow_session_from_json() { .await .expect("runtime"); + super::runtime_validation_tests::publish_saved_workflow(&runtime, &root, workflow_id).await; + let create_session_json = runtime .workflow_create_session( serde_json::json!({ @@ -375,7 +377,7 @@ async fn direct_runtime_runs_workflow_session_from_json() { } #[tokio::test] -async fn direct_runtime_workflow_session_run_preserves_invalid_request_envelope() { +async fn direct_runtime_rejects_interactive_graph_at_scheduler_boundary() { let workflow_id = "uniffi-runtime-interactive-run"; let root = create_temp_root(workflow_id); write_human_input_workflow(&root, workflow_id); @@ -392,6 +394,8 @@ async fn direct_runtime_workflow_session_run_preserves_invalid_request_envelope( .await .expect("runtime"); + super::runtime_validation_tests::publish_saved_workflow(&runtime, &root, workflow_id).await; + let create_session_json = runtime .workflow_create_session( serde_json::json!({ @@ -421,14 +425,14 @@ async fn direct_runtime_workflow_session_run_preserves_invalid_request_envelope( .to_string(), ) .await - .expect_err("interactive workflow session run should preserve invalid-request envelope"); + .expect_err("interactive workflow must remain unsupported by the scheduler"); let envelope = workflow_error_envelope(err); - assert_eq!(envelope.code, WorkflowErrorCode::InvalidRequest); - assert_eq!( - envelope.message, - "workflow 'uniffi-runtime-interactive-run' requires interactive input at node 'human-input-1'" - ); + assert_eq!(envelope.code, WorkflowErrorCode::CapabilityViolation); + assert!(envelope + .message + .contains("scheduler task session runner has no execution path")); + assert!(envelope.message.contains("unsupported=1")); runtime.shutdown().await; let _ = std::fs::remove_dir_all(root); @@ -669,7 +673,12 @@ async fn direct_runtime_exposes_backend_owned_graph_authoring_discovery() { .as_array() .expect("queryable ports") .iter() - .any(|port| port["node_type"] == "puma-lib" && port["port_id"] == "model_path")); + .any(|port| port["node_type"] == "puma-lib" && port["port_id"] == "pumas_model_ref")); + assert!(!queryable + .as_array() + .expect("queryable ports") + .iter() + .any(|port| { port["node_type"] == "puma-lib" && port["port_id"] == "model_path" })); let missing = runtime .workflow_graph_get_node_definition("missing-node".to_string()) @@ -732,7 +741,7 @@ async fn direct_runtime_puma_lib_options_use_selector_access_from_pumas_api() { let options_json = runtime .workflow_graph_query_port_options( "puma-lib".to_string(), - "model_path".to_string(), + "pumas_model_ref".to_string(), serde_json::json!({ "limit": 10, "context": { @@ -757,8 +766,15 @@ async fn direct_runtime_puma_lib_options_use_selector_access_from_pumas_api() { .find(|option| option["metadata"]["id"] == "llm/imported/uniffi-test-gguf") .expect("selector option should be present"); + assert!( + option["value"].is_object(), + "selection must carry a model reference, not a display path" + ); + assert_eq!(option["value"]["model_ref_contract_version"], 1); + assert_eq!(option["value"]["model_id"], "llm/imported/uniffi-test-gguf"); + assert_eq!(option["value"], option["metadata"]["pumas_model_ref"]); assert_eq!( - option["value"], + option["metadata"]["display_entry_path"], serde_json::json!(model_file.display().to_string()) ); assert!(result["metadata"]["package_facts_summary_cursor"] @@ -1012,11 +1028,22 @@ async fn direct_runtime_exposes_managed_media_dependency_statuses_and_actions_js let neutral_statuses: serde_json::Value = serde_json::from_str(&neutral_statuses_json) .expect("parse neutral managed dependency statuses"); let neutral_statuses = neutral_statuses.as_array().expect("neutral statuses array"); - assert!(neutral_statuses.iter().any(|status| { - status["key"]["runtime_sidecar"] == serde_json::json!("llama_cpp") - && status["category"] == serde_json::json!("runtime_sidecar") - && status["readiness_state"] == serde_json::json!("ready") - })); + let runtime_status = neutral_statuses + .iter() + .find(|status| { + status["key"]["runtime_sidecar"] == serde_json::json!("llama_cpp") + && status["category"] == serde_json::json!("runtime_sidecar") + }) + .expect("neutral runtime sidecar status"); + // Legacy files under app-data/runtimes are not a managed installation. + // The current owner resolves app-data/third-party/runtimes instead. + assert_eq!(runtime_status["install_state"], "missing"); + assert_eq!(runtime_status["readiness_state"], "missing"); + assert_eq!(runtime_status["available"], false); + assert!(!runtime_status["missing_files"] + .as_array() + .expect("missing runtime files") + .is_empty()); assert!(neutral_statuses.iter().any(|status| { status["key"]["media_tool"] == serde_json::json!("ffmpeg") && status["category"] == serde_json::json!("media_tool") diff --git a/crates/pantograph-uniffi/src/runtime_validation_tests.rs b/crates/pantograph-uniffi/src/runtime_validation_tests.rs new file mode 100644 index 000000000..b1c09bfa7 --- /dev/null +++ b/crates/pantograph-uniffi/src/runtime_validation_tests.rs @@ -0,0 +1,240 @@ +use std::path::Path; +use std::sync::Arc; + +use pantograph_workflow_service::WorkflowErrorCode; +use serde_json::{json, Value}; + +use super::runtime_tests::{create_temp_root, workflow_error_envelope}; +use super::{FfiEmbeddedRuntimeConfig, FfiPantographRuntime}; + +async fn open_runtime(root: &Path) -> Arc { + FfiPantographRuntime::new( + FfiEmbeddedRuntimeConfig { + app_data_dir: root.join("app-data").to_string_lossy().into_owned(), + project_root: root.to_string_lossy().into_owned(), + workflow_roots: Vec::new(), + max_loaded_sessions: None, + }, + None, + ) + .await + .expect("open runtime") +} + +fn parse(response: String) -> Value { + serde_json::from_str(&response).expect("public response JSON") +} + +async fn refresh_saved_graph( + runtime: &FfiPantographRuntime, + root: &Path, + workflow_id: &str, +) -> (Value, Value) { + let file = parse( + runtime + .workflow_graph_load( + json!({ + "path": root.join(".pantograph/workflows").join(format!("{workflow_id}.json")) + }) + .to_string(), + ) + .expect("load saved graph"), + ); + let edit = parse( + runtime + .workflow_graph_create_edit_session( + json!({ + "workflow_id": workflow_id, + "graph": file["graph"], + }) + .to_string(), + ) + .await + .expect("create edit session"), + ); + let validation = parse( + runtime + .workflow_graph_refresh_current_validation_summary( + json!({ + "graph_session_id": edit["session_id"], + "graph_revision": edit["graph_revision"], + }) + .to_string(), + ) + .await + .expect("refresh owner validation"), + ); + assert_eq!(validation["summary"]["state"], "current"); + assert_eq!(validation["summary"]["submit_gate"]["allowed"], true); + assert!(validation["summary"]["validation_session_id"] + .as_str() + .is_some()); + (edit, validation) +} + +fn publication_request(workflow_id: &str, edit: &Value, validation: &Value) -> Value { + json!({ + "workflow_id": workflow_id, + "workflow_semantic_version": "0.1.0", + "graph_session_id": edit["session_id"], + "validation_session_id": validation["summary"]["validation_session_id"], + }) +} + +pub(super) async fn publish_saved_workflow( + runtime: &FfiPantographRuntime, + root: &Path, + workflow_id: &str, +) -> Value { + let (edit, validation) = refresh_saved_graph(runtime, root, workflow_id).await; + let published = parse( + runtime + .publish_graph_session_executable_validation_snapshot( + publication_request(workflow_id, &edit, &validation).to_string(), + ) + .await + .expect("publish owner-derived executable snapshot"), + ); + assert_eq!(published["workflow_id"], workflow_id); + assert_eq!( + published["validation_session_id"], + validation["summary"]["validation_session_id"] + ); + assert!(published["validation_snapshot_id"] + .as_str() + .is_some_and(|id| !id.is_empty())); + runtime + .workflow_graph_close_edit_session(json!({"session_id": edit["session_id"]}).to_string()) + .await + .expect("close published edit session"); + published +} + +async fn run_text( + runtime: &FfiPantographRuntime, + workflow_id: &str, +) -> Result { + let session = parse( + runtime + .workflow_create_session( + json!({ + "workflow_id": workflow_id, + "keep_alive": false, + }) + .to_string(), + ) + .await + .expect("create execution session"), + ); + let result = runtime.workflow_run_session(json!({ + "session_id": session["session_id"], + "workflow_semantic_version": "0.1.0", + "inputs": [{"node_id": "text-input-1", "port_id": "text", "value": "published text"}], + "output_targets": [{"node_id": "text-output-1", "port_id": "text"}], + }).to_string()).await; + runtime + .workflow_close_session(json!({"session_id": session["session_id"]}).to_string()) + .await + .expect("close execution session"); + result +} + +fn assert_missing_publication(error: crate::FfiError) { + let envelope = workflow_error_envelope(error); + assert_eq!(envelope.code, WorkflowErrorCode::InvalidRequest); + assert_eq!( + envelope.message, + "saved executable validation snapshot was not found for workflow version" + ); + let diagnostics = envelope.diagnostics.expect("run admission diagnostics"); + assert!(diagnostics + .workflow_run_id + .as_deref() + .is_some_and(|id| !id.is_empty())); +} + +#[tokio::test] +async fn public_validation_publication_is_required_again_after_runtime_reopen() { + let workflow_id = "uniffi-publication-reopen"; + let root = create_temp_root(workflow_id); + let runtime = open_runtime(&root).await; + assert_missing_publication( + run_text(&runtime, workflow_id) + .await + .expect_err("missing publication"), + ); + publish_saved_workflow(&runtime, &root, workflow_id).await; + let response = parse( + run_text(&runtime, workflow_id) + .await + .expect("published text runs"), + ); + assert_eq!(response["outputs"][0]["value"], "published text"); + runtime.shutdown().await; + drop(runtime); + + let reopened = open_runtime(&root).await; + assert_missing_publication( + run_text(&reopened, workflow_id) + .await + .expect_err("ephemeral snapshot is absent"), + ); + publish_saved_workflow(&reopened, &root, workflow_id).await; + let response = parse( + run_text(&reopened, workflow_id) + .await + .expect("republished text runs"), + ); + assert_eq!(response["outputs"][0]["value"], "published text"); + reopened.shutdown().await; + drop(reopened); + std::fs::remove_dir_all(root).expect("remove fixture"); +} + +#[tokio::test] +async fn public_publication_rejects_stale_validation_and_caller_supplied_proof() { + let workflow_id = "uniffi-publication-stale"; + let root = create_temp_root(workflow_id); + let runtime = open_runtime(&root).await; + let (edit, validation) = refresh_saved_graph(&runtime, &root, workflow_id).await; + let request = publication_request(workflow_id, &edit, &validation); + let mut forged_request = request.clone(); + forged_request["validation_publication"] = json!({"status": "executable"}); + let error = runtime + .publish_graph_session_executable_validation_snapshot(forged_request.to_string()) + .await + .expect_err("caller proof is not accepted"); + let envelope = workflow_error_envelope(error); + assert_eq!(envelope.code, WorkflowErrorCode::InvalidRequest); + assert!(envelope.message.contains("unknown field")); + + let changed = parse( + runtime + .workflow_graph_update_node_data( + json!({ + "session_id": edit["session_id"], + "node_id": "text-input-1", + "data": {"text": "changed after validation"}, + }) + .to_string(), + ) + .await + .expect("mutate graph"), + ); + assert_ne!(changed["graph_revision"], edit["graph_revision"]); + let error = runtime + .publish_graph_session_executable_validation_snapshot(request.to_string()) + .await + .expect_err("stale validation cannot be published"); + let envelope = workflow_error_envelope(error); + assert_eq!(envelope.code, WorkflowErrorCode::InvalidRequest); + assert!(!envelope.message.is_empty()); + assert_missing_publication( + run_text(&runtime, workflow_id) + .await + .expect_err("rejected publication stores no executable snapshot"), + ); + runtime.shutdown().await; + drop(runtime); + std::fs::remove_dir_all(root).expect("remove fixture"); +} diff --git a/crates/pantograph-workflow-service/src/graph/inference_interface_request.rs b/crates/pantograph-workflow-service/src/graph/inference_interface_request.rs index ef991f7b2..527db7027 100644 --- a/crates/pantograph-workflow-service/src/graph/inference_interface_request.rs +++ b/crates/pantograph-workflow-service/src/graph/inference_interface_request.rs @@ -73,6 +73,14 @@ pub enum InferenceInterfaceGraphResolutionDiagnosticCode { InvalidRuntimeSourceContext, } +/// Canonical graph nodes requiring inference-interface and dependency proofs. +pub(crate) fn inference_nodes_in_graph(graph: &WorkflowGraph) -> impl Iterator { + graph + .nodes + .iter() + .filter(|node| node.node_type == NODE_TYPE_GENERIC_INFERENCE) +} + pub fn inference_interface_resolution_inputs_from_graph( graph: &WorkflowGraph, ) -> InferenceInterfaceGraphResolutionInputs { @@ -85,11 +93,7 @@ pub fn inference_interface_resolution_inputs_from_graph( let mut requests = Vec::new(); let mut diagnostics = Vec::new(); - for node in graph - .nodes - .iter() - .filter(|node| node.node_type == NODE_TYPE_GENERIC_INFERENCE) - { + for node in inference_nodes_in_graph(graph) { let Some(model_ref) = model_ref_for_inference_node( &node.id, &node.data, diff --git a/crates/pantograph-workflow-service/src/graph/mod.rs b/crates/pantograph-workflow-service/src/graph/mod.rs index 764c5c7b5..4dad3ac73 100644 --- a/crates/pantograph-workflow-service/src/graph/mod.rs +++ b/crates/pantograph-workflow-service/src/graph/mod.rs @@ -83,6 +83,7 @@ pub use inference_interface_publication::{ InferenceInterfaceNodeProjectionRecord, InferenceInterfacePublicationError, WorkflowGraphInferenceValidationPublication, }; +pub(crate) use inference_interface_request::inference_nodes_in_graph; pub use inference_interface_request::{ inference_interface_resolution_inputs_from_graph, InferenceInterfaceGraphResolutionDiagnostic, InferenceInterfaceGraphResolutionDiagnosticCode, InferenceInterfaceGraphResolutionInput, diff --git a/crates/pantograph-workflow-service/src/graph/session_tests.rs b/crates/pantograph-workflow-service/src/graph/session_tests.rs index 13a755377..c6c3d12ea 100644 --- a/crates/pantograph-workflow-service/src/graph/session_tests.rs +++ b/crates/pantograph-workflow-service/src/graph/session_tests.rs @@ -1324,11 +1324,16 @@ async fn refresh_current_validation_summary_rejects_stale_requested_revision() { async fn refresh_current_validation_summary_rejects_revision_changed_during_fact_lookup() { let entered = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); + let second_entered = Arc::new(Notify::new()); + let second_release = Arc::new(Notify::new()); let store = Arc::new(GraphSessionStore::with_inference_interface_facts_provider( - Arc::new(BlockingInferenceFactsProvider { + Arc::new(SequencedBlockingInferenceFactsProvider { facts: BTreeMap::from([("infer".to_string(), ready_inference_facts())]), - entered: Arc::clone(&entered), - release: Arc::clone(&release), + calls: Mutex::new(0), + first_entered: Arc::clone(&entered), + first_release: Arc::clone(&release), + second_entered: Arc::clone(&second_entered), + second_release: Arc::clone(&second_release), }), )); let session = store @@ -1357,7 +1362,10 @@ async fn refresh_current_validation_summary_rejects_revision_changed_during_fact }) .await .expect("mutate graph while validation facts are pending"); + second_entered.notified().await; release.notify_one(); + second_release.notify_one(); + store.drain_validation_tasks_for_tests().await; let response = refresh .await .expect("refresh task should not panic") @@ -3086,7 +3094,12 @@ fn inference_graph() -> WorkflowGraph { position: Position { x: 200.0, y: 0.0 }, data: serde_json::json!({ "task_kind": "image_generation", - "runtime": "pytorch" + "runtime": "pytorch", + "runtime_source_context": { + "operation_type": "image_generation", + "context_shape_key": "image.1024.square", + "cancellation_mode": "run_scoped" + } }), }, ], diff --git a/crates/pantograph-workflow-service/src/technical_fit.rs b/crates/pantograph-workflow-service/src/technical_fit.rs index a4f5763db..8565861f0 100644 --- a/crates/pantograph-workflow-service/src/technical_fit.rs +++ b/crates/pantograph-workflow-service/src/technical_fit.rs @@ -1636,9 +1636,9 @@ mod tests { let assessment = workflow_runtime_preflight_from_decision( &decision, - &[], + &["candle".to_string()], &["llm/gen-verse/trado-8b-instruct".to_string()], - &[unavailable_candle_runtime()], + &[], ); assert_eq!(assessment.runtime_warnings.len(), 1); diff --git a/crates/pantograph-workflow-service/src/workflow/attribution_api.rs b/crates/pantograph-workflow-service/src/workflow/attribution_api.rs index cadd1d78a..26c9f626e 100644 --- a/crates/pantograph-workflow-service/src/workflow/attribution_api.rs +++ b/crates/pantograph-workflow-service/src/workflow/attribution_api.rs @@ -277,6 +277,7 @@ impl WorkflowService { &workflow_version, snapshot_id, &source, + &graph, ) .map_err(workflow_executable_validation_snapshot_service_error)?; self.reject_changed_dependency_proof_freshness(&snapshot)?; diff --git a/crates/pantograph-workflow-service/src/workflow/diagnostic_errors.rs b/crates/pantograph-workflow-service/src/workflow/diagnostic_errors.rs index 3b7213c48..9499b2eff 100644 --- a/crates/pantograph-workflow-service/src/workflow/diagnostic_errors.rs +++ b/crates/pantograph-workflow-service/src/workflow/diagnostic_errors.rs @@ -20,6 +20,7 @@ use super::{WorkflowErrorDiagnosticsLink, WorkflowService, WorkflowServiceError} pub(crate) enum WorkflowDiagnosticErrorPhase { RunSnapshot, SchedulerAdmission, + SessionRuntimeAdmission, RuntimePreflight, RuntimeModelLoad, RuntimeLaunch, @@ -77,6 +78,18 @@ const WORKFLOW_SERVICE_SOURCE: &[DiagnosticEventSourceComponent] = &[DiagnosticEventSourceComponent::WorkflowService]; const WORKFLOW_DIAGNOSTIC_ERROR_REGISTRY: &[WorkflowDiagnosticErrorRegistryEntry] = &[ + WorkflowDiagnosticErrorRegistryEntry { + phase: WorkflowDiagnosticErrorPhase::SessionRuntimeAdmission, + phase_id: "session_runtime_admission", + code: "session_runtime_admission_failed", + scope_kind: DiagnosticErrorScopeKind::SessionRuntime, + default_source: DiagnosticEventSourceComponent::Scheduler, + allowed_sources: SCHEDULER_SOURCE, + default_severity: DiagnosticErrorSeverity::Error, + default_recoverability: DiagnosticErrorRecoverability::Retryable, + causality_policy: WorkflowDiagnosticCausalityPolicy::DirectProducerKnowledgeOnly, + projection_effect: WorkflowDiagnosticProjectionEffect::DiagnosticsOnly, + }, WorkflowDiagnosticErrorRegistryEntry { phase: WorkflowDiagnosticErrorPhase::RunSnapshot, phase_id: "run_snapshot", @@ -304,8 +317,16 @@ pub(crate) struct WorkflowDiagnosticTransportScope { pub workflow_id: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkflowDiagnosticSessionRuntimeScope { + pub session_id: String, + pub workflow_id: WorkflowId, + pub workflow_run_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum WorkflowDiagnosticErrorScope { + SessionRuntime(WorkflowDiagnosticSessionRuntimeScope), Run(WorkflowDiagnosticRunScope), Node(WorkflowDiagnosticNodeScope), RuntimeModel(WorkflowDiagnosticRuntimeModelScope), @@ -318,6 +339,7 @@ pub(crate) enum WorkflowDiagnosticErrorScope { impl WorkflowDiagnosticErrorScope { fn kind(&self) -> DiagnosticErrorScopeKind { match self { + Self::SessionRuntime(_) => DiagnosticErrorScopeKind::SessionRuntime, Self::Run(_) => DiagnosticErrorScopeKind::Run, Self::Node(_) => DiagnosticErrorScopeKind::Node, Self::RuntimeModel(_) => DiagnosticErrorScopeKind::RuntimeModel, @@ -330,6 +352,16 @@ impl WorkflowDiagnosticErrorScope { fn append_request_fields(&self, request: &mut DiagnosticEventAppendRequest) { match self { + Self::SessionRuntime(scope) => { + request.workflow_id = Some(scope.workflow_id.clone()); + request.workflow_run_id = scope.workflow_run_id.clone(); + request.scheduler_policy_id = Some("priority_then_fifo".to_string()); + if let DiagnosticEventPayload::DiagnosticErrorOccurred(payload) = + &mut request.payload + { + payload.workflow_execution_session_id = Some(scope.session_id.clone()); + } + } Self::Run(scope) => apply_run_context(request, &scope.run), Self::Node(scope) => { apply_run_context(request, &scope.run); @@ -420,6 +452,18 @@ pub(crate) struct WorkflowDiagnosticErrorRecordRequest { } impl WorkflowDiagnosticErrorRecordRequest { + #[track_caller] + pub(crate) fn session_runtime_admission_failed( + scope: WorkflowDiagnosticSessionRuntimeScope, + error: &WorkflowServiceError, + ) -> Self { + Self::from_error( + WorkflowDiagnosticErrorPhase::SessionRuntimeAdmission, + WorkflowDiagnosticErrorScope::SessionRuntime(scope), + error, + ) + } + #[track_caller] pub(crate) fn run_snapshot_failed( scope: WorkflowDiagnosticRunScope, @@ -693,6 +737,7 @@ impl WorkflowService { payload_ref: None, payload: DiagnosticEventPayload::DiagnosticErrorOccurred( DiagnosticErrorOccurredPayload { + workflow_execution_session_id: None, phase: registry_entry.phase_id.to_string(), scope: registry_entry.scope_kind, severity: request.severity.unwrap_or(registry_entry.default_severity), diff --git a/crates/pantograph-workflow-service/src/workflow/executable_validation_snapshot.rs b/crates/pantograph-workflow-service/src/workflow/executable_validation_snapshot.rs index b72ec9b1e..1dc4b8b5b 100644 --- a/crates/pantograph-workflow-service/src/workflow/executable_validation_snapshot.rs +++ b/crates/pantograph-workflow-service/src/workflow/executable_validation_snapshot.rs @@ -204,6 +204,7 @@ impl WorkflowExecutableValidationSnapshotRecord { workflow_version: &WorkflowVersionRecord, validation_snapshot_id: WorkflowExecutableValidationSnapshotId, source: &CurrentExecutableValidationSnapshotSource, + graph: &WorkflowGraph, ) -> Result { let nodes = source .nodes @@ -223,7 +224,7 @@ impl WorkflowExecutableValidationSnapshotRecord { validation_summary: source.validation_summary.clone(), nodes, }; - snapshot.validate()?; + snapshot.validate_graph_inference_coverage(graph)?; Ok(snapshot) } @@ -263,10 +264,6 @@ impl WorkflowExecutableValidationSnapshotRecord { self.nodes.len(), WORKFLOW_EXECUTABLE_VALIDATION_SNAPSHOT_MAX_NODES, )?; - if self.nodes.is_empty() { - return Err(WorkflowExecutableValidationSnapshotError::MissingNodes); - } - let mut seen_nodes = BTreeSet::new(); for node in &self.nodes { node.validate()?; @@ -279,6 +276,45 @@ impl WorkflowExecutableValidationSnapshotRecord { Ok(()) } + /// Validate contextual coverage before publication or executable projection. + /// Empty inference coverage is valid for a graph with no inference nodes. + fn validate_graph_inference_coverage( + &self, + graph: &WorkflowGraph, + ) -> Result<(), WorkflowExecutableValidationSnapshotError> { + self.validate()?; + let mut expected = BTreeSet::new(); + for node in crate::graph::inference_nodes_in_graph(graph) { + if !expected.insert(node.id.as_str()) { + return Err( + WorkflowExecutableValidationSnapshotError::DuplicateGraphInferenceNode { + node_id: node.id.clone(), + }, + ); + } + } + let actual = self + .nodes + .iter() + .map(|node| node.node_id.as_str()) + .collect::>(); + if actual != expected { + return Err( + WorkflowExecutableValidationSnapshotError::InferenceNodeCoverageMismatch { + missing_node_ids: expected + .difference(&actual) + .map(|id| (*id).to_string()) + .collect(), + unexpected_node_ids: actual + .difference(&expected) + .map(|id| (*id).to_string()) + .collect(), + }, + ); + } + Ok(()) + } + pub fn to_attribution_store_request( &self, ) -> Result< @@ -574,8 +610,10 @@ impl ValidatedWorkflowExecutableValidationSnapshotRecord { pub fn scheduler_inference_task_projections( &self, + graph: &WorkflowGraph, ) -> Result { + self.0.validate_graph_inference_coverage(graph)?; let records = self .0 .nodes @@ -764,6 +802,13 @@ pub enum WorkflowExecutableValidationSnapshotError { NonExecutableSummary { status: DraftGraphValidationStatus }, #[error("executable validation snapshot must contain at least one inference node")] MissingNodes, + #[error("duplicate graph inference node '{node_id}'")] + DuplicateGraphInferenceNode { node_id: String }, + #[error("inference snapshot coverage mismatch: missing {missing_node_ids:?}, unexpected {unexpected_node_ids:?}")] + InferenceNodeCoverageMismatch { + missing_node_ids: Vec, + unexpected_node_ids: Vec, + }, #[error("duplicate executable validation snapshot node '{node_id}'")] DuplicateNode { node_id: WorkflowNodeId }, #[error("node '{node_id}' model ref is invalid: {message}")] @@ -1093,6 +1138,92 @@ mod tests { use super::*; + fn inference_graph_fixture() -> WorkflowGraph { + WorkflowGraph { + nodes: vec![crate::graph::GraphNode { + id: "infer_node".to_string(), + node_type: "llm-inference".to_string(), + position: crate::graph::Position { x: 0.0, y: 0.0 }, + data: serde_json::json!({}), + }], + edges: Vec::new(), + derived_graph: None, + } + } + + #[test] + fn empty_snapshot_projects_only_for_zero_inference_graph() { + let mut snapshot = snapshot_fixture(); + snapshot.nodes.clear(); + let imported: WorkflowExecutableValidationSnapshotRecord = serde_json::from_str( + &serde_json::to_string(&snapshot).expect("serialize empty snapshot"), + ) + .expect("deserialize empty snapshot"); + let validated = ValidatedWorkflowExecutableValidationSnapshotRecord::try_from(imported) + .expect("empty structurally executable record"); + assert!(validated + .scheduler_inference_task_projections(&WorkflowGraph::new()) + .is_ok()); + assert!(matches!( + validated.scheduler_inference_task_projections(&inference_graph_fixture()), + Err(WorkflowExecutableValidationSnapshotError::InferenceNodeCoverageMismatch { missing_node_ids, unexpected_node_ids }) + if missing_node_ids == vec!["infer_node"] && unexpected_node_ids.is_empty() + )); + } + + #[test] + fn projection_rejects_extra_and_duplicate_inference_nodes() { + let snapshot = snapshot_fixture(); + let validated = + ValidatedWorkflowExecutableValidationSnapshotRecord::try_from(snapshot.clone()) + .expect("valid runtime snapshot"); + assert!(matches!( + validated.scheduler_inference_task_projections(&WorkflowGraph::new()), + Err(WorkflowExecutableValidationSnapshotError::InferenceNodeCoverageMismatch { missing_node_ids, unexpected_node_ids }) + if missing_node_ids.is_empty() && unexpected_node_ids == vec!["infer_node"] + )); + let mut duplicate_graph = inference_graph_fixture(); + duplicate_graph.nodes.push(duplicate_graph.nodes[0].clone()); + assert!(matches!( + validated.scheduler_inference_task_projections(&duplicate_graph), + Err(WorkflowExecutableValidationSnapshotError::DuplicateGraphInferenceNode { .. }) + )); + let mut duplicate_snapshot = snapshot; + duplicate_snapshot + .nodes + .push(duplicate_snapshot.nodes[0].clone()); + assert!(matches!( + ValidatedWorkflowExecutableValidationSnapshotRecord::try_from(duplicate_snapshot), + Err(WorkflowExecutableValidationSnapshotError::DuplicateNode { .. }) + )); + } + + #[test] + fn owner_publication_requires_exact_graph_inference_coverage() { + let version = workflow_version_fixture(); + let mut source = snapshot_source_fixture(); + source.nodes.clear(); + let id = WorkflowExecutableValidationSnapshotId::generate(); + assert!( + WorkflowExecutableValidationSnapshotRecord::from_snapshot_source( + &version, + id.clone(), + &source, + &WorkflowGraph::new() + ) + .is_ok() + ); + assert!(matches!( + WorkflowExecutableValidationSnapshotRecord::from_snapshot_source( + &version, + id, + &source, + &inference_graph_fixture() + ), + Err(WorkflowExecutableValidationSnapshotError::InferenceNodeCoverageMismatch { .. }) + )); + } + #[test] fn validation_publication_without_dependency_proof_fails_closed() { let workflow_version = workflow_version_fixture(); @@ -1128,6 +1259,7 @@ mod tests { &workflow_version, snapshot_id.clone(), &source, + &inference_graph_fixture(), ) .expect("source should compact to snapshot"); @@ -1163,7 +1295,7 @@ mod tests { ValidatedWorkflowExecutableValidationSnapshotRecord::try_from(snapshot.clone()) .expect("snapshot should validate"); let projections = validated - .scheduler_inference_task_projections() + .scheduler_inference_task_projections(&inference_graph_fixture()) .expect("snapshot should project scheduler tasks"); let node_id = SchedulerNodeId::parse("infer_node").expect("valid scheduler node id"); diff --git a/crates/pantograph-workflow-service/src/workflow/session_execution_api.rs b/crates/pantograph-workflow-service/src/workflow/session_execution_api.rs index d8ec105d0..b282e59c3 100644 --- a/crates/pantograph-workflow-service/src/workflow/session_execution_api.rs +++ b/crates/pantograph-workflow-service/src/workflow/session_execution_api.rs @@ -777,7 +777,7 @@ impl WorkflowService { }, )?; let projections = snapshot - .scheduler_inference_task_projections() + .scheduler_inference_task_projections(&graph) .map_err(|error| WorkflowServiceError::InvalidRequest(error.to_string()))?; return workflow_scheduler_task_graph_with_inference_projections( &workflow_id, diff --git a/crates/pantograph-workflow-service/src/workflow/session_runtime.rs b/crates/pantograph-workflow-service/src/workflow/session_runtime.rs index 5a5db1fd1..e99db3310 100644 --- a/crates/pantograph-workflow-service/src/workflow/session_runtime.rs +++ b/crates/pantograph-workflow-service/src/workflow/session_runtime.rs @@ -1,16 +1,20 @@ use pantograph_diagnostics_ledger::{ - DiagnosticEventAppendRequest, DiagnosticEventPayload, DiagnosticEventPrivacyClass, - DiagnosticEventRetentionClass, DiagnosticEventSourceComponent, SchedulerModelCacheState, - SchedulerModelLifecycleChangedPayload, SchedulerModelLifecycleTransition, + sanitize_diagnostic_error_text, DiagnosticEventAppendRequest, DiagnosticEventPayload, + DiagnosticEventPrivacyClass, DiagnosticEventRetentionClass, DiagnosticEventSourceComponent, + SchedulerModelCacheState, SchedulerModelLifecycleChangedPayload, + SchedulerModelLifecycleTransition, MAX_DIAGNOSTIC_ERROR_TEXT_LEN, }; use pantograph_runtime_attribution::{ - BucketId, ClientId, ClientSessionId, WorkflowId, WorkflowRunId, WorkflowRunSnapshotRecord, + BucketId, ClientId, ClientSessionId, WorkflowId, WorkflowRunSnapshotRecord, }; use pantograph_timing_contracts::{checked_timing_duration_ms, WorkflowTimingAttemptId}; use crate::scheduler::WorkflowExecutionSessionPreflightCache; use crate::technical_fit::WorkflowTechnicalFitOverride; +use super::diagnostic_errors::{ + WorkflowDiagnosticErrorRecordRequest, WorkflowDiagnosticSessionRuntimeScope, +}; use super::{ WorkflowExecutionSessionRetentionHint, WorkflowExecutionSessionRuntimeSelectionTarget, WorkflowExecutionSessionRuntimeUnloadCandidate, WorkflowExecutionSessionSummary, @@ -49,8 +53,6 @@ fn workflow_timing_duration_ms( pub(super) struct WorkflowSessionRuntimeAdmissionDiagnosticContext<'a> { pub(super) session: &'a WorkflowExecutionSessionSummary, pub(super) snapshot: Option<&'a WorkflowRunSnapshotRecord>, - pub(super) workflow_run_id: &'a str, - pub(super) workflow_semantic_version: &'a str, } struct CapacityRebalanceModelLifecycleEventRequest<'a> { @@ -162,7 +164,8 @@ impl WorkflowService { duration_ms: None, error: None, }, - )?; + ) + .map_err(lifecycle_diagnostic_failure)?; self.record_capacity_rebalance_model_lifecycle_events_if_configured( CapacityRebalanceModelLifecycleEventRequest { context, @@ -173,7 +176,8 @@ impl WorkflowService { duration_ms: None, error: None, }, - )?; + ) + .map_err(lifecycle_diagnostic_failure)?; } let unload_started_at_ms = crate::scheduler::unix_timestamp_ms(); let unload_result = host @@ -183,49 +187,55 @@ impl WorkflowService { WorkflowExecutionSessionUnloadReason::CapacityRebalance, ) .await; + // Host residency is authoritative even if subsequent telemetry fails. + if unload_result.is_ok() { + self.session_store_guard()? + .mark_runtime_loaded(&candidate.session_id, false)?; + } let unload_duration_ms = workflow_timing_duration_ms( &unload_timing_attempt_id, unload_started_at_ms, crate::scheduler::unix_timestamp_ms(), )?; - if let Some(context) = diagnostics_context.as_ref() { - match &unload_result { - Ok(()) => { - self.record_capacity_rebalance_model_lifecycle_events_if_configured( - CapacityRebalanceModelLifecycleEventRequest { - context, - candidate: &candidate, - transition: - SchedulerModelLifecycleTransition::UnloadCompleted, - timing_attempt_id: Some(unload_timing_attempt_id.as_str()), - reason: "capacity rebalance unloaded selected session", - duration_ms: Some(unload_duration_ms), - error: None, - }, - )?; - } - Err(error) => { - let error_text = error.to_string(); - let _diagnostic_result = self - .record_capacity_rebalance_model_lifecycle_events_if_configured( - CapacityRebalanceModelLifecycleEventRequest { - context, - candidate: &candidate, - transition: SchedulerModelLifecycleTransition::UnloadFailed, - timing_attempt_id: Some(unload_timing_attempt_id.as_str()), - reason: - "capacity rebalance failed to unload selected session", - duration_ms: Some(unload_duration_ms), - error: Some(error_text.as_str()), - }, - ); - } + let terminal_diagnostics = if let Some(context) = diagnostics_context.as_ref() { + let error_text = unload_result.as_ref().err().map(|error| { + sanitize_diagnostic_error_text( + &error.to_string(), + MAX_DIAGNOSTIC_ERROR_TEXT_LEN, + ) + }); + self.record_capacity_rebalance_model_lifecycle_events_if_configured( + CapacityRebalanceModelLifecycleEventRequest { + context, + candidate: &candidate, + transition: if unload_result.is_ok() { + SchedulerModelLifecycleTransition::UnloadCompleted + } else { + SchedulerModelLifecycleTransition::UnloadFailed + }, + timing_attempt_id: Some(unload_timing_attempt_id.as_str()), + reason: if unload_result.is_ok() { + "capacity rebalance unloaded selected session" + } else { + "capacity rebalance failed to unload selected session" + }, + duration_ms: Some(unload_duration_ms), + error: error_text.as_deref(), + }, + ) + } else { + Ok(()) + }; + match unload_result { + Ok(()) => terminal_diagnostics.map_err(lifecycle_diagnostic_failure)?, + Err(error) => { + return Err(match terminal_diagnostics { + Ok(()) => error, + Err(recording_error) => error + .with_diagnostics(lifecycle_diagnostic_link(&recording_error)), + }) } } - unload_result?; - if let Ok(mut store) = self.session_store.lock() { - let _ = store.mark_runtime_loaded(&candidate.session_id, false); - } } RuntimeDecision::LoadTarget { workflow_id, @@ -260,7 +270,10 @@ impl WorkflowService { return Ok(()); } - let workflow_run_id = WorkflowRunId::try_from(request.context.workflow_run_id.to_string())?; + let workflow_run_id = request + .context + .snapshot + .map(|snapshot| snapshot.workflow_run_id.clone()); let workflow_id = workflow_id_for_runtime_admission_event( request.context.session, request.context.snapshot, @@ -276,21 +289,16 @@ impl WorkflowService { source_component: DiagnosticEventSourceComponent::Scheduler, source_instance_id: Some("workflow-session-scheduler".to_string()), occurred_at_ms: crate::scheduler::unix_timestamp_ms() as i64, - workflow_run_id: Some(workflow_run_id.clone()), + workflow_run_id: workflow_run_id.clone(), workflow_id: Some(workflow_id.clone()), workflow_version_id: request .context .snapshot .map(|snapshot| snapshot.workflow_version_id.clone()), - workflow_semantic_version: Some( - request - .context - .snapshot - .map(|snapshot| snapshot.workflow_semantic_version.clone()) - .unwrap_or_else(|| { - request.context.workflow_semantic_version.to_string() - }), - ), + workflow_semantic_version: request + .context + .snapshot + .map(|snapshot| snapshot.workflow_semantic_version.clone()), node_id: None, node_type: None, node_version: None, @@ -320,6 +328,12 @@ impl WorkflowService { payload_ref: None, payload: DiagnosticEventPayload::SchedulerModelLifecycleChanged( SchedulerModelLifecycleChangedPayload { + workflow_execution_session_id: Some( + request.context.session.session_id.clone(), + ), + unloaded_workflow_execution_session_id: Some( + request.candidate.session_id.clone(), + ), transition: request.transition, cache_state: Some(SchedulerModelCacheState::for_lifecycle_transition( request.transition, @@ -407,8 +421,51 @@ impl WorkflowService { super::format_runtime_not_ready_message(&cache.blocking_runtime_issues), )); } - self.ensure_session_runtime_loaded(host, session_id, None) - .await + let session = self.session_store_guard()?.session_summary(session_id)?; + let result = self + .ensure_session_runtime_loaded( + host, + session_id, + Some(WorkflowSessionRuntimeAdmissionDiagnosticContext { + session: &session, + snapshot: None, + }), + ) + .await; + match result { + Ok(()) => Ok(()), + Err(error) => { + let outcome = self.record_workflow_diagnostic_error_if_configured( + WorkflowDiagnosticErrorRecordRequest::session_runtime_admission_failed( + WorkflowDiagnosticSessionRuntimeScope { + session_id: session.session_id.clone(), + workflow_id: WorkflowId::try_from(session.workflow_id.clone())?, + workflow_run_id: None, + }, + &error, + ), + ); + let prior_unavailable = error + .diagnostics() + .and_then(|link| link.diagnostics_unavailable.clone()); + let mut diagnostics = match outcome { + Ok(outcome) => outcome.into_error_link(None::), + Err(recording_error) => super::WorkflowErrorDiagnosticsLink { + workflow_run_id: None, + diagnostic_event_id: None, + diagnostics_unavailable: Some(recording_error.message().to_string()), + }, + }; + if let Some(prior) = prior_unavailable { + diagnostics.diagnostics_unavailable = + Some(match diagnostics.diagnostics_unavailable { + Some(current) if current != prior => format!("{prior}; {current}"), + _ => prior, + }); + } + Err(error.with_diagnostics(diagnostics)) + } + } } pub(super) async fn refresh_session_runtime_affinity_basis( @@ -486,3 +543,19 @@ fn runtime_event_bucket_id( .map_err(WorkflowServiceError::from), } } + +fn lifecycle_diagnostic_link(error: &WorkflowServiceError) -> super::WorkflowErrorDiagnosticsLink { + super::WorkflowErrorDiagnosticsLink { + workflow_run_id: None, + diagnostic_event_id: None, + diagnostics_unavailable: Some(format!( + "capacity lifecycle event unavailable: {}", + sanitize_diagnostic_error_text(error.message(), MAX_DIAGNOSTIC_ERROR_TEXT_LEN) + )), + } +} + +fn lifecycle_diagnostic_failure(error: WorkflowServiceError) -> WorkflowServiceError { + let link = lifecycle_diagnostic_link(&error); + error.with_diagnostics(link) +} diff --git a/crates/pantograph-workflow-service/src/workflow/task_execution_classification.rs b/crates/pantograph-workflow-service/src/workflow/task_execution_classification.rs index b1d0a5f64..e722d8699 100644 --- a/crates/pantograph-workflow-service/src/workflow/task_execution_classification.rs +++ b/crates/pantograph-workflow-service/src/workflow/task_execution_classification.rs @@ -108,7 +108,7 @@ mod tests { #[test] fn classifier_rejects_excluded_and_unknown_nodes() { - for node_type in ["model-provider", "expand-settings", "image-output"] { + for node_type in ["model-provider", "image-output"] { let contract = contract(node_type); assert_eq!( @@ -118,12 +118,31 @@ mod tests { ); } + assert!(!workflow_nodes::builtin_node_contracts() + .expect("built-in node contracts") + .iter() + .any(|contract| contract.node_type.as_str() == "expand-settings")); + assert_eq!( + classify_workflow_scheduler_task("expand-settings", None), + WorkflowSchedulerTaskExecutionClass::Unsupported + ); assert_eq!( classify_workflow_scheduler_task("not-registered", None), WorkflowSchedulerTaskExecutionClass::Unsupported ); } + #[test] + fn other_inference_bearing_node_types_remain_unsupported() { + let mut other = contract("llm-inference"); + other.node_type = "future-inference".parse().expect("valid node type"); + assert!(!other.inference_tasks.is_empty()); + assert_eq!( + classify_workflow_scheduler_task("future-inference", Some(&other)), + WorkflowSchedulerTaskExecutionClass::Unsupported, + ); + } + #[test] fn classifier_requires_matching_contract_facts() { let text_contract = contract("text-input"); diff --git a/crates/pantograph-workflow-service/src/workflow/tests.rs b/crates/pantograph-workflow-service/src/workflow/tests.rs index ac76e59c2..d3a6fecbe 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests.rs @@ -23,6 +23,7 @@ mod scheduler_snapshot; mod scheduler_snapshot_diagnostics; mod session_admission; mod session_capacity; +mod session_capacity_faults; mod session_capacity_limits; mod session_execution; mod session_queue; diff --git a/crates/pantograph-workflow-service/src/workflow/tests/diagnostics.rs b/crates/pantograph-workflow-service/src/workflow/tests/diagnostics.rs index 588aa7aa3..19b542165 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/diagnostics.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/diagnostics.rs @@ -408,12 +408,13 @@ fn workflow_diagnostic_error_recorder_validates_registered_scope() { fn workflow_diagnostic_error_registry_declares_phase_contracts() { let registry = registered_workflow_diagnostic_error_phases(); - assert_eq!(registry.len(), 13); + assert_eq!(registry.len(), 14); for entry in registry { assert!(!entry.phase_id.starts_with("inference")); assert!(!entry.code.starts_with("inference")); } for phase_id in [ + "session_runtime_admission", "runtime_preflight", "runtime_model_load", "runtime_launch", @@ -426,6 +427,22 @@ fn workflow_diagnostic_error_registry_declares_phase_contracts() { "missing canonical diagnostic error phase {phase_id}" ); } + let session_runtime = registry + .iter() + .find(|entry| entry.phase == WorkflowDiagnosticErrorPhase::SessionRuntimeAdmission) + .expect("session runtime phase"); + assert_eq!( + session_runtime.scope_kind, + pantograph_diagnostics_ledger::DiagnosticErrorScopeKind::SessionRuntime + ); + assert_eq!( + session_runtime.projection_effect, + WorkflowDiagnosticProjectionEffect::DiagnosticsOnly + ); + assert_eq!( + session_runtime.default_severity, + pantograph_diagnostics_ledger::DiagnosticErrorSeverity::Error + ); let runtime_model_load = registry .iter() .find(|entry| entry.phase == WorkflowDiagnosticErrorPhase::RuntimeModelLoad) diff --git a/crates/pantograph-workflow-service/src/workflow/tests/session_capacity.rs b/crates/pantograph-workflow-service/src/workflow/tests/session_capacity.rs index ba220a464..db7001255 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/session_capacity.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/session_capacity.rs @@ -43,20 +43,26 @@ async fn workflow_execution_session_capacity_rebalance_uses_host_selected_candid let selecting_host = SelectingRuntimeHost::new(second.session_id.clone(), unloads.clone()); service - .run_workflow_execution_session( + .workflow_set_execution_session_keep_alive( &selecting_host, - WorkflowExecutionSessionRunRequest { + WorkflowExecutionSessionKeepAliveRequest { session_id: third_session_id.clone(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, - priority: None, + keep_alive: true, }, ) .await - .expect("run third session"); + .expect("admit target keep-alive runtime"); + + service + .workflow_set_execution_session_keep_alive( + &selecting_host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: third_session_id.clone(), + keep_alive: false, + }, + ) + .await + .expect("disable target keep-alive and unload runtime"); let unloads = unloads.lock().expect("unloads lock poisoned"); assert_eq!( @@ -121,27 +127,37 @@ async fn workflow_execution_session_capacity_rebalance_preserves_unload_error_wh .expect("create target session"); let error = service - .run_workflow_execution_session( + .workflow_set_execution_session_keep_alive( &host, - WorkflowExecutionSessionRunRequest { - session_id: target.session_id, - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, - priority: None, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: true, }, ) .await .expect_err("capacity rebalance should preserve runtime unload failure"); + let state = service + .session_store_guard() + .expect("session store") + .session_summary(&target.session_id) + .expect("target still exists"); + assert!(!state.keep_alive); + assert_eq!(state.state, WorkflowExecutionSessionState::IdleUnloaded); + assert_eq!( + service + .session_store_guard() + .expect("store") + .loaded_session_count(), + 2 + ); assert_eq!(error.code(), WorkflowErrorCode::RuntimeNotReady); assert!(error.message().contains("runtime unload failed")); assert!(!error.message().contains("diagnostics ledger lock poisoned")); let diagnostics = error .diagnostics() .expect("diagnostics unavailable link should be attached"); + assert!(diagnostics.workflow_run_id.is_none()); assert!(diagnostics.diagnostic_event_id.is_none()); assert!(diagnostics .diagnostics_unavailable @@ -191,20 +207,26 @@ async fn workflow_execution_session_capacity_rebalance_preserves_affine_idle_run .expect("create target session"); service - .run_workflow_execution_session( + .workflow_set_execution_session_keep_alive( &host, - WorkflowExecutionSessionRunRequest { + WorkflowExecutionSessionKeepAliveRequest { session_id: target.session_id.clone(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, - priority: None, + keep_alive: true, + }, + ) + .await + .expect("admit target keep-alive runtime"); + + service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: false, }, ) .await - .expect("run target session"); + .expect("disable target keep-alive and unload runtime"); let unloads = unloads.lock().expect("unloads lock poisoned"); assert_eq!( @@ -273,20 +295,26 @@ async fn workflow_execution_session_capacity_rebalance_preserves_shared_model_id .expect("create target session"); service - .run_workflow_execution_session( + .workflow_set_execution_session_keep_alive( &host, - WorkflowExecutionSessionRunRequest { + WorkflowExecutionSessionKeepAliveRequest { session_id: target.session_id.clone(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, - priority: None, + keep_alive: true, }, ) .await - .expect("run target session"); + .expect("admit target keep-alive runtime"); + + service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: false, + }, + ) + .await + .expect("disable target keep-alive and unload runtime"); let unloads = unloads.lock().expect("unloads lock poisoned"); assert_eq!( @@ -341,6 +369,17 @@ async fn workflow_execution_session_capacity_rebalance_preserves_shared_model_id .as_str() .expect("capacity unload timing attempt id"); assert!(timing_attempt_id.starts_with("timing_attempt_")); + for event in &capacity_rebalance_events { + assert!(event.workflow_run_id.is_none()); + let payload: serde_json::Value = + serde_json::from_str(&event.payload_json).expect("payload"); + assert_eq!(payload["workflow_execution_session_id"], target.session_id); + assert_eq!( + payload["unloaded_workflow_execution_session_id"], + other_model.session_id + ); + } + assert!(unload_completed_payload["duration_ms"].as_u64().is_some()); assert!(capacity_rebalance_events[0] .payload_json .contains("\"cache_state\":\"unload_requested\"")); @@ -427,20 +466,26 @@ async fn workflow_execution_session_capacity_rebalance_preserves_shared_backend_ .expect("create target session"); service - .run_workflow_execution_session( + .workflow_set_execution_session_keep_alive( &host, - WorkflowExecutionSessionRunRequest { + WorkflowExecutionSessionKeepAliveRequest { session_id: target.session_id.clone(), - workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), - output_targets: None, - override_selection: None, - timeout_ms: None, - priority: None, + keep_alive: true, }, ) .await - .expect("run target session"); + .expect("admit target keep-alive runtime"); + + service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: false, + }, + ) + .await + .expect("disable target keep-alive and unload runtime"); let unloads = unloads.lock().expect("unloads lock poisoned"); assert_eq!( @@ -454,3 +499,116 @@ async fn workflow_execution_session_capacity_rebalance_preserves_shared_backend_ .iter() .any(|session_id| session_id == &shared_backend.session_id)); } + +#[tokio::test] +async fn keep_alive_capacity_failure_records_session_error_and_rolls_back() { + let service = WorkflowService::with_capacity_limits(3, 2) + .with_diagnostics_ledger(SqliteDiagnosticsLedger::open_in_memory().expect("ledger")); + let host = SelectingRuntimeHost::new(String::new(), Arc::new(Mutex::new(Vec::new()))); + for workflow_id in ["loaded-a", "loaded-b"] { + service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: workflow_id.to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect("loaded session"); + } + let target = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "target".to_string(), + usage_profile: None, + keep_alive: false, + }, + ) + .await + .expect("target session"); + let error = service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: true, + }, + ) + .await + .expect_err("host declined every unload candidate"); + assert_eq!(error.code(), WorkflowErrorCode::SchedulerBusy); + let link = error.diagnostics().expect("diagnostic link"); + assert!(link.workflow_run_id.is_none()); + assert!(link.diagnostic_event_id.is_some()); + assert!(link.diagnostics_unavailable.is_none()); + let state = service + .session_store_guard() + .expect("store") + .session_summary(&target.session_id) + .expect("target retained"); + assert!(!state.keep_alive); + assert_eq!(state.state, WorkflowExecutionSessionState::IdleUnloaded); + let events = { + let ledger = service.diagnostics_ledger_guard().expect("ledger"); + pantograph_diagnostics_ledger::DiagnosticsLedgerRepository::diagnostic_events_after( + &*ledger, 0, 20, + ) + .expect("events") + }; + let event = events + .iter() + .find(|event| Some(&event.event_id) == link.diagnostic_event_id.as_ref()) + .expect("recorded error"); + assert!(event.workflow_run_id.is_none()); + let payload: serde_json::Value = serde_json::from_str(&event.payload_json).expect("payload"); + assert_eq!(payload["phase"], "session_runtime_admission"); + assert_eq!(payload["workflow_execution_session_id"], target.session_id); + assert_eq!(payload["scope"], "session_runtime"); +} + +#[tokio::test] +async fn failed_keep_alive_creation_removes_only_the_new_session() { + let service = WorkflowService::with_capacity_limits(2, 1) + .with_diagnostics_ledger(SqliteDiagnosticsLedger::open_in_memory().expect("ledger")); + let host = SelectingRuntimeHost::new(String::new(), Arc::new(Mutex::new(Vec::new()))); + let existing = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "existing".to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect("existing loaded session"); + let error = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "rejected".to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect_err("capacity admission rejected"); + assert_eq!(error.code(), WorkflowErrorCode::SchedulerBusy); + assert!(error + .diagnostics() + .expect("error link") + .workflow_run_id + .is_none()); + let store = service.session_store_guard().expect("store"); + assert_eq!(store.active.len(), 1); + assert_eq!(store.loaded_session_count(), 1); + assert!( + store + .session_summary(&existing.session_id) + .expect("existing retained") + .keep_alive + ); +} diff --git a/crates/pantograph-workflow-service/src/workflow/tests/session_capacity_faults.rs b/crates/pantograph-workflow-service/src/workflow/tests/session_capacity_faults.rs new file mode 100644 index 000000000..fc0bde245 --- /dev/null +++ b/crates/pantograph-workflow-service/src/workflow/tests/session_capacity_faults.rs @@ -0,0 +1,337 @@ +use super::*; +use async_trait::async_trait; + +struct CapacityFaultHost { + inner: MockWorkflowHost, + loads: Mutex>, + unloads: Mutex>, + unload_error: Option, +} + +impl CapacityFaultHost { + fn new(unload_error: Option) -> Self { + Self { + inner: MockWorkflowHost::new(16, 4096), + loads: Mutex::new(Vec::new()), + unloads: Mutex::new(Vec::new()), + unload_error, + } + } +} + +#[async_trait] +impl WorkflowHost for CapacityFaultHost { + async fn validate_workflow(&self, id: &str) -> Result<(), WorkflowServiceError> { + self.inner.validate_workflow(id).await + } + async fn workflow_graph_fingerprint(&self, id: &str) -> Result { + self.inner.workflow_graph_fingerprint(id).await + } + async fn workflow_capabilities( + &self, + id: &str, + ) -> Result { + self.inner.workflow_capabilities(id).await + } + async fn runtime_capabilities( + &self, + ) -> Result, WorkflowServiceError> { + self.inner.runtime_capabilities().await + } + async fn load_session_runtime( + &self, + session_id: &str, + _id: &str, + _usage: Option<&str>, + _hint: WorkflowExecutionSessionRetentionHint, + ) -> Result<(), WorkflowServiceError> { + self.loads + .lock() + .expect("loads") + .push(session_id.to_string()); + Ok(()) + } + async fn unload_session_runtime( + &self, + session_id: &str, + _id: &str, + _reason: WorkflowExecutionSessionUnloadReason, + ) -> Result<(), WorkflowServiceError> { + if let Some(error) = &self.unload_error { + return Err(WorkflowServiceError::RuntimeNotReady(error.clone())); + } + self.unloads + .lock() + .expect("unloads") + .push(session_id.to_string()); + Ok(()) + } + async fn run_workflow( + &self, + _id: &str, + _inputs: &[WorkflowPortBinding], + _targets: Option<&[WorkflowOutputTarget]>, + _options: WorkflowRunOptions, + _handle: WorkflowRunHandle, + ) -> Result, WorkflowServiceError> { + panic!("explicit keep-alive must not execute a workflow") + } +} + +async fn assert_completed_append_failure_preserves_residency(enable_existing: bool) { + let temp = tempfile::tempdir().expect("temp ledger"); + let path = temp.path().join("diagnostics.sqlite"); + let service = WorkflowService::with_capacity_limits(2, 1) + .with_diagnostics_ledger(SqliteDiagnosticsLedger::open(&path).expect("ledger")); + let host = CapacityFaultHost::new(None); + let victim = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect("loaded victim"); + let connection = rusqlite::Connection::open(&path).expect("fault connection"); + connection + .execute_batch( + "CREATE TRIGGER reject_terminal BEFORE INSERT ON diagnostic_events + WHEN json_extract(NEW.payload_json, '$.transition') = 'unload_completed' + BEGIN SELECT RAISE(FAIL, 'injected terminal append failure'); END;", + ) + .expect("fault trigger"); + let target_id; + let error = if enable_existing { + let target = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: false, + }, + ) + .await + .expect("unloaded target"); + target_id = Some(target.session_id.clone()); + service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id, + keep_alive: true, + }, + ) + .await + .expect_err("terminal telemetry failed") + } else { + target_id = None; + service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect_err("terminal telemetry failed") + }; + let link = error.diagnostics().expect("explicit telemetry failure"); + assert!( + link.diagnostic_event_id.is_some(), + "canonical error is independently recorded" + ); + assert!(link + .diagnostics_unavailable + .as_deref() + .expect("lifecycle gap") + .contains("injected terminal append failure")); + assert!(link.workflow_run_id.is_none()); + { + let store = service.session_store_guard().expect("store"); + assert_eq!(store.loaded_session_count(), 0); + assert_eq!( + store + .session_summary(&victim.session_id) + .expect("victim") + .state, + WorkflowExecutionSessionState::IdleUnloaded + ); + assert_eq!(store.active.len(), if enable_existing { 2 } else { 1 }); + if let Some(id) = target_id { + let target = store.session_summary(&id).expect("target"); + assert!(!target.keep_alive); + assert_eq!(target.state, WorkflowExecutionSessionState::IdleUnloaded); + } + } + assert_eq!( + *host.unloads.lock().expect("unloads"), + vec![victim.session_id.clone()] + ); + connection + .execute_batch("DROP TRIGGER reject_terminal") + .expect("remove fault"); + service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: victim.session_id.clone(), + keep_alive: true, + }, + ) + .await + .expect("recovery reloads unloaded victim"); + assert_eq!( + *host.loads.lock().expect("loads"), + vec![victim.session_id.clone(), victim.session_id] + ); + assert_eq!( + service + .session_store_guard() + .expect("store") + .loaded_session_count(), + 1 + ); +} + +#[tokio::test] +async fn keep_alive_creation_terminal_append_failure_preserves_residency_and_recovery() { + assert_completed_append_failure_preserves_residency(false).await; +} + +#[tokio::test] +async fn keep_alive_enablement_terminal_append_failure_preserves_residency_and_recovery() { + assert_completed_append_failure_preserves_residency(true).await; +} + +async fn assert_failed_unload_reports_terminal_diagnostics(reject_terminal: bool) { + let temp = tempfile::tempdir().expect("temp ledger"); + let path = temp.path().join("diagnostics.sqlite"); + let service = WorkflowService::with_capacity_limits(2, 1) + .with_diagnostics_ledger(SqliteDiagnosticsLedger::open(&path).expect("ledger")); + let message = "unload refused\nworker still busy"; + let host = CapacityFaultHost::new(Some(message.to_string())); + let victim = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: true, + }, + ) + .await + .expect("loaded victim"); + let target = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: false, + }, + ) + .await + .expect("unloaded target"); + if reject_terminal { + rusqlite::Connection::open(&path) + .expect("fault connection") + .execute_batch( + "CREATE TRIGGER reject_failed BEFORE INSERT ON diagnostic_events + WHEN json_extract(NEW.payload_json, '$.transition') = 'unload_failed' + BEGIN SELECT RAISE(FAIL, 'injected failed-event append failure'); END;", + ) + .expect("fault trigger"); + } + let error = service + .workflow_set_execution_session_keep_alive( + &host, + WorkflowExecutionSessionKeepAliveRequest { + session_id: target.session_id.clone(), + keep_alive: true, + }, + ) + .await + .expect_err("host unload failure"); + assert_eq!(error.code(), WorkflowErrorCode::RuntimeNotReady); + assert_eq!(error.message(), message); + let link = error.diagnostics().expect("canonical error link"); + assert!(link.workflow_run_id.is_none()); + assert!(link.diagnostic_event_id.is_some()); + if reject_terminal { + assert!(link + .diagnostics_unavailable + .as_deref() + .expect("terminal event gap") + .contains("injected failed-event append failure")); + } else { + assert!(link.diagnostics_unavailable.is_none()); + } + let store = service.session_store_guard().expect("store"); + assert_eq!(store.loaded_session_count(), 1); + assert_eq!( + store + .session_summary(&victim.session_id) + .expect("victim") + .state, + WorkflowExecutionSessionState::IdleLoaded + ); + assert!( + !store + .session_summary(&target.session_id) + .expect("target") + .keep_alive + ); + drop(store); + let ledger = service.diagnostics_ledger_guard().expect("ledger"); + let events = + pantograph_diagnostics_ledger::DiagnosticsLedgerRepository::diagnostic_events_after( + &*ledger, 0, 20, + ) + .expect("events"); + let lifecycle = events.iter().filter(|event| event.event_kind == pantograph_diagnostics_ledger::DiagnosticEventKind::SchedulerModelLifecycleChanged).collect::>(); + assert_eq!(lifecycle.len(), if reject_terminal { 2 } else { 3 }); + let payloads = lifecycle + .iter() + .map(|event| { + assert!(event.workflow_run_id.is_none()); + serde_json::from_str::(&event.payload_json).expect("payload") + }) + .collect::>(); + assert_eq!(payloads[0]["transition"], "unload_scheduled"); + assert_eq!(payloads[1]["transition"], "unload_started"); + assert_eq!( + payloads[0]["timing_attempt_id"], + payloads[1]["timing_attempt_id"] + ); + if !reject_terminal { + assert_eq!(payloads[2]["transition"], "unload_failed"); + assert_eq!( + payloads[2]["timing_attempt_id"], + payloads[0]["timing_attempt_id"] + ); + assert!(!payloads[2]["error"] + .as_str() + .expect("sanitized host error") + .contains('\n')); + assert!(payloads[2]["error"] + .as_str() + .expect("error") + .contains("worker still busy")); + } +} + +#[tokio::test] +async fn multiline_unload_error_records_complete_lifecycle_with_healthy_ledger() { + assert_failed_unload_reports_terminal_diagnostics(false).await; +} + +#[tokio::test] +async fn terminal_unload_failure_is_reported_even_when_canonical_error_records() { + assert_failed_unload_reports_terminal_diagnostics(true).await; +} diff --git a/crates/pantograph-workflow-service/src/workflow/tests/session_execution.rs b/crates/pantograph-workflow-service/src/workflow/tests/session_execution.rs index 2560ad48f..fa52cdfee 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/session_execution.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/session_execution.rs @@ -1697,7 +1697,7 @@ async fn workflow_execution_session_bootstrap_recovery_applies_progress_loop_bef ) .expect("validated executable snapshot"); let projections = snapshot - .scheduler_inference_task_projections() + .scheduler_inference_task_projections(&graph) .expect("scheduler inference task projections"); let first_created = service @@ -1876,7 +1876,7 @@ async fn workflow_execution_session_bootstrap_recovery_redispatches_ready_runtim ) .expect("validated executable snapshot"); let projections = snapshot - .scheduler_inference_task_projections() + .scheduler_inference_task_projections(&graph) .expect("scheduler inference task projections"); let first_created = service @@ -3252,6 +3252,49 @@ async fn workflow_execution_session_run_rejects_stale_graph_before_queue_admissi assert_eq!(host.run_attempts.load(Ordering::SeqCst), 0); } +async fn publish_non_runtime_execution_snapshot( + service: &WorkflowService, + workflow_id: &str, + semantic_version: &str, + graph: WorkflowGraph, +) { + let edit = service + .workflow_graph_create_edit_session(crate::WorkflowGraphEditSessionCreateRequest { + graph, + workflow_id: Some(workflow_id.to_string()), + }) + .await + .expect("create non-runtime graph session"); + let validation = service + .workflow_graph_refresh_current_validation_summary( + crate::WorkflowGraphCurrentValidationRefreshRequest { + graph_session_id: edit.session_id.clone(), + graph_revision: edit.graph_revision.parse().expect("graph revision"), + }, + ) + .await + .expect("validate non-runtime graph"); + assert!(validation.summary.submit_gate.allowed); + service + .publish_graph_session_executable_validation_snapshot( + crate::WorkflowGraphSessionExecutableValidationSnapshotPublishRequest { + workflow_id: workflow_id.to_string(), + workflow_semantic_version: semantic_version.to_string(), + graph_session_id: edit.session_id.clone(), + validation_session_id: validation.summary.validation_session_id, + validation_snapshot_id: None, + }, + ) + .await + .expect("publish non-runtime executable snapshot"); + service + .workflow_graph_close_edit_session(crate::WorkflowGraphEditSessionCloseRequest { + session_id: edit.session_id, + }) + .await + .expect("close published graph session"); +} + #[tokio::test] async fn workflow_execution_session_run_records_snapshot_before_execution() { let host = MockWorkflowHost::with_technical_fit_decision( @@ -3286,14 +3329,8 @@ async fn workflow_execution_session_run_records_snapshot_before_execution() { let workflow_id = "wf-snapshot"; let workflow_semantic_version = "1.2.3"; let graph = mock_workflow_graph(); - let version = service - .resolve_workflow_graph_version(workflow_id, workflow_semantic_version, &graph) - .expect("resolve workflow version"); - service - .store_workflow_executable_validation_snapshot(runtime_executable_validation_snapshot( - &version, &graph, - )) - .expect("store executable validation snapshot"); + publish_non_runtime_execution_snapshot(&service, workflow_id, workflow_semantic_version, graph) + .await; let created = service .create_workflow_execution_session( @@ -3700,14 +3737,8 @@ async fn attributed_workflow_execution_session_carries_client_bucket_into_run_ev let workflow_id = "wf-attributed"; let workflow_semantic_version = "1.2.3"; let graph = mock_workflow_graph(); - let version = service - .resolve_workflow_graph_version(workflow_id, workflow_semantic_version, &graph) - .expect("resolve workflow version"); - service - .store_workflow_executable_validation_snapshot(runtime_executable_validation_snapshot( - &version, &graph, - )) - .expect("store executable validation snapshot"); + publish_non_runtime_execution_snapshot(&service, workflow_id, workflow_semantic_version, graph) + .await; let registered = service .register_attribution_client(ClientRegistrationRequest { display_name: Some("local gui".to_string()), diff --git a/crates/pantograph-workflow-service/src/workflow/tests/workflow_version.rs b/crates/pantograph-workflow-service/src/workflow/tests/workflow_version.rs index f203283b5..762ff0cb0 100644 --- a/crates/pantograph-workflow-service/src/workflow/tests/workflow_version.rs +++ b/crates/pantograph-workflow-service/src/workflow/tests/workflow_version.rs @@ -182,8 +182,15 @@ fn workflow_run_graph_query_returns_none_for_unknown_run() { #[test] fn workflow_executable_validation_snapshot_round_trips_through_attribution() { let service = WorkflowService::with_ephemeral_attribution_store().expect("service"); + let mut graph = graph(); + graph.nodes.push(GraphNode { + id: "infer_node".to_string(), + node_type: "llm-inference".to_string(), + position: Position { x: 0.0, y: 0.0 }, + data: serde_json::json!({}), + }); let version = service - .resolve_workflow_graph_version("workflow-versioned", "1.0.0", &graph()) + .resolve_workflow_graph_version("workflow-versioned", "1.0.0", &graph) .expect("version"); let snapshot = executable_validation_snapshot(&version); @@ -202,7 +209,7 @@ fn workflow_executable_validation_snapshot_round_trips_through_attribution() { assert_eq!(stored.as_record(), &snapshot); assert_eq!(loaded.as_record(), &snapshot); - assert!(loaded.scheduler_inference_task_projections().is_ok()); + assert!(loaded.scheduler_inference_task_projections(&graph).is_ok()); } #[test] diff --git a/crates/pantograph-workflow-service/tests/contract.rs b/crates/pantograph-workflow-service/tests/contract.rs index 23b5b6e5f..053763329 100644 --- a/crates/pantograph-workflow-service/tests/contract.rs +++ b/crates/pantograph-workflow-service/tests/contract.rs @@ -71,6 +71,17 @@ struct ContractHost; #[async_trait] impl WorkflowHost for ContractHost { + async fn run_workflow( + &self, + _workflow_id: &str, + _inputs: &[WorkflowPortBinding], + _output_targets: Option<&[WorkflowOutputTarget]>, + _run_options: pantograph_workflow_service::WorkflowRunOptions, + _run_handle: pantograph_workflow_service::WorkflowRunHandle, + ) -> Result, WorkflowServiceError> { + panic!("session contract tests must execute scheduler tasks, not the legacy host hook"); + } + async fn validate_workflow(&self, _workflow_id: &str) -> Result<(), WorkflowServiceError> { Ok(()) } @@ -224,32 +235,6 @@ impl WorkflowHost for ContractHost { Ok("contract-graph".to_string()) } - async fn run_workflow( - &self, - _workflow_id: &str, - _inputs: &[WorkflowPortBinding], - output_targets: Option<&[WorkflowOutputTarget]>, - _run_options: pantograph_workflow_service::WorkflowRunOptions, - _run_handle: pantograph_workflow_service::WorkflowRunHandle, - ) -> Result, WorkflowServiceError> { - if let Some(targets) = output_targets { - return Ok(targets - .iter() - .map(|target| WorkflowPortBinding { - node_id: target.node_id.clone(), - port_id: target.port_id.clone(), - value: serde_json::json!([0.1, 0.2, 0.3]), - }) - .collect()); - } - - Ok(vec![WorkflowPortBinding { - node_id: "vector-output-1".to_string(), - port_id: "vector".to_string(), - value: serde_json::json!([0.1, 0.2, 0.3]), - }]) - } - async fn workflow_io( &self, _workflow_id: &str, @@ -287,10 +272,150 @@ impl WorkflowHost for ContractHost { } } +// Execution contracts use supported scheduler tasks and a real graph. The +// metadata-only ContractHost above retains its embedding capability snapshots. +#[derive(Default)] +struct SchedulerContractHost { + graph_reads: std::sync::atomic::AtomicUsize, + io_reads: std::sync::atomic::AtomicUsize, +} + +impl SchedulerContractHost { + fn assert_admission_callbacks(&self) { + use std::sync::atomic::Ordering; + assert!( + self.graph_reads.load(Ordering::SeqCst) > 0, + "graph admission must consult the host" + ); + assert!( + self.io_reads.load(Ordering::SeqCst) > 0, + "I/O admission must consult the host" + ); + } +} + +#[async_trait] +impl WorkflowHost for SchedulerContractHost { + async fn run_workflow( + &self, + _workflow_id: &str, + _inputs: &[WorkflowPortBinding], + _output_targets: Option<&[WorkflowOutputTarget]>, + _run_options: pantograph_workflow_service::WorkflowRunOptions, + _run_handle: pantograph_workflow_service::WorkflowRunHandle, + ) -> Result, WorkflowServiceError> { + panic!("session contract tests must execute scheduler tasks, not the legacy host hook"); + } + + async fn validate_workflow(&self, workflow_id: &str) -> Result<(), WorkflowServiceError> { + assert_eq!(workflow_id, "wf-1"); + Ok(()) + } + + async fn workflow_graph_fingerprint( + &self, + _workflow_id: &str, + ) -> Result { + Ok("contract-text-graph".to_string()) + } + + async fn workflow_graph( + &self, + _workflow_id: &str, + ) -> Result { + self.graph_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + use pantograph_workflow_service::{GraphEdge, GraphNode, Position, WorkflowGraph}; + Ok(WorkflowGraph { + nodes: vec![ + GraphNode { + id: "text-input-1".to_string(), + node_type: "text-input".to_string(), + position: Position { x: 0.0, y: 0.0 }, + data: serde_json::json!({}), + }, + GraphNode { + id: "text-output-1".to_string(), + node_type: "text-output".to_string(), + position: Position { x: 100.0, y: 0.0 }, + data: serde_json::json!({}), + }, + ], + edges: vec![GraphEdge { + id: "edge".to_string(), + source: "text-input-1".to_string(), + source_handle: "text".to_string(), + target: "text-output-1".to_string(), + target_handle: "text".to_string(), + }], + derived_graph: None, + }) + } + + async fn workflow_capabilities( + &self, + _workflow_id: &str, + ) -> Result { + Ok(WorkflowHostCapabilities { + max_input_bindings: 32, + max_output_targets: 8, + max_value_bytes: 4096, + runtime_requirements: WorkflowRuntimeRequirements::default(), + models: Vec::new(), + runtime_capabilities: Vec::new(), + }) + } + + async fn workflow_io( + &self, + workflow_id: &str, + ) -> Result { + self.io_reads + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut io = ContractHost.workflow_io(workflow_id).await?; + io.outputs = vec![WorkflowIoNode { + node_id: "text-output-1".to_string(), + node_type: "text-output".to_string(), + name: Some("Text Output".to_string()), + description: None, + ports: vec![WorkflowIoPort { + port_id: "text".to_string(), + name: Some("Text".to_string()), + description: None, + data_type: Some("string".to_string()), + required: Some(false), + multiple: Some(false), + }], + }]; + Ok(io) + } +} + +#[test] +fn workflow_run_vector_response_contract_round_trip() { + // Vector serialization remains a public DTO contract even though this + // integration fixture executes only the scheduler's supported text tasks. + let expected = serde_json::json!({ + "workflow_run_id": "run-vector-contract", + "outputs": [{ + "node_id": "vector-output-1", + "port_id": "vector", + "value": [0.1, 0.2, 0.3] + }], + "timing_ms": 42 + }); + let response: pantograph_workflow_service::WorkflowRunResponse = + serde_json::from_value(expected.clone()).expect("deserialize vector run response"); + assert_eq!( + serde_json::to_value(response).expect("serialize vector run response"), + expected + ); +} + #[tokio::test] async fn workflow_run_contract_snapshot() { let service = WorkflowService::new(); - let host = ContractHost; + let host = SchedulerContractHost::default(); let session = service .create_workflow_execution_session( &host, @@ -315,8 +440,8 @@ async fn workflow_run_contract_snapshot() { value: serde_json::json!("hello world"), }], output_targets: Some(vec![WorkflowOutputTarget { - node_id: "vector-output-1".to_string(), - port_id: "vector".to_string(), + node_id: "text-output-1".to_string(), + port_id: "text".to_string(), }]), override_selection: None, timeout_ms: None, @@ -325,6 +450,7 @@ async fn workflow_run_contract_snapshot() { ) .await .expect("session workflow run response"); + host.assert_admission_callbacks(); let value = serde_json::to_value(response).expect("serialize response"); assert!(value["workflow_run_id"] @@ -334,9 +460,9 @@ async fn workflow_run_contract_snapshot() { "workflow_run_id": value["workflow_run_id"], "outputs": [ { - "node_id": "vector-output-1", - "port_id": "vector", - "value": [0.1, 0.2, 0.3] + "node_id": "text-output-1", + "port_id": "text", + "value": "hello world" } ], "timing_ms": value["timing_ms"] @@ -345,6 +471,55 @@ async fn workflow_run_contract_snapshot() { assert_eq!(value, expected); } +#[tokio::test] +async fn workflow_run_missing_graph_preserves_admission_diagnostics_contract() { + let service = WorkflowService::new(); + let host = ContractHost; + let session = service + .create_workflow_execution_session( + &host, + WorkflowExecutionSessionCreateRequest { + workflow_id: "wf-1".to_string(), + usage_profile: None, + keep_alive: false, + }, + ) + .await + .expect("create metadata-only session"); + let error = service + .run_workflow_execution_session( + &host, + WorkflowExecutionSessionRunRequest { + session_id: session.session_id, + workflow_semantic_version: "0.1.0".to_string(), + inputs: Vec::new(), + output_targets: None, + override_selection: None, + timeout_ms: None, + priority: None, + }, + ) + .await + .expect_err("execution requires a stored graph"); + assert_eq!( + error.code(), + pantograph_workflow_service::WorkflowErrorCode::WorkflowNotFound + ); + assert_eq!(error.message(), "workflow 'wf-1' not found"); + let diagnostics = error + .diagnostics() + .expect("admission error retains diagnostics attribution"); + assert!(diagnostics + .workflow_run_id + .as_deref() + .is_some_and(|id| !id.is_empty())); + assert_eq!(diagnostics.diagnostic_event_id, None); + assert_eq!( + diagnostics.diagnostics_unavailable.as_deref(), + Some("diagnostics ledger is not configured") + ); +} + #[tokio::test] async fn workflow_capabilities_contract_snapshot() { let service = WorkflowService::new(); @@ -2398,7 +2573,7 @@ fn workflow_trace_snapshot_request_rejects_blank_contract_filter() { #[tokio::test] async fn workflow_run_rejects_non_discovered_output_target_contract() { let service = WorkflowService::new(); - let host = ContractHost; + let host = SchedulerContractHost::default(); let session = service .create_workflow_execution_session( &host, @@ -2417,9 +2592,13 @@ async fn workflow_run_rejects_non_discovered_output_target_contract() { WorkflowExecutionSessionRunRequest { session_id: session.session_id, workflow_semantic_version: "0.1.0".to_string(), - inputs: Vec::new(), + inputs: vec![WorkflowPortBinding { + node_id: "text-input-1".to_string(), + port_id: "text".to_string(), + value: serde_json::json!("hello world"), + }], output_targets: Some(vec![WorkflowOutputTarget { - node_id: "vector-output-1".to_string(), + node_id: "text-output-1".to_string(), port_id: "stream".to_string(), }]), override_selection: None, @@ -2429,8 +2608,21 @@ async fn workflow_run_rejects_non_discovered_output_target_contract() { ) .await .expect_err("expected invalid request for non-discovered target"); + host.assert_admission_callbacks(); + assert_eq!( + err.code(), + pantograph_workflow_service::WorkflowErrorCode::InvalidRequest + ); + assert_eq!( + err.message(), + "output_targets.0 references non-discoverable output 'text-output-1.stream'" + ); assert!(matches!(err, WorkflowServiceError::InvalidRequest(_))); + assert!( + err.diagnostics().is_none(), + "unconfigured terminal ledger preserves the original error" + ); } #[tokio::test] diff --git a/crates/pantograph-workflow-service/tests/fixtures/run_projection_contract.json b/crates/pantograph-workflow-service/tests/fixtures/run_projection_contract.json index d77f354cc..18ebcd67d 100644 --- a/crates/pantograph-workflow-service/tests/fixtures/run_projection_contract.json +++ b/crates/pantograph-workflow-service/tests/fixtures/run_projection_contract.json @@ -34,6 +34,9 @@ "output_artifact_count": 1, "output_artifact_total_size_bytes": 1024, "model_cache_state": "cache_hit", + "memory_failure_kind": null, + "observed_peak_ram_bytes": null, + "observed_peak_vram_bytes": null, "scheduler_reason": "queued behind one run", "latest_error_event_id": "error-event-1", "latest_error_severity": "fatal", @@ -119,6 +122,9 @@ "output_artifact_count": 1, "output_artifact_total_size_bytes": 1024, "model_cache_state": "cache_hit", + "memory_failure_kind": null, + "observed_peak_ram_bytes": null, + "observed_peak_vram_bytes": null, "scheduler_reason": "queued behind one run", "latest_error_event_id": "error-event-1", "latest_error_severity": "fatal", diff --git a/docs/development.md b/docs/development.md index f42060b0c..a5d46569b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -73,7 +73,9 @@ directories do not require inventories or fixed headings. - Strict workspace Clippy and the current frontend lint/accessibility gates are not green. - Release smoke does not yet prove the packaged artifact. -- Decision traceability still implements a retired README-per-directory model. +- Decision traceability checks declared decision-to-guide impacts and local + references; semantic documentation coverage still requires review. See + [gate operation](../scripts/README.md#decision-traceability-operation). Those limitations are active work, not setup exceptions. See the [current remediation portfolio](plans/current-standards-remediation/plan.md). diff --git a/docs/headless-workflow.md b/docs/headless-workflow.md index 30f45cff0..618e97806 100644 --- a/docs/headless-workflow.md +++ b/docs/headless-workflow.md @@ -76,3 +76,68 @@ must decode runtime input rather than relying on static-language assertions. Packaging, platform support, and binding compatibility are not yet established as release-grade claims. See [Release](release.md) and the current audit. + +### Session capacity and keep-alive diagnostics + +Explicit keep-alive creation and enablement own session-count capacity eviction. +The host may choose an idle victim; the default policy preserves workflow, +model and backend affinity. Disabling keep-alive or closing the session performs +explicit cleanup. Ordinary non-runtime runs do not load a session runtime, and +runtime-task resource reservations remain a separate admission contract. + +Capacity eviction events carry `workflow_execution_session_id` for the target +and `unloaded_workflow_execution_session_id` for the victim, plus the existing +model/runtime facts and shared timing-attempt ID. Session-only operations leave +run attribution absent and do not create run projections. Historical run-scoped +payloads retain their existing decoding and required run identity. + +A failed session-runtime admission records the `session_runtime_admission` error +phase under `session_runtime` scope. The returned error retains the operational +code and message; its diagnostic link identifies the recorded error or explains +why diagnostics were unavailable. No workflow-run ID is synthesized. Failed +keep-alive enablement rolls back the flag; failed keep-alive creation removes +the new session. A failed victim unload does not mark that victim unloaded. + +Once the host confirms an eviction, session residency is updated before terminal +telemetry is recorded. If that recording fails, admission reports the diagnostic +failure and the caller rolls back its target request, but the evicted session +remains correctly unloaded and can be reloaded on retry. An unload failure leaves +the victim loaded. Missing lifecycle telemetry is reported even when recording +the separate canonical error succeeds; lifecycle error text is sanitized without +changing the original operational error returned to the caller. + +## Publishing validation before native session execution + +The UniFFI runtime owns an ephemeral attribution store. Before running a saved +workflow version, an embedding client must publish validation through the public +graph-session API: + +1. Load the saved graph with `workflow_graph_load`, or save it with + `workflow_graph_save`, and create a graph edit session for that workflow ID. +2. Call `workflow_graph_refresh_current_validation_summary` with the returned + `graph_session_id` and `graph_revision`. Inspect the returned summary and + require its submit gate to allow execution; unavailable facts remain blocking. +3. Call `publish_graph_session_executable_validation_snapshot` with workflow ID, + semantic version, graph-session ID and the current validation-session ID. + The service derives the graph, descriptor facts and dependency proof from its + own session state. The request accepts identifiers, not a caller-authored proof + or snapshot body, and rejects stale publication. +4. Create an execution session and run the same saved workflow/version through + `workflow_run_session`. A missing snapshot still fails closed. Closing the + graph edit session after successful publication does not erase the published + validation record. + +Reopening `FfiPantographRuntime` starts a fresh attribution store, even when its +app-data directory is reused. Repeat validation/publication before execution; +filesystem persistence of the workflow alone is not evidence of current +executable validation. The bridge does not add support for interactive scheduler +tasks or fabricate inference/runtime readiness. Errors retain the existing JSON +workflow error envelopes. + +Executable snapshots contain inference proofs, so a graph with no inference nodes +has an empty proof list. Publication and executable projection both compare that +list against the canonical graph's complete inference-node set. Empty records +cannot stand in for missing runtime proofs, and extra/duplicate node identities +are rejected. Rust callers of `scheduler_inference_task_projections` must supply +the actual graph for this coverage check; normal run admission also retains its +workflow-version and fingerprint checks. diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md index 1cd409f3c..2a3d07860 100644 --- a/docs/plans/domain-architecture-and-multimodal/plan.md +++ b/docs/plans/domain-architecture-and-multimodal/plan.md @@ -616,6 +616,43 @@ No arbitrary file-size target, new crate quota, or mandatory universal abstracti **Status:** `Active` +### CI-01 — Session Capacity Observability Repair + +**Decision (2026-10-02):** Explicit keep-alive creation/enablement owns the +existing session-count capacity limit, host-selected eviction and affinity. +Runtime-task dispatch continues to own dependency admission and resource +reservations; non-runtime runs must not acquire a session runtime. The five +legacy capacity tests reached the wrong owner after this split. Moving only +their trigger would lose previously asserted eviction diagnostics and timing: +`session_runtime.rs` currently receives no diagnostic context from its sole +keep-alive caller, while that context requires a run that does not exist. + +**Admitted repair:** Emit eviction lifecycle facts with genuine target and +unloaded execution-session identities and optional real run attribution. Extend +the existing typed diagnostic payloads additively, retaining old payload decoding +and validation of run-only events. Add a registered session-runtime error scope +so an unload failure retains its original code/message and reports diagnostic +unavailability without inventing a workflow run. Preserve host selection, +rollback, cleanup, event order and a shared timing-attempt identity. Session-only +facts belong in the ledger; they must not create fabricated run projections. + +**Write set:** workflow-service `session_runtime.rs`, `diagnostic_errors.rs`, +`tests/session_capacity.rs`, `tests/session_capacity_faults.rs`, test module +registration and focused diagnostic tests if needed; diagnostics +ledger `event.rs` and `tests.rs`; this plan, its scoped report, and the owning +`docs/headless-workflow.md` guide. No runtime-task admission, resource reservation, +non-runtime execution, public run facade, or unrelated baseline repair changes. +The integrator owns this decision and independent review. Implementation is on +`fix/session-capacity-observability-2026-10-02`, separate from fixture PR #7. + +**Gate:** Direct keep-alive lifecycle tests preserve victim selection, all three +affinity cases, explicit target cleanup, rollback and original unload failure; +available and unavailable diagnostics are tested. Ledger tests accept valid +session-only events, reject missing/blank identity, preserve old run-event +contracts, and demonstrate no fabricated run projection. The non-runtime no-load +regression and focused runtime-dispatch tests remain green. Full CI gaps remain +explicit; independent review precedes integration. + ### M5 — Integrated acceptance and maintainer handoff **Goal:** One honest, current product and compliance result. @@ -840,6 +877,41 @@ appropriate to its recorded lifecycle; the next-slice field is not independent execution authority. The user has now authorized starting the plan with subagents and the bounded implementation pilot. +## UniFFI Validation Publication Bridge (2026-10-03) + +Hosted headless qualification now reaches the UniFFI runtime tests. The FFI +constructor owns an ephemeral attribution store, and execution correctly requires +an owner-published executable validation snapshot. The embedding API exposes +edit sessions but omits validation refresh and snapshot publication, so a valid +text graph cannot currently complete the public create/run flow. + +Accepted bounded repair: expose typed EmbeddedRuntime delegates and strict JSON +UniFFI entrypoints for the existing workflow-service current-validation refresh +and graph-session snapshot publication methods. Keep graph/proof construction, +revision checks, descriptor compatibility and dependency-proof freshness at their +existing owners. A client supplies session/version identifiers, never trusted +snapshot content. It loads/saves a graph, creates an edit session, refreshes the +returned graph revision, publishes its current executable validation session, +then creates/runs an execution session. Reopening the ephemeral FFI runtime +requires publication again. Missing, stale and unavailable validation still fail +closed; runtime inference facts and readiness are not fabricated. + +Required evidence: actual public JSON text execution; missing/stale/reopened +runtime rejection; existing error-envelope fidelity; UniFFI metadata and generated +C# surface checks. Independent read-only review is required before publication. +Canonical selector fixtures and managed-runtime readiness evidence stay separate. + +Independent review found that inference-only snapshots rejected every legitimate +zero-inference graph and that real C# callers had not been migrated. The accepted +correction keeps publication mandatory and checks exact canonical inference-node +coverage at owner publication and executable projection, including imported +records. Empty coverage is valid only for a graph with no inference nodes. Missing, +extra and duplicate runtime projections fail closed; per-node proofs and existing +freshness checks remain. The canonical inference request owner supplies the shared +classification; unsupported inference-bearing task types remain rejected. Both +NativeSmoke and the packaged DirectRuntimeQuickstart must perform the real public +load/edit/refresh/publish lifecycle before execution. + ## Final Acceptance - Acceptance status: `blocked` diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-ci-prerequisites.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-ci-prerequisites.md new file mode 100644 index 000000000..1ae424056 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-ci-prerequisites.md @@ -0,0 +1,78 @@ +# CI Build Prerequisite Repair + +Date: 2026-10-02. Status: `Verifying`. +Parent authority: [active domain architecture plan](../plan.md), CI-01 baseline +repair scope. Branch: `fix/ci-build-prerequisites-2026-10-02`, admitted from gate +`e22ebb2fe52efc8cfcaac6b23c4a902c7bc6a571`. Initial PR target is the gate branch; +retarget `main` after its prerequisite qualifies. The tooling owner implements +this isolated proposal; the domain integrator owns review and integration. +The checkout is retained for review until integrated, rejected or superseded. +Standards inspected: `MrScripty/Coding-Standards@dcc56f26e884ade260770beceba2501d3746200d`, +Core/Router, Implementation, Verification, Commit, Documentation, Tooling, Build, +Dependencies/Licensing and applicable Rust tooling guidance. + +## Diagnosed Obstructions + +- The headless workflow asks crates.io for generator `uniffi-bindgen-cs` 0.9.0, + which is unavailable there. This reproduces on docs-only PR #1 and gate PR #2 + before any workflow-service or C# smoke runs. Source: + [headless run](https://github.com/MrScripty/Pantograph/actions/runs/37066045057). +- Runtime-separation CI builds the actual Tauri binary but never provisions the + GLib/GTK/WebKit native prerequisites, stopping in `glib-sys`. Source: + [separation run](https://github.com/MrScripty/Pantograph/actions/runs/37066045102). +- Workspace check already provisions desktop libraries but Lance's build needs + `protoc`; the runner has no compiler. Source: + [baseline quality run](https://github.com/MrScripty/Pantograph/actions/runs/37059945912). + +These are build prerequisites, not permission to skip checks, reduce features, +relax warnings, or accept the runtime source. Other failing baseline contracts, +Clippy, frontend lint and dependency-audit findings remain separate work. + +## Coherent Repair + +Use the official generator source rather than an unavailable registry release. +NordSecurity's [tag](https://github.com/NordSecurity/uniffi-bindgen-cs/tree/v0.9.0%2Bv0.28.3) +resolves to `2f4880f03ed08d960ad0ee14d11cf95444eee540`; its manifest and lockfile +identify generator `0.9.0+v0.28.3` and UniFFI 0.28.3, matching this repository's +0.28 consumer. Its README documents Git installation and Rust 1.81 minimum. +Pin that exact commit with `--locked`, retaining the intended generator version. +No application Cargo declaration, Cargo lockfile, generated C# or Pumas pin changes. +The C# binding guide owns local installation instructions; missing-tool messages +link that owner instead of repeating a registry/version guess. The generator is +an unchanged development executable, not vendored or packaged in this proposal; +its [MPL-2.0 source notices](https://github.com/NordSecurity/uniffi-bindgen-cs/blob/2f4880f03ed08d960ad0ee14d11cf95444eee540/LICENSE) +remain upstream. Existing binding/release obligations are not waived. + +Consolidate the two existing identical Ubuntu native package lists into +`scripts/install-ubuntu-build-dependencies.sh`, add `protobuf-compiler`, and use +that same owned list for the runtime-separation build. The only permitted system +mutation is the existing hosted Ubuntu provisioning step; no local or user +computer system packages were installed. Failed package installation fails the +step. The existing Rust checks, features, ordering and failure behavior remain. + +Write set: three affected workflow YAML files, the shared Ubuntu installer, +the scripts and C# guides, two C# scripts' missing-tool messages, and this report. +No runtime source, test fixture, severity, broad dependency upgrade, published +branch history or presentation artifact changes. + +## Evidence And Remaining Claims + +- Official tag resolution, source manifest, lockfile and README checked through + GitHub at the exact generator revision. +- All workflow YAML parses; all three affected shell scripts pass `bash -n`. +- A disposable `sudo` substitute verified the exact apt update/install argument + sequence, retained GLib/Desktop packages, added protobuf compiler and fail-fast + propagation of an installation error. This proves orchestration only; it does + not establish successful apt provisioning. +- Staged whitespace, staged traceability (nine paths, no ownership impact) and + tooling lint passed before publication. +- Actual generator installation, produced binding compatibility, native compile + and all downstream CI claims remain pending on an Ubuntu hosted runner. +- Independent integrator review accepted staged tree + `a7567c4563dc09a145ee897983c31c8a8b3ec36f`: all nine files inspected, + Bash syntax and whitespace independently checked, and upstream generator and + workspace manifests verified at the exact pinned revision. This bounded source + review found no blocker; actual provisioning and native/generator builds still + require hosted qualification. This report records that acceptance after review. +- This proposal remains a draft, not a green-CI or release claim. No merge until + required exact-head CI and applicable hosted review qualify it and its prerequisite. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-session-capacity-observability.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-session-capacity-observability.md new file mode 100644 index 000000000..42904028f --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-session-capacity-observability.md @@ -0,0 +1,98 @@ +# Session Capacity Observability + +Date: 2026-10-02. Status: `Verifying`. +Authority: [CI-01 session-capacity decision](../plan.md#ci-01--session-capacity-observability-repair). +Branch: `fix/session-capacity-observability-2026-10-02`, based on fixture +PR #7 at `74723b5249ad35404506c6c01ab6c087c8db3b58`. Retain until integrated, +rejected or superseded. The workflow-service owner implements; the domain +integrator independently reviews the admitted decision and final patch. +Coding standards inspected at `MrScripty/Coding-Standards@dcc56f26e884ade260770beceba2501d3746200d`. + +## Source-Grounded Decision + +The five old capacity tests invoked a non-runtime graph and expected session +runtime eviction. Current non-runtime execution deliberately performs no runtime +load. Session-count eviction is owned by explicit keep-alive creation/enablement: +`ensure_keep_alive_session_runtime_ready` calls the existing capacity loader, +which consults the host victim selector and default affinity policy. Runtime-task +dependency admission and resource reservations have separate owners. + +The loader's optional diagnostic context previously required a workflow run, +but its sole caller passed no context. Moving tests to keep-alive without repairing +observability would lose their ordered eviction, timing and failure-diagnostic +assertions. The integrator accepted the session-scoped design before implementation; +the plan was updated first. No obsolete non-runtime loading path is restored. + +## Resulting Contract + +Existing capacity selection, host unload/load calls and affinity remain unchanged. +Keep-alive admission supplies the genuine execution-session summary. Model +lifecycle payloads now optionally identify the target and unloaded session as a +validated pair. Real snapshot run attribution remains optional; session-only +operations have no run ID or semantic run version. Old payloads omit new fields +and retain their former round trip. Other run-only event validation remains strict. + +The registered `session_runtime_admission` error phase uses `session_runtime` +scope and diagnostics-only projection semantics. Error payloads contain the real +execution-session ID. Original operational errors retain their code/message; +recording failures become diagnostic-unavailability detail instead of replacing +the unload error. Existing caller rollback removes a failed newly created session +or reverses failed keep-alive enablement. A failed victim unload leaves its loaded +state intact. Session-only ledger records do not invent run projections. + +The five tests now exercise explicit keep-alive enablement and disablement, +preserving victim choice, workflow/model/backend affinity, target cleanup, +unload error and ordered timing/event assertions. Additional lifecycle tests +cover recorded capacity rejection and rollback. Ledger regressions cover session +identity, strict legacy validation and absent fabricated run projections. + +## Evidence And Remaining Qualification + +- Final fresh Cargo compilation and full library runs: workflow-service 874/874 + and diagnostics-ledger 103/103 passed, zero ignored. This includes all former + capacity assertions, the non-runtime no-load regression, runtime-dispatch tests, + new creation/enablement rollback cases and three ledger scope regressions. +- The diagnostic registry check includes its admitted fourteenth phase and asserts + session scope, error severity and diagnostics-only projection behavior. +- Cargo format, staged whitespace and staged traceability passed. Independent + source review and exact-head hosted qualification remain pending. +- Baseline integration-contract, Rustler, frontend lint, audit and Clippy failures + remain separate. Hosted exact-head CI and applicable review are required; + these focused results do not establish whole-repository merge qualification. + +## Independent Review Repair — 2026-10-03 + +Review of frozen tree `363367538bf09d950e4661903253cc6676459d18` requested two +changes. Successful host unload could be followed by a failed terminal append +before residency changed, leaving a falsely loaded victim. Failed-unload lifecycle +append errors were discarded, including validation failures caused by multiline +host errors, even when separate canonical error recording succeeded. + +The repair commits a successful host residency transition immediately, before +fallible timing/terminal reporting. The existing failed-admission response policy +is retained: target creation/enablement rolls back after a terminal telemetry +failure, while the genuinely unloaded victim stays unloaded and can be reloaded. +Lifecycle text is sanitized; missing lifecycle-event detail survives canonical +error recording and does not replace the original failed-unload code/message. + +Four fault regressions exercise SQLite triggers rejecting only terminal lifecycle +writes, with canonical error recording still healthy: creation and enablement +both verify victim residency and actual reload recovery; a healthy-ledger multiline +host error verifies a complete ordered lifecycle; a selectively rejected failed +terminal event verifies explicit unavailable detail alongside a canonical error ID. +The earlier frozen tree remains available for comparison. Narrow re-review is +required before publication; verification for this repair is recorded below. + +Repair verification: fresh Cargo compilation and 15/15 capacity tests passed. +The freshly built repair binary then passed all 878 workflow-service library +tests, including non-runtime no-load and runtime-dispatch regressions. Ledger +source is unchanged from the previously passing 103/103 run. Formatting, +whitespace and staged traceability passed. The three earlier integration-contract +failures remain unchanged; no new integrated or hosted pass is claimed. + +Independent narrow re-review accepted tree +`260422c5c67332280527781ed13c312683b0143d`: both P1/P2 repairs and their four +fault regressions were inspected; all 15 capacity tests were independently rerun +from the repair binary and passed. No remaining narrow source finding was raised. +This report-only addition records acceptance after that frozen review. Hosted +exact-head qualification, unrelated integration failures and whole CI remain open. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-traceability-gate.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-traceability-gate.md new file mode 100644 index 000000000..6d650a5a7 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-traceability-gate.md @@ -0,0 +1,161 @@ +# Decision Traceability Gate Reconciliation + +Date: 2026-10-02 + +Plan authority: [domain architecture and multimodal workflows](../plan.md). +Admitted base: `4938e405c7f656365eefdca492774ccae110c90d`. +Standards inspected: `dcc56f26e884ade260770beceba2501d3746200d` of Coding-Standards. +This later inspected revision is explicit; it is not a silent replacement of the +plan's historical planning baseline. + +## Finding And Policy Decision + +The previous checker required a README at every changed source directory, eleven +universal headings, path-list-selected host/producer headings, and a README or +any ADR edit. This contradicted the current Documentation workflow, +[repository documentation rules](../../../README.md), and the active plan's +Documentation Proportionality. Its process-substitution diff collection also +hid failed Git reads as empty successful checks. + +Replace that mechanism with declared decision-to-guide impact checking. The +[project map](../../../../scripts/decision-traceability-map.json) and its +[owning guide](../../../../scripts/README.md#decision-traceability-operation) +select exact durable decision sources, affected knowledge and documentation +profiles. The decision is scoped gate reconciliation, not an exception to +contract documentation or permission to disable verification. + +The admitted rows cover host/service and scheduler-session authority +(ADR-001/011), runtime lifecycle/readiness/observability (ADR-002/003/007), +canonical/composed-node and migration ownership (ADR-006/009), contributor +workspace policy (ADR-017), and the map's own coverage. Each decision was read +against its canonical guide before selecting the row. The gate validates an +update to that owner only; an unrelated README, plan or ADR cannot substitute. +Changed unmapped ADRs produce an unresolved mapping diagnostic. + +Mixed implementation files are intentionally not triggers. A local repair that +preserves a contract needs no guide churn. Source changes that alter consumer +semantics still require canonical documentation, review and contract tests; +path comparison cannot decide those semantics. Existing scheduler-only, +host/binding, worker-protocol and structured-producer validation remains intact. +The removed heading checks never proved those contracts. + +## Change Input And Migration + +- The existing hook explicitly selects staged mode. All evaluated map, guide, + decision and reference content comes from HEAD and the index tree. An unstaged + repair cannot satisfy a staged obligation. +- Range mode requires explicit base and head commit refs, reports resolved IDs, + and reads both snapshots. The affected CI job fetches history and supplies + event base/head IDs; it does not guess a branch or adjacent revision. +- Both prior and current maps contribute obligations. Row removal, trigger + deletion/rename and owner migration cannot erase the previous obligation. +- Initial map adoption is supported only from the exact audited no-map base + above, with the repository-selected map path. Its decision sources and owners + must resolve in that base. There is no override to skip an arbitrary absent + prior map. Retire the migration case when supported bases all carry the map. +- Missing modes, revisions, maps, owners and referenced files fail explicitly; + malformed and unreadable Git input cannot become a successful empty diff. + +Local file links in current mapped owners and changed Markdown are checked. +Unchanged documents are checked when a referenced ADR or prior/current canonical +guide target changes, preserving rename/deletion protection. Remote URLs and heading fragments are not checked. +Untouched historical links remain outside scope. One superseded security plan +already names a never-created proposed trust/isolation ADR; the gate does not +turn that historical proposal into current authority or silently repair it. + +## Write Set And Ownership + +- `scripts/check-decision-traceability.sh`: retained shell entrypoint +- `scripts/check-decision-traceability.mjs`: Git-snapshot gate +- `scripts/check-decision-traceability.test.mjs`: isolated regression cases +- `scripts/decision-traceability-map.json`: bounded project impact coverage +- `package.json` / `package-lock.json`: development-only CommonMark parser and its locked dependency closure +- `scripts/README.md`: current gate operation and coverage owner +- `docs/development.md`: replace the now-retired-gate limitation +- `.github/workflows/quality-gates.yml`: only the affected job's history, + explicit inputs and focused test invocation +- This report: dated deciding evidence + +No runtime implementation, other CI job, standards checkout, active plan, +issues or execution ledger is edited here. The integration coordinator owns +master-plan reconciliation and downstream runtime branch dependencies. + +## Independent Review Repair + +Independent review found false positives for balanced-parenthesis destinations, +tracked directories and fenced examples, mapping-key ordering churn, and a +retained-prior-owner migration gap. The revised candidate uses the established +CommonMark parser instead of owning a regex Markdown grammar, treats tracked +trees as valid link targets, compares normalized maps, and requires a retained +old owner to explain migration. Deleted old owners remain permitted only with a +current replacement and repaired readers. Three regression cases cover these +findings. The parser is a pinned development dependency, BSD-2-Clause licensed; +no production dependency version or existing override changes. + +Revised focused verification passes (24 cases). The integration owner independently +reviewed staged tree `90f286152a59c27bff775e73ceda2b8386dbd575`, including the +complete gate, regression additions, package/lock delta, wrapper and CI inputs, +and reproduced all 24 cases. That narrow review accepted the four repairs +without another finding. This report-only update records the result; unrelated +baseline CI and final remote-head qualification remain unsatisfied. `npm ci --ignore-scripts` installed the locked dependency closure. +CommonMark 0.31.2 and its new entities 3.0.1, mdurl 1.0.1 and minimist 1.2.8 +entries are development-only; all prior version/integrity identities are unchanged. +The installed package retains its copyright/license notices. No third-party +source is vendored or copied into the product. The gate owner maintains the +parser dependency and retires it if the gate no longer consumes Markdown. +Node/npm here are 24.19.0/11.9.0 rather than the pinned 24.12.0/11.6.2; exact +pinned-toolchain qualification remains a hosted-CI obligation. + +## Verification And Limits + +- Node's ordinary test runner: 24 focused cases pass, covering code-only changes, + required guide updates, unrelated documents, index/range isolation, missing and + contradictory inputs, missing maps, malformed maps, broken references, + removed/moved rows and paths, nonregular files, spaces/newlines, and failed or + malformed Git diffs. Test commits have explicitly synthetic identities and + live only in disposable temporary repositories. +- Real staged adoption from the audited base passes using the repository map. +- Repository tooling lint and focused ESLint over both new JavaScript files pass; Bash and Node syntax + checks pass. The checkout uses its isolated locked dependency tree; + no global configuration is changed. The reviewed repair adds the development-only + parser dependency described above. +- `npm run lint:no-new` remains blocked before traceability by the existing + `IoInspectorPage.svelte:520` DOM-mutation finding. This slice does not waive + that gate or claim repository-wide lint success. The a11y check independently + reports the same three existing findings as the clean base (Inspector and + RunGraphSnapshot reviewed-ignore annotations; SavedGraphInspectionSnapshot + keyboard activation). +- Passing this gate establishes selected traceability and file-reference + integrity, not semantic prose completeness, all standards compliance, runtime + correctness or release readiness. Independent narrow review and integration-owner + approval permit draft publication. Merge remains conditional on exact-head + CI and applicable hosted code review; neither is claimed complete here. + +The candidate branch is `fix/decision-traceability-ownership-2026-10-02`, targeting +main. Its isolated checkout is retained for review and downstream dependency +coordination; it is not an accepted integration or cleanup instruction. + +The existing pre-push `npm test` also passed on 2026-10-02: node-engine +258 passed / 1 ignored, workflow-nodes 168 passed. This proves its declared +library scope, not the unrelated failing aggregate/desktop gates. + +## Hosted Review: Pull-request Range Selection + +CodeRabbit reviewed exact head `9567186252f7ebb60794911baeeabce0daec5140` and +identified the PR target-tip versus fork-point mismatch. The workflow now +resolves a unique PR merge base explicitly, while push events retain their +before/head range. The gate continues to compare exactly the supplied snapshots; +it does not silently reinterpret an explicit range. Ambiguous/no common bases +fail. A diverged-branch regression covers unrelated target-only map additions. +This correction is separate from unchanged baseline CI failures. + +Correction verification: 25 focused tests pass. The actual workflow shell was +extracted and executed against a disposable divergent Git fixture: PR mode +selected its fork point, and push mode preserved the exact before/head input. +Workflow YAML, Bash syntax, scoped ESLint and whitespace checks pass. The +integration owner independently reviewed staged tree +`85ab1fe64e85c7f299d53f99e5ebb74625730607`, read the actual workflow delta, +reproduced the divergent-PR regression and passed staged whitespace checks. +That bounded review found no new source issue and accepted publication. +This report-only addition records the review; exact-head hosted CI and hosted +review qualification remain outstanding. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-workflow-contract-fixtures.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-workflow-contract-fixtures.md new file mode 100644 index 000000000..5998666dd --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-02-workflow-contract-fixtures.md @@ -0,0 +1,65 @@ +# Workflow Contract Fixture Repair + +Date: 2026-10-02. Status: `Verifying`. +Authority: [domain architecture plan](../plan.md), CI-01 baseline qualification. +Branch: `fix/workflow-contract-fixtures-2026-10-02`, based on prerequisite +`4cf2317b5d541e534cd953dddb9a3aa919fceb52` ([PR #6](https://github.com/MrScripty/Pantograph/pull/6)). +The workflow-service test owner implements this slice; the domain integrator owns +independent review and integration. Retain the branch until integrated, rejected +or superseded. Coding standards inspected at +`MrScripty/Coding-Standards@dcc56f26e884ade260770beceba2501d3746200d` include +Implementation, Verification, Commit, Documentation and Rust tooling guidance. + +## Reproduction + +The prerequisite branch installed the pinned C# generator successfully. Its +[headless run](https://github.com/MrScripty/Pantograph/actions/runs/37075520823) +then stopped at `cargo test -p pantograph-workflow-service`: 852 library tests +passed and 20 failed. All 20 failure identities match the earlier base-4938e40 +library run. The earlier aggregate of 23 also included three integration-contract +failures reached with `--no-fail-fast`. Comparing the prerequisite head with base +shows no workflow-service, Cargo manifest or lockfile source changes. +The original assertion logs were retained during diagnosis. + +PR #6 also passed the runtime-separation workflow and workspace checks with both +no default features and all features. Those receipts qualify provisioning and +native compilation, not the skipped generated-binding smoke or whole CI. + +## Corrected Preconditions + +The shared inference graph fixture omitted required `runtime_source_context`, +so publication rejected its request before lifecycle and dependency assertions. +Supply the same explicit operation, context shape and cancellation facts as the +existing publication tests. No production fallback or validation relaxation is +introduced. The stale-revision test also raced the new automatic validation +started by graph mutation. Use its existing sequenced facts provider to distinguish +both requests, then drain background validation before asserting the new revision +is Current. All original stale-response and current-summary assertions remain. + +The classifier fixture demanded a built-in contract for retired `expand-settings`. +Assert that the registry omits that type and that a missing contract is rejected; +retain rejection checks for existing excluded and unknown node types. + +The missing-candidate test supplied neither a runtime requirement nor a selected +model, making readiness irrelevant, and supplied a differently failing runtime. +Declare its required backend and omit candidate capabilities so the unchanged +incomplete-candidate warning and blocking assertions exercise their intended path. +Existing unavailable-runtime and irrelevant-runtime tests remain intact. + +## Verification And Open Work + +- Fresh Cargo compilation and complete library run: 867 passed, five failed, + zero ignored. All 15 repaired failures passed; only the unchanged capacity + tests remain. This is not a green aggregate claim. +- The five capacity fixtures execute a source/output graph without inputs and + expect runtime admission; non-runtime execution intentionally does not load a + runtime. Their admission/lifecycle setup requires a separate bounded repair. +- Three earlier integration-contract failures and other CI lint, audit and Rustler + failures remain separate. No workflow check or test has been disabled. +- Focused library run excluding the unchanged nine-test capacity module: 863 + passed. The synchronized race passed ten isolated repetitions. Cargo format, + staged whitespace and staged traceability passed. +- Independent integrator review accepted staged tree + `0650a9620edeff2c943f649147e5e2be06b460f8` after inspecting the changes, + surrounding source, report and whitespace. This report records that bounded + acceptance afterward. Exact-head hosted qualification remains pending. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-a11y-role-parser.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-a11y-role-parser.md new file mode 100644 index 000000000..db5f963cd --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-a11y-role-parser.md @@ -0,0 +1,16 @@ +# Svelte role-button scanner correction + +## Decision and scope + +The role-button scanner truncated opening tags at JavaScript arrow operators and quoted greater-than signs. SavedGraphInspectionSnapshot therefore failed keyboard activation checks despite declaring onkeydown. Parse Svelte with the existing locked compiler and inspect complete attributes. Keep native-control exemptions and require explicit tabindex, a keyboard handler, and accessible-name evidence. Rendered descendant text/expressions count as name evidence; callback source and comments do not. Preserve the existing native-button and reviewed-ignore policies. + +No dependency change or accessibility suppression is included. Dynamic role expressions remain outside this literal-role check. This is a static guard, not a complete runtime accessibility audit. Malformed Svelte stops the gate. + +## Verification + +- Eight parser regression tests pass: modern arrow handlers, quoted greater-than signs, legacy directives, real missing keyboard activation, independent required attributes and source lines, nested markup, comments/scripts, malformed syntax, and descendant names versus callback source. +- The lint:a11y entry point runs these regressions before scanning. +- Full repository scan now reports only the two existing reviewed-a11y-ignore findings in IoInspectorPage and RunGraphSnapshot. Those remain unresolved rather than waived. +- Independent review pending. No hosted qualification yet. + +Independent source review accepted tree 9ac66e91ff8ee2e5e1e9bc0393b22df5b27a6e00. Full ESLint also passes. Name evidence is intentionally static rather than a complete accessible-name computation: conditional or hidden content and dynamically empty labels still require UI/accessibility validation. Hosted qualification remains pending publication. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-beam-smoke-export-stubs.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-beam-smoke-export-stubs.md new file mode 100644 index 000000000..18248eaf8 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-beam-smoke-export-stubs.md @@ -0,0 +1,54 @@ +# BEAM Smoke Export Stub Repair + +Hosted Quality run 37099224216 at PR #9 head +`e1bf2d8d4426566308fb9ce72f4c4f1895512568` builds the Rustler NIF successfully, +then job 111135294915 fails all five BEAM smoke tests. The loader reports +`Function not found 'Elixir.Pantograph.Native':pumas_model_package_facts_summary_snapshot/3`. + +The default Rust NIF surface has added three exports since the smoke shim was +written: `pumas_model_package_facts_summary_snapshot/3`, +`pumas_resolve_model_package_facts_summary/2`, and +`pumas_list_model_library_updates_since/3`. Add their matching generated Elixir +stub declarations so the BEAM loader can replace them with the native functions. +No native implementation, feature selection, loader error handling, or assertions +change. The existing five tests exercise real loading and workflow/registry +round trips and will fail if export registration remains inconsistent. + +Source inspection finds 67 default Rust exports and 67 matching shim names after +this change, with the three new arities checked directly against their Rust +signatures. Seven additional Rust exports are guarded by `frontend-http`; the +canonical smoke runner uses the default feature set and does not include them. +This is bounded source evidence, not an ABI execution claim or a new permanent +source-parser gate. No broader model-library query behavior is claimed. + +Staged whitespace passes. Elixir/Mix are unavailable locally and no Rust rebuild +was attempted under the current disk constraint. Fresh hosted Rustler BEAM smoke +qualification and independent source review are pending. PR #9's workflow-service +tests already pass, while its downstream Headless qualification is still running. +Existing lint, Clippy and dependency audit failures remain separate; no merge is +qualified by this repair alone. + +Independent integrator source review accepted staged tree +`ad33e068e8057e70e8552fdb6e9f122d3ac82667` after comparing all three new +names/arities against the Rust NIF definitions and reading the unchanged generated +stub behavior. Actual BEAM loading remains a hosted qualification requirement. + +## Hosted loading result and newly reachable fixture repairs + +At head `2c5f22b09025c557585a010076af75bed5b0859b`, hosted job 111136852470 +built and loaded the native library successfully. Three smoke tests passed and +two reached outdated assertions. This qualifies native loading, not the full +smoke suite. + +Canonical graph contract validation reports an unknown source first and returns +for that edge before checking the target. Give the fixture two edges, each with +one known endpoint, to preserve both unknown-source and unknown-target checks. +Assert the current role-specific messages. Canonical `puma-lib` discovery exposes +`pumas_model_ref`; assert that port and reject the retired `model_path` port. +These changes preserve the validation/discovery coverage at the actual public +BEAM boundary. Production code and all other assertions remain unchanged. +Whitespace passes; independent source review and hosted rerun are pending. +Independent integrator source review accepted follow-up tree +`421f3be33e9504040c6e93c00f11bad7f8b8ec77` against the canonical edge validator +and current path-free model selector port. Fresh hosted smoke execution remains +required for these fixture assertions. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-clippy-borrow-result-annotations.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-clippy-borrow-result-annotations.md new file mode 100644 index 000000000..0b2a4003a --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-clippy-borrow-result-annotations.md @@ -0,0 +1,7 @@ +# Redundant borrow and Result annotation repair + +Two independent warning-deny Clippy findings require no behavioral change. Pass the existing &Path directly to fs::read_to_string instead of borrowing it again. Remove a redundant bare must_use annotation from select_scheduler_dispatch because its Result return type already carries that obligation; the validated successful payload also remains must_use. + +Error formatting, deserialization, dispatch policy, signatures, and tests are unchanged. Whitespace validation passes. No local Rust workspace compilation was performed; exact-head hosted qualification and independent source review are pending. These two changes do not address the separate AsRef migration, public runtime enum layout, or other aggregate findings. + +Independent source review accepted the two-line repair at tree b35f32903f85288265a68ffbe57e9d61bf021b80. Publication is stacked on the separately reviewed AsRef milestone to retain cumulative hosted qualification; this report does not imply that aggregate Clippy already passes. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-headless-integration-contracts.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-headless-integration-contracts.md new file mode 100644 index 000000000..e8a4b245f --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-headless-integration-contracts.md @@ -0,0 +1,63 @@ +# Headless Integration Contract Fixtures + +## Source-grounded scope + +PR #8 head `333e53db2b60c85c0190fd6ba34b75d3d5ecce46` passed all 878 +workflow-service library tests in hosted job 111081103534, then failed three of +29 integration contracts. Runtime Separation passed; aggregate Quality Gates +remains red. This repair changes test fixtures only and is stacked on PR #8. + +The shared run projection fixture omitted `memory_failure_kind`, +`observed_peak_ram_bytes` and `observed_peak_vram_bytes`, which the canonical +ledger run-list/detail records serialize as null when unavailable. Add those +fields to both rows, preserving the exact Rust round-trip assertions and the +TypeScript projection consumer's shared fixture. + +The execution fixture supplied canned vector outputs through the obsolete host +`run_workflow` hook, but current session execution reads `workflow_graph` and +runs scheduler tasks. Its missing graph failed before its output assertions. +Use a dedicated text-input to text-output graph host, with explicit non-runtime +capabilities and I/O, for the two execution contracts. Assert that graph and I/O +callbacks occurred and that the scheduler returns the supplied text. Remove the +unused canned run hook. Preserve vector response serialization and deserialization +as a separate exact DTO round-trip test; no vector execution support is claimed. + +The invalid-output fixture must also supply the text input: target discovery is +validated after all tasks complete, so missing inputs would instead leave a task +AwaitingInputs. Assert the exact non-discoverable output message and original +InvalidRequest variant. With no ledger, terminal finalization preserves that +error without a diagnostics wrapper. The previous missing-graph admission error +had a wrapper because admission records canonical errors separately. Retain this +actual admission/error-link contract in a dedicated missing-graph regression, +including real nonblank run attribution and the unavailable-ledger explanation. +Production scheduling, diagnostics and host APIs are unchanged. + +## Verification and qualification + +- Focused TypeScript projection tests: 12 passed, zero failed. +- Rustfmt and whitespace checks passed. No local Cargo build was attempted: + generated intermediates were reclaimed for the approved Android emulator and + current disk capacity does not support a fresh Rust build. +- Fresh hosted Rust integration qualification is required after publication. + Preserved old executables are not evidence for this changed source. +- Independent integrator source review accepted staged tree + `2c4b27a6fcccc182d90e597df4f275984cbe4fa3` after reading all three changed + files and the relevant host, validation and diagnostics sources. This is source + review acceptance, not Rust execution evidence; this report update follows it. +- Existing frontend lint, scheduler Clippy, + dependency audit and native Rustler failures remain separate. C# full binding + and native packaging qualification previously remained blocked downstream of + the failing integration contracts. No checks or assertions were waived. + +## Hosted compilation correction + +Initial exact-head hosted run 37098781826 failed compilation with E0046 before +executing contracts: `WorkflowHost::run_workflow` remains a required compatibility +trait method. Both fixture hosts now implement it as an explicit panic guard, +so accidental execution through that hook fails instead of returning canned +outputs. This preserves the real scheduler path and all assertions. Rustfmt and +staged whitespace pass; fresh hosted compilation and execution remain required. +Independent integrator review accepted the exact two-hook correction at staged +`08b64cd03e0ce645b3aae6e0a5363ff3674397b3`; it satisfies the required trait +signature while retaining fail-on-legacy-bypass behavior. This acceptance is +source-only and does not replace hosted Rust execution. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-http-adapter-transport-contracts.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-http-adapter-transport-contracts.md new file mode 100644 index 000000000..1fd5cfaa1 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-http-adapter-transport-contracts.md @@ -0,0 +1,57 @@ +# Frontend HTTP Adapter Transport Contracts + +## Diagnosis and owner + +PR #9 Headless run 37099224225 passed workflow-service tests and then remained +in the frontend HTTP adapter step. Completed job logs are unavailable while it +runs. Source inspection identifies an unbounded wait: five tests use a test-only +session wrapper around a vector-output graph, which the current scheduler does +not execute. The service returns before the HTTP host hook is called, and each +test then joins a server blocked in `TcpListener::accept`. Its existing socket +read timeout only applies after acceptance. PR #10 inherits the same test path. + +The four HTTP error-envelope tests belong to the transport implementation in +`FrontendHttpWorkflowHost::run_workflow`. Invoke that port directly, retain the +original exact error kinds/messages, and require a real POST to `/v1/workflow/run`. +Remove the test-only session wrapper and disk graph fixtures, which obscured +that boundary. This does not reintroduce a public direct-run service API or make +session scheduling dispatch through the compatibility hook. + +The old missing-output fixture mixed two owners: it returned HTTP 200 with an +empty output list but expected the local service to validate a requested target. +The adapter only parses that response. Preserve its empty-output behavior in a +transport test and assert remote `output_not_produced` envelope mapping separately. +Canonical scheduler missing-result rejection remains covered by +`task_result_output_projection::tests::rejects_missing_requested_output`; this +slice makes no new claim about the scheduler's public error code. + +## Bounded server and evidence + +The test server now uses a five-second accept deadline and retains a two-second +read timeout. Transport test requests have a two-second timeout. Read the complete +request line, headers and declared body before replying, avoiding partial-read +assumptions; assert the actual HTTP method/path. Missing dispatch becomes a clear +finite server failure rather than a CI job waiting indefinitely. + +Changes are confined to the adapter's test module and this report. Rustfmt and +whitespace pass. Local Cargo execution is unavailable under the shared disk +constraint; existing binaries do not qualify changed source. Independent review +and fresh hosted adapter tests are required. Running earlier jobs cannot consume +this repair automatically and need cancellation or their existing timeout before +their final logs become available. Broader lint, Clippy, audit and native binding +qualification remain separately open. + +## Terminal evidence and review + +Both earlier stalled runs were cancelled through the authenticated GitHub UI. +Completed PR #9 logs now confirm 878 library tests and all 31 integration contracts +passed; the adapter compiled, passed nine of fourteen tests, then blocked in its +remaining HTTP fixtures until cancellation at 05:31:39 UTC. Native/C# stages did +not run. This corroborates the source-grounded hang diagnosis. + +Independent integrator source review accepted staged tree +`85d9372bd91786187719042bc778446485d553fd`, including transport/target-validation +ownership and bounded socket lifetimes. At the reviewer's suggestion, the empty +response transport test additionally checks the exact received JSON workflow ID, +inputs and output targets. This is source review only; hosted compilation and +execution remain required. Production module bytes remain unchanged. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-scheduler-asref-contracts.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-scheduler-asref-contracts.md new file mode 100644 index 000000000..ac37383f2 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-scheduler-asref-contracts.md @@ -0,0 +1,17 @@ +# Scheduler validated-wrapper AsRef compatibility + +## Decision and public surface + +Move the fourteen validated scheduler wrapper accessors from inherent methods into standard AsRef implementations, preserving exported wrapper types, borrowed raw targets, lifetime elision, and direct `&self.0` references. Constructors, validation, ownership, serialization, and into_inner remain unchanged. This addresses should_implement_trait without suppressing the lint. + +A Rust 1.92 standalone Clippy probe showed that retaining an inherent accessor alongside the trait still triggers the lint. A trait-only probe compiled and executed method, type-qualified, fully qualified, and typed function-pointer forms. Repository inspection found no no_implicit_prelude usage or explicit validated scheduler UFCS references. The crate is publish=false but its types are publicly re-exported, so external Git/path consumers remain relevant: a niche consumer disabling the Rust prelude must import AsRef explicitly. Trait-only calls rely on the standard prelude. No claim is made about unseen consumers. + +The integration compile fixtures cover all fourteen exported wrapper/raw pairs with method calls, type-qualified calls, typed function pointers, and explicitly trait-qualified calls. The Quality workflow runs the scheduler suite and a targeted should_implement_trait Clippy check, while retaining the existing aggregate warning-deny audit unchanged. + +## Verification and remaining scope + +The small standalone probe passed locally; formatting and whitespace checks pass. No local workspace Rust build was run due to disk constraints. Actual scheduler tests and targeted Clippy qualification require fresh hosted execution. Independent source review is pending. + +The runtime execution enum size/construction API, redundant must_use annotation, managed dependency needless borrow, and later aggregate Clippy findings remain separate. This is an accessor-contract repair, not a scheduler redesign. + +Independent source review accepted tree ea62f92c5bfed9225adcf753a77c5be55d23475e. Accessor must_use annotations follow the standard trait method semantics; the validated wrapper types remain must_use. This acceptance covers the source/API migration and hosted checks, not an unperformed local workspace compilation. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-selector-derived-state.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-selector-derived-state.md new file mode 100644 index 000000000..48815bd3e --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-selector-derived-state.md @@ -0,0 +1,22 @@ +# Selector State Derivation + +The current Full lint job reports one error in PumaLibNode.svelte: a `$state` +value is copied from `data.model_id` by an effect. Replace that pair with writable +`$derived` state. The existing selection handler can still update the value +optimistically, and incoming node data remains authoritative. No selection, +hydration, cache or error-handling code changes. + +The locked Svelte 5.55.4 supports writable derived values; official documentation +covers this optimistic-update pattern from 5.25 onward: +https://svelte.dev/docs/svelte/$derived#Overriding-derived-values + +Verification: existing selector-state and model-options-cache tests pass 15/15; +full ESLint, TypeScript typecheck, direct Svelte client compilation and whitespace +checks pass. No dependencies or Rust sources change. No browser interaction claim +is made. Independent review and exact-head hosted qualification remain pending. +The separately bot-reviewed/native-qualified PR #15 head remains unchanged; +critical/a11y lint, Clippy and dependency audit still have separate work. +Independent integrator source review accepted tree +`0435e312fab157a8d9e3d6b5492e3f0197aa44e6` after checking the component and +selection handler. Optimistic writes and source-driven resynchronization remain; +no broader error/hydration behavior change is implied. Hosted gates remain required. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-capability-estimate-fixture.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-capability-estimate-fixture.md new file mode 100644 index 000000000..759af6f5b --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-capability-estimate-fixture.md @@ -0,0 +1,28 @@ +# UniFFI Capability Estimate Fixture + +Headless run 37101137330 at PR #11 head +`8ac413c52b2501bc3c69310a38eb6f13e81bb2d4` passed workflow-service and frontend +HTTP adapter tests, then reached UniFFI adapter-mode qualification. The +frontend-http test configuration failed compilation at `lib_tests.rs:320`: +`WorkflowRuntimeRequirements::estimated_peak_ram_mb` no longer exists. + +The canonical contract publishes typed `resource_estimates`. Host capabilities +call `estimate_memory_requirements`, which emits available zero-byte RAM/VRAM +estimates when the graph has no required models. Preserve the old zero-RAM +assertion by locating PeakRamBytes and comparing the complete available estimate, +including its zero value and empty diagnostics. An absent, unavailable, or +nonzero estimate fails the assertion. Existing limits and no-model assertions +remain unchanged; no production, feature, or memory policy change is needed. + +Rustfmt and staged whitespace pass. No local Cargo build was attempted under the +shared disk constraint. Independent source review and fresh hosted UniFFI test +execution remain required. The separate unused shutdown Result warning in +`runtime.rs:209` is recorded for lifecycle/error-contract review rather than +suppressed. Native metadata, C# generation/package/quickstart and aggregate lint, +Clippy/audit qualification remain open. + +Independent integrator source review accepted staged tree +`1910522f10c8fddcbe156635e0db2663e220f5fc` against capabilities.rs:313–370. +This preserves full available zero-byte RAM semantics; exact-head hosted +frontend-http feature compilation and test execution remain required. The +preceding no-default UniFFI configuration already passed all 12 tests. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-missing-runtime-fixture.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-missing-runtime-fixture.md new file mode 100644 index 000000000..9a562af1d --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-missing-runtime-fixture.md @@ -0,0 +1,27 @@ +# UniFFI Managed Runtime Readiness Fixture + +The current hosted UniFFI runtime suite exposes a false readiness expectation: +`create_temp_root` writes legacy fixture files under `app-data/runtimes/llama-cpp`, +but the managed-runtime owner resolves `app-data/third-party/runtimes/llama-cpp` +or a selected managed version. See inference/src/managed_runtime/paths.rs and +operations/state_transitions.rs:runtime_install_dir_for_projection. On the +supported test platforms the current installation validator reports required +files missing; operations/projection.rs and neutral_contracts.rs preserve that +Missing state rather than marking a runtime Ready. + +Keep the fixture's legacy files and assert the honest neutral projection: +llama_cpp/runtime_sidecar exists, install and readiness states are Missing, +available is false, and missing_files is nonempty. All media dependency listing, +staging/install/remove and conversion-capability assertions remain. This repairs +the test's evidence claim without fabricating a managed installation or weakening +production readiness rules. + +Rustfmt and whitespace pass. Independent source review and hosted execution are +pending; no local Rust build was attempted. The validation/publication API bridge +is separately reviewed, and native/C# plus aggregate lint/Clippy/audit qualification +remain open. + +Independent integrator source review accepted staged tree +`2e98f08e0c780bd5becc8a74925dac9bbd252ec6` as an explicit regression that +legacy-location files cannot establish a current managed installation. Hosted +execution remains required. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-selector-contracts.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-selector-contracts.md new file mode 100644 index 000000000..17e71c629 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-selector-contracts.md @@ -0,0 +1,28 @@ +# UniFFI Selector Contract Fixtures + +PR #12 Headless run 37101754525 compiled the repaired capability contract and +ran the frontend-http UniFFI configuration: 18 passed and five failed. Two +failures were stale selector fixtures referring to `puma-lib:model_path`, which +has been replaced by `pumas_model_ref` in the canonical node and options provider. + +Update discovery to require `pumas_model_ref` and explicitly reject the retired +queryable port. Query options through the canonical port and require an object +with model-ref contract version 1 and the exact indexed model ID. Verify that the +value matches the canonical reference included in selector metadata; preserve +the old fixture's exact file association assertion against `display_entry_path`, +where display filesystem paths now belong. Cursor and model-presence assertions +remain unchanged. The provider source is workflow-nodes/src/input/puma_lib.rs: +selector_row_value, selector_row_option_metadata and port_option_from_selector_row. +These tests do not claim inference readiness or model execution. + +Only two test functions and this report change. Rustfmt and whitespace pass; +independent source review and hosted tests remain required. No local build was +attempted. The three other runtime-test failures are separate: two require a +legitimate embedding validation/publication API bridge, and managed-runtime +readiness requires current installation evidence rather than legacy empty files. +Neither admission checks nor readiness semantics are weakened here. + +Independent integrator source review accepted staged tree +`f106e00bcb9b43a7903baab5403c88a38ee9b7a8`: canonical reference identity, +version and metadata equality remain asserted, while display path and cursor +checks are preserved. Hosted qualification is still required. diff --git a/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-validation-publication-bridge.md b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-validation-publication-bridge.md new file mode 100644 index 000000000..c732c2732 --- /dev/null +++ b/docs/plans/domain-architecture-and-multimodal/reports/2026-10-03-uniffi-validation-publication-bridge.md @@ -0,0 +1,99 @@ +# UniFFI Owner-Derived Validation Publication + +## Observed gap and accepted decision + +PR #12 hosted UniFFI tests compiled and reached execution, but saved text and +interactive fixtures failed on missing executable validation snapshots. The FFI +constructor configures ephemeral attribution; the scheduler requires a matching +saved validation snapshot. The API exposed editing but no path to obtain/publish +that validation. This is an embedding API gap, not permission to fabricate a test +snapshot or weaken admission. + +The integrator accepted a thin bridge before implementation; the canonical plan +records it. EmbeddedRuntime delegates typed requests to the existing service +refresh/publication methods. UniFFI accepts their strict JSON DTOs, maps errors +through the existing workflow envelope, and serializes the validated snapshot +record returned by the owner. Publication derives graph and proof provenance from +server-owned session state. Revision, descriptor and dependency-proof freshness +checks remain unchanged. No parallel validator or snapshot constructor is added. + +The public client flow loads a saved graph, creates an edit session, refreshes the +returned revision, checks the submit gate, publishes the returned validation +session for the workflow/version, then runs an execution session. The documented +attribution lifecycle is ephemeral; reopening the runtime requires republishing. +No inference readiness or interactive task execution support is introduced. + +## Evidence and remaining qualification + +The existing text execution test now obtains its snapshot through that public +flow. New tests cover execution after publication and closing the edit session, +missing publication, loss of publication on runtime reopen followed by successful +republishing, stale graph validation, and rejection of caller-supplied proof +fields. The interactive graph retains an explicit rejection regression at the +current unsupported scheduler-task boundary rather than asserting the obsolete +legacy executor's waiting-input message. UniFFI metadata and generated C# checks +require both new methods. + +Rustfmt, shell syntax and staged whitespace are supporting checks only. No local +Rust compilation has been attempted under the shared disk constraint. Independent +read-only source review is required before publication, then exact-head hosted +Rust and generated-binding execution must qualify the bridge. Selector fixtures +are a separate preceding milestone. The managed-runtime readiness fixture still +requires current evidence; aggregate lint, Clippy and audit remain unresolved. + +## Independent review correction + +Historical checkpoint `0e5496ce362002d11379d3771b952beb02d0eaa6` remains frozen +in its original checkout. Review found two blocking gaps: inference-only records +unconditionally required a node, so pure text could not be published; and actual +C# callers still attempted execution without publication. No wrapper ownership, +strict DTO or newly introduced race bypass was found. + +The integrator approved a domain correction: structural validation permits zero +inference nodes, while publication and scheduler projection require exact equality +with the canonical graph's inference-node set. The inference request owner exports +a shared iterator for its existing llm-inference category. Other inference-bearing +node types remain Unsupported in the scheduler classifier, with a regression for +that fail-closed behavior. Duplicate snapshot and graph inference IDs are rejected; +missing/extra projection IDs are reported. Runtime-node proof validation and all +revision/fingerprint/version/dependency freshness checks stay in force. + +The public scheduler projection method now requires a graph argument and enforces +coverage itself, including for imported/deserialized records. The execution owner +passes its actual loaded graph; publication passes the server-owned edit graph. +No new caller flag, alternate snapshot constructor or schema escape is added. +Regressions cover empty owner publication, valid runtime coverage, missing/extra/ +duplicate projections and deserialized empty records presented for runtime graphs. +The stored-attribution fixture now actually includes its claimed inference node. + +NativeSmoke and DirectRuntimeQuickstart now load/edit/refresh/publish through the +new generated methods, check submit permission and publication identity, and close +the edit session before execution. Symbol checks remain supporting gates, not a +claim that the C# workflow ran. Both real programs require hosted generation, +compilation and execution. This correction is based on the separately published +selector/readiness milestones; their tests have passed in hosted runs. Independent +re-review and all fresh Rust/C# qualification remain pending. + +Independent narrow re-review accepted correction tree +`80666d31216c3286eec923bd4ee96e8a58bab05e`: exact graph coverage at owner +construction and graph-required public projection resolves the zero-inference +blocker, and both actual C# callers use publication before execution. Missing, +extra, duplicate and deserialized-empty regressions were inspected; unsupported +inference-bearing kinds remain fail-closed. No new source blocker was found. +This report records bounded source acceptance, not hosted execution evidence. + +## Hosted attribution-fixture follow-up + +Initial PR #15 head `8a8dcbcc0a58aafc0f8e09d7853078856ad78834` compiled and +ran the workflow library suite: 880 passed, two failed. Exact coverage rejected +pre-existing synthetic `infer` records attached to pure text graphs in the +snapshot-ordering and client/bucket attribution fixtures. Their behavioral +assertions remain unchanged. Both fixtures now create a graph session, refresh +owner validation and publish through the real service API, then close the edit +session; they no longer invent an inference node to satisfy the old nonempty +snapshot rule. Runtime-inference fixtures retain their actual runtime proofs. +Rustfmt/whitespace pass; narrow review and fresh hosted qualification are pending. +Independent narrow source review accepted follow-up tree +`255079840fae85689cc6ff5b508fe3bc66be0e8b`: both fixtures now use the public +owner validation flow, with all behavioral assertions unchanged and no production +change. Fresh hosted full-library and downstream C# qualification remain required. diff --git a/package-lock.json b/package-lock.json index 61ac3ad64..d1f5f8729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@wdio/mocha-framework": "^9.29.0", "@wdio/spec-reporter": "^9.29.0", "autoprefixer": "^10.4.19", + "commonmark": "0.31.2", "es-module-lexer": "^2.0.0", "eslint": "^9.0.0", "eslint-plugin-svelte": "^3.0.0", @@ -4383,6 +4384,44 @@ "node": ">= 6" } }, + "node_modules/commonmark": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.31.2.tgz", + "integrity": "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "entities": "~3.0.1", + "mdurl": "~1.0.1", + "minimist": "~1.2.8" + }, + "bin": { + "commonmark": "bin/commonmark" + }, + "engines": { + "node": "*" + } + }, + "node_modules/commonmark/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/commonmark/node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -7150,6 +7189,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", diff --git a/package.json b/package.json index 49032ab1e..6f3b5f18e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint": "eslint scripts eslint.config.mjs vite.config.ts --max-warnings 0", "lint:full": "eslint src packages scripts eslint.config.mjs vite.config.ts --max-warnings 0", "lint:critical": "node scripts/check-critical-antipatterns.mjs", - "lint:a11y": "node scripts/check-svelte-a11y.mjs", + "lint:a11y": "node --test scripts/svelte-role-button-check.test.mjs && node scripts/check-svelte-a11y.mjs", "lint:no-new": "npm run lint:critical && npm run lint:a11y && npm run traceability", "format:check": "cargo fmt --all -- --check", "test:frontend": "node scripts/run-frontend-tests.mjs", @@ -61,6 +61,7 @@ "@wdio/mocha-framework": "^9.29.0", "@wdio/spec-reporter": "^9.29.0", "autoprefixer": "^10.4.19", + "commonmark": "0.31.2", "es-module-lexer": "^2.0.0", "eslint": "^9.0.0", "eslint-plugin-svelte": "^3.0.0", diff --git a/scripts/README.md b/scripts/README.md index 6d67c3bb0..c4d431ff6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,7 +10,9 @@ main app entrypoint. | ----------- | ----------- | | `check-runtime-redistributables-smoke.sh` | Verifies a built Pantograph release artifact exists, then runs the bounded release contract smoke that covers managed-runtime view projection, runtime diagnostics projection, current image workflow shape, Pumas resolution, stale graph diagnostics, and image artifact retention. | | `check-current-image-workflow-smoke.mjs` | Validates the bundled current image workflow template and tracked Juggernaut workflow still use canonical `puma-lib -> llm-inference -> image-output` graph shape without retired executable inference nodes. | -| `check-decision-traceability.sh` | Enforces source-directory README/ADR decision traceability for changed source directories, with repo-specific host-facing and structured-producer paths plus a grep fallback when `ripgrep` is unavailable. | +| `check-decision-traceability.sh` | Runs the decision-to-guide traceability gate over explicit Git snapshots using the reviewed impact map. | +| `check-decision-traceability.mjs` | Validates mapped decision impacts, canonical guides, and local ADR references without per-directory documentation rules. | +| `check-decision-traceability.test.mjs` | Exercises Git snapshot isolation, impact ownership, missing inputs, broken references, and path transitions. | | `check-no-python-linkage.sh` | Verifies the runtime-separation guarantee that Pantograph no longer links Python in-process. | | `check-scheduler-only-workflow-execution.sh` | Fails when public Rust, Tauri, binding, or frontend source reintroduces direct workflow execution APIs outside scheduler session execution. | | `check-rustler-beam-smoke.sh` | Builds `pantograph_rustler`, verifies the local BEAM toolchain exists, and runs the Mix smoke harness under `bindings/beam/pantograph_native_smoke/`. | @@ -52,14 +54,12 @@ can be debugged without the full app UI in the loop. ## Invariants - Scripts run relative to the repository root. -- Decision traceability defaults cover `src`, `src-tauri/src`, `crates`, - `packages/svelte-graph/src`, `scripts`, and `.pantograph`; use environment - overrides for temporary focused audits. The default host-facing and - structured-producer lists include UniFFI binding generator helpers and active - inference managed-runtime producers only. -- Decision traceability must preserve equivalent README/ADR checks when `rg` is - unavailable by falling back to standard shell tooling instead of failing on - clean CI runners. +- Decision traceability checks only declared decision-source impacts and local + references. It does not infer semantic contract changes from source-directory + names, require fixed headings, or accept an unrelated ADR as evidence. +- The gate reads the selected Git snapshots, including the map and guides. It + fails on unavailable or unreadable inputs rather than guessing a branch or + treating a failed diff as an empty change. - Smoke tests target real Pantograph worker/runtime modules, not forks of that logic. - Release contract smoke is headless: it validates the built artifact and @@ -79,7 +79,10 @@ can be debugged without the full app UI in the loop. and repo-local build configuration. **External:** Bash, Node.js, Python, and any runtime libraries required by the -specific script being executed. +specific script being executed. Decision traceability additionally uses the +locked development-only `commonmark` package (BSD-2-Clause); run `npm ci` before +invoking it. The package owns Markdown parsing, avoiding a parallel regex +grammar; it does not render or execute document content. ## Related ADRs - `docs/adr/ADR-011-scheduler-only-workflow-execution.md` @@ -93,11 +96,12 @@ specific script being executed. python3 -m py_compile scripts/diffusion_cli_smoketest.py ./.venv/bin/python scripts/diffusion_cli_smoketest.py --model-path /path/to/tiny-sd-turbo node scripts/check-current-image-workflow-smoke.mjs -npm run lint:no-new +TRACEABILITY_STAGED_ONLY=1 npm run lint:no-new npm run lint:a11y npm run format:check npm run release:sbom -- 0.1.0 -./scripts/check-decision-traceability.sh +TRACEABILITY_STAGED_ONLY=1 ./scripts/check-decision-traceability.sh +node --test scripts/check-decision-traceability.test.mjs ./scripts/check-no-python-linkage.sh ./scripts/check-scheduler-only-workflow-execution.sh ./scripts/check-rustler-beam-smoke.sh @@ -135,3 +139,83 @@ Reason: script stdout/stderr is diagnostic and may change unless a future script is explicitly documented as machine-consumed. Revisit trigger: CI, external tooling, or another repo begins parsing a script's output structurally. + +## Decision Traceability Operation + +The [impact map](decision-traceability-map.json) is owned by this guide. It maps +exact accepted decision documents to current documentation owners: + +- ADR-001/011: host/service ownership and scheduler-session consumer lifecycle, + owned by [headless integration](../docs/headless-workflow.md) +- ADR-002/003/007: runtime lifecycle, readiness, observability and recovery, + owned by [runtime operations](../docs/runtime-operations.md) +- ADR-006/009: canonical/composed-node and migration responsibility, + owned by [architecture](../ARCHITECTURE.md) +- ADR-017: contributor workspace policy and verification procedure, + owned by [development](../docs/development.md) +- The map itself: the gate's declared coverage, owned by this guide + +Changing a mapped decision or its mapping requires updating that boundary's +canonical guide. New or changed unmapped ADRs fail as unresolved coverage until +an owner and the affected knowledge are declared. Removing a row or moving a +trigger still evaluates its prior obligation. A moved guide must be replaced in +the current map and affected links; if the old guide remains, update it to +explain the migration. A retired row still requires its prior guide +to explain the change. The map admits exact decision-document paths only, not +whole source trees or mixed implementation files. + +Staged mode (`TRACEABILITY_STAGED_ONLY=1`) compares HEAD with the index tree and +reads all content from those snapshots. Unstaged documents cannot repair the +index. Range mode requires both revisions explicitly: + +```bash +TRACEABILITY_MODE=range TRACEABILITY_BASE_REF= \ + TRACEABILITY_HEAD_REF= npm run traceability +``` + +The gate reports and compares the exact supplied input IDs. CI fetches history +and, for pull requests, explicitly selects the unique merge base of the event +base/head commits as the comparison base. This excludes unrelated target-branch +changes from the PR delta. Push events compare the event before/head commits +directly. Missing or multiple PR merge bases fail; the gate itself never silently +substitutes a caller-provided revision. Missing modes, refs, maps, owners or local targets +fail; unreadable Git state also fails. The old source-root/host/producer path +list overrides are rejected. The shell entrypoint explicitly selects this +repository's map; the Node implementation accepts `--map` for isolated tests or +an explicitly reviewed alternative. + +Both prior and current maps are read. The sole migration case is the audited +pre-map commit `4938e405c7f656365eefdca492774ccae110c90d` with this exact map path. +At that base, current decision rows must also resolve in the prior snapshot; +there is no general missing-map opt-out. Remove that transition once supported +comparison bases all contain the map. + +Local links in mapped guides and changed Markdown are checked, including ADR +paths in code spans. Unchanged readers are checked when an ADR or prior/current +canonical guide target changes, so moving/deleting owned documentation cannot +leave stale references. Untouched historical links are outside this change-scoped +gate. The development-only CommonMark parser owns Markdown syntax, including balanced +link destinations, used reference links and images. Fenced/indented examples +are not live links; inline code-span ADR paths remain the repository convention. +Tracked directory targets are valid. Object-key, boundary-row and trigger +ordering do not change mapped knowledge or create guide-edit obligations. +URL fragments and remote URLs are outside this file-existence check. A guide +edit proves traceability, not that its prose is substantively correct. + +This gate does not prove semantic contract completeness. A code change that +alters consumer behavior, responsibility, invariants or procedures still needs +its canonical documentation under [documentation rules](../docs/README.md), +with review and applicable contract tests. Routine repairs under unchanged +contracts need no documentation churn. Existing host/binding, scheduler-only, +worker-protocol and structured-producer checks remain required; the removed +universal README headings never proved those contracts. + +## Ubuntu CI Build Prerequisites + +`install-ubuntu-build-dependencies.sh` owns the shared native package list for +workspace check, warning-deny Clippy and the desktop linkage build on Ubuntu +hosted runners. It retains the existing GTK/WebKit/libsoup packages and installs +`protobuf-compiler` for the Lance build's `protoc` requirement. This command +uses `sudo apt-get` and changes the runner's system packages; do not treat it as +a read-only check or run it on another computer without authorization. A failed +install fails the step. It changes no Rust features, pins or verification gates. diff --git a/scripts/check-decision-traceability.mjs b/scripts/check-decision-traceability.mjs new file mode 100644 index 000000000..50200b029 --- /dev/null +++ b/scripts/check-decision-traceability.mjs @@ -0,0 +1,235 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { Parser } from 'commonmark'; + +const markdown = new Parser(); + +// Audited transition from the retired directory-template gate. This is not a +// caller override: every other prior revision must contain the selected map. +const LEGACY_BASE = '4938e405c7f656365eefdca492774ccae110c90d'; +const PROJECT_MAP = 'scripts/decision-traceability-map.json'; +const ADR_PATH = /^docs\/adr\/ADR-\d+[^/]*\.md$/; +const PROFILES = new Set(['boundary-readme', 'contract-readme', 'adr', 'runbook']); + +function fail(kind, message) { + throw new Error(`${kind}: ${message}`); +} + +function git(...args) { + const result = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + if (result.error || result.status !== 0) { + fail('unsupported', `cannot read Git input (${args[0]}): ${result.error?.message || result.stderr.trim() || result.signal || result.status}`); + } + return result.stdout; +} + +function repositoryPath(value, label) { + if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || + path.posix.isAbsolute(value) || path.posix.normalize(value) !== value || + value === '..' || value.startsWith('../')) { + fail('invalid', `${label} must be an exact repository-relative file path`); + } + return value; +} + +function revision(ref, label) { + if (!ref) fail('unavailable', `${label} is required in range mode`); + return git('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`).trim(); +} + +function snapshot(rev) { + const files = new Map(); + const output = git('ls-tree', '-rtz', '--full-tree', rev); + if (output && !output.endsWith('\0')) fail('invalid', 'malformed Git tree input'); + for (const record of output.split('\0').filter(Boolean)) { + const match = /^(\d{6}) (\w+) ([0-9a-f]+)\t([\s\S]+)$/.exec(record); + if (!match) fail('invalid', 'malformed Git tree entry'); + files.set(match[4], { mode: match[1], type: match[2], oid: match[3] }); + } + files.set('.', { mode: '040000', type: 'tree', oid: rev }); + const cache = new Map(); + return { + rev, + files, + read(file) { + const entry = files.get(file); + if (!entry) fail('unavailable', `${file} is missing at ${rev}`); + if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode)) { + fail('unsupported', `${file} must be a regular tracked file at ${rev}`); + } + if (!cache.has(file)) cache.set(file, git('cat-file', 'blob', entry.oid)); + return cache.get(file); + }, + }; +} + +function changedPaths(base, head) { + // No rename inference: both the removed and added paths remain obligations. + const output = git('diff', '--no-ext-diff', '--no-textconv', '--name-status', '-z', '--no-renames', base, head, '--'); + if (output && !output.endsWith('\0')) fail('invalid', 'malformed Git diff: missing NUL terminator'); + const fields = output ? output.slice(0, -1).split('\0') : []; + if (fields.length % 2) fail('invalid', 'malformed Git diff: incomplete status/path pair'); + const changed = new Set(); + for (let i = 0; i < fields.length; i += 2) { + if (!/^[AMDT]$/.test(fields[i]) || !fields[i + 1]) { + fail('invalid', `malformed Git diff: unsupported status/path pair ${JSON.stringify(fields[i])}`); + } + if (changed.has(fields[i + 1])) fail('invalid', 'malformed Git diff: duplicate path'); + changed.add(fields[i + 1]); + } + return changed; +} + +function readMap(state, mapPath) { + let value; + try { value = JSON.parse(state.read(mapPath)); } + catch (error) { + if (/^(unavailable|unsupported):/.test(error.message)) throw error; + fail('invalid', `cannot parse ${mapPath} at ${state.rev}: ${error.message}`); + } + if (value?.version !== 1 || !Array.isArray(value.boundaries) || !value.boundaries.length || + Object.keys(value).some(key => !['version', 'boundaries'].includes(key))) { + fail('invalid', `${mapPath} must declare version 1 and a nonempty boundaries array`); + } + const rows = new Map(); + const owners = new Map(); + for (const row of value.boundaries) { + if (!row || typeof row.id !== 'string' || !row.id.trim() || rows.has(row.id) || + !PROFILES.has(row.profile) || typeof row.knowledge !== 'string' || !row.knowledge.trim() || + !Array.isArray(row.triggers) || !row.triggers.length || + Object.keys(row).some(key => !['id', 'profile', 'knowledge', 'artifact', 'triggers'].includes(key))) { + fail('invalid', `${mapPath} contains an invalid or duplicate boundary`); + } + repositoryPath(row.artifact, `artifact for ${row.id}`); + if (!row.artifact.endsWith('.md')) fail('invalid', `artifact for ${row.id} must be Markdown`); + if (!state.read(row.artifact).trim()) fail('invalid', `empty canonical artifact ${row.artifact}`); + for (const trigger of row.triggers) { + repositoryPath(trigger, `trigger for ${row.id}`); + if (trigger === row.artifact || owners.has(trigger)) fail('invalid', `contradictory trigger ownership: ${trigger}`); + // Decision documents and the map are the entire admitted trigger surface. + // Mixed implementation files would require semantic review, not path rules. + if (!ADR_PATH.test(trigger) && trigger !== mapPath) fail('unsupported', `undeclared decision-source trigger: ${trigger}`); + state.read(trigger); + owners.set(trigger, row.id); + } + rows.set(row.id, { id: row.id, profile: row.profile, knowledge: row.knowledge, artifact: row.artifact, triggers: [...row.triggers].sort() }); + } + if (owners.get(mapPath) === undefined) fail('invalid', `${mapPath} must map its own coverage changes to an owning guide`); + return rows; +} + +function localTarget(owner, reference, rootRelative = false) { + if (/^[a-z][a-z\d+.-]*:/i.test(reference) || reference.startsWith('//')) return null; + const file = reference.split(/[?#]/, 1)[0]; + if (!file) return null; + let decoded; + try { decoded = decodeURIComponent(file); } + catch { fail('invalid', `malformed local reference in ${owner}: ${reference}`); } + const target = decoded.startsWith('/') ? decoded.slice(1) : + rootRelative && decoded.startsWith('docs/') ? decoded : path.posix.join(path.posix.dirname(owner), decoded); + return repositoryPath(path.posix.normalize(target).replace(/\/$/, ''), `local reference in ${owner}`); +} + +function documentReferences(state, file, allLocalLinks, changed, mappedArtifacts) { + const text = state.read(file); + const refs = []; + // Use CommonMark's syntax owner: examples in fenced/indented blocks are not + // live links, and balanced/escaped destinations and references stay intact. + const walker = markdown.parse(text).walker(); + let event; + while ((event = walker.next())) { + if (!event.entering) continue; + const node = event.node; + if (node.type === 'link' || node.type === 'image') refs.push([node.destination, false]); + if (node.type === 'code' && /ADR-\d+[^\n]*\.md(?:#[^\n]*)?$/.test(node.literal)) { + refs.push([node.literal, true]); + } + } + for (const [reference, rootRelative] of refs) { + const target = localTarget(file, reference, rootRelative); + if (!target || (!allLocalLinks && (!changed.has(target) || (!ADR_PATH.test(target) && !mappedArtifacts.has(target))))) continue; + if (!state.files.has(target)) fail('unavailable', `broken local reference in ${file}: ${reference} -> ${target}`); + if (ADR_PATH.test(target) && !state.read(target).trim()) fail('invalid', `empty ADR target in ${file}: ${target}`); + } +} + +function main() { + if (process.argv.length !== 4 || process.argv[2] !== '--map') fail('unavailable', 'invoke with --map '); + const mapPath = repositoryPath(process.argv[3], 'map'); + const env = process.env; + for (const retired of ['TRACEABILITY_SOURCE_ROOTS', 'TRACEABILITY_HOST_FACING_DIRS', 'TRACEABILITY_STRUCTURED_PRODUCER_DIRS', 'ADR_DIR']) { + if (env[retired] !== undefined) fail('invalid', `${retired} is retired; use the reviewed decision-source map`); + } + if (env.TRACEABILITY_STAGED_ONLY !== undefined && env.TRACEABILITY_STAGED_ONLY !== '1') { + fail('invalid', 'TRACEABILITY_STAGED_ONLY must be 1 when supplied'); + } + const mode = env.TRACEABILITY_STAGED_ONLY === '1' ? 'staged' : env.TRACEABILITY_MODE; + if (!['staged', 'range'].includes(mode)) fail('unavailable', 'select TRACEABILITY_STAGED_ONLY=1 or TRACEABILITY_MODE=range with explicit base/head refs'); + if (env.TRACEABILITY_STAGED_ONLY === '1' && env.TRACEABILITY_MODE && env.TRACEABILITY_MODE !== 'staged') { + fail('invalid', 'contradictory traceability modes'); + } + if (mode === 'staged' && (env.TRACEABILITY_BASE_REF || env.TRACEABILITY_HEAD_REF)) fail('invalid', 'staged mode cannot use range refs'); + const root = git('rev-parse', '--show-toplevel').trim(); + process.chdir(root); + const baseRev = mode === 'staged' ? revision('HEAD', 'HEAD') : revision(env.TRACEABILITY_BASE_REF, 'TRACEABILITY_BASE_REF'); + const headRev = mode === 'staged' ? git('write-tree').trim() : revision(env.TRACEABILITY_HEAD_REF, 'TRACEABILITY_HEAD_REF'); + console.log(`Decision traceability input: ${mode} ${baseRev} -> ${headRev}; map ${mapPath}`); + const base = snapshot(baseRev); + const head = snapshot(headRev); + const changed = changedPaths(baseRev, headRev); + const current = readMap(head, mapPath); + let prior; + if (!base.files.has(mapPath) && baseRev === LEGACY_BASE && mapPath === PROJECT_MAP) { + // The audited legacy tree had no impact map. Validate current decisions at + // the prior snapshot too; only the map's own addition is new at adoption. + prior = new Map(); + for (const [id, row] of current) { + if (row.triggers.includes(mapPath)) continue; + base.read(row.artifact); + for (const trigger of row.triggers) base.read(trigger); + prior.set(id, row); + } + console.log(`Explicit legacy-map adoption from ${LEGACY_BASE}`); + } else { + prior = readMap(base, mapPath); + } + const canonicalMap = rows => JSON.stringify([...rows].sort(([a], [b]) => a.localeCompare(b))); + const mapChanged = canonicalMap(prior) !== canonicalMap(current); + const affected = new Set(); + for (const [id, row] of [...prior, ...current]) { + if (row.triggers.some(trigger => trigger === mapPath ? mapChanged : changed.has(trigger)) || + JSON.stringify(prior.get(id)) !== JSON.stringify(current.get(id))) affected.add(id); + } + for (const file of changed) { + if (ADR_PATH.test(file) && ![...prior.values(), ...current.values()].some(row => row.triggers.includes(file))) { + fail('unavailable', `changed ADR has no declared impact mapping: ${file}`); + } + } + for (const id of affected) { + const old = prior.get(id); + const next = current.get(id); + for (const row of [old, next].filter(Boolean)) { + // A moved owner may be removed only when its replacement exists in the + // current map. A retained old owner must explain retirement/migration. + const movedAndRemoved = row === old && next && old.artifact !== next.artifact && !head.files.has(old.artifact); + if (!movedAndRemoved) head.read(row.artifact); + if (!changed.has(row.artifact)) fail('unavailable', `boundary ${id} requires an update to ${row.artifact} (${row.knowledge}); unrelated documents cannot satisfy it`); + } + } + const owners = new Set([...current.values()].map(row => row.artifact)); + const mappedArtifacts = new Set([...prior.values(), ...current.values()].map(row => row.artifact)); + for (const [file, entry] of head.files) { + if (!file.endsWith('.md') || entry.type !== 'blob') continue; + // Preserve readers of changed decisions and prior/current canonical guides. + // Other references are checked in owners and changed documents; untouched + // historical targets are not promoted into current documentation authority. + documentReferences(head, file, owners.has(file) || changed.has(file), changed, mappedArtifacts); + } + console.log(`Decision traceability passed: ${changed.size} changed path(s), ${affected.size} mapped impact(s). Semantic contract review remains required.`); +} + +try { main(); } +catch (error) { + console.error(`Decision traceability failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/check-decision-traceability.sh b/scripts/check-decision-traceability.sh index 1da750570..3a6effa51 100755 --- a/scripts/check-decision-traceability.sh +++ b/scripts/check-decision-traceability.sh @@ -1,271 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -SOURCE_ROOTS="${TRACEABILITY_SOURCE_ROOTS:-src,src-tauri/src,crates,packages/svelte-graph/src,scripts,.pantograph}" -ADR_DIR="${ADR_DIR:-docs/adr}" -BASE_REF="${TRACEABILITY_BASE_REF:-origin/main}" -STAGED_ONLY="${TRACEABILITY_STAGED_ONLY:-0}" -HOST_FACING_DIRS="${TRACEABILITY_HOST_FACING_DIRS:-src,src-tauri/src/workflow,src-tauri/src/llm/commands,src-tauri/src/llm/commands/registry,crates/pantograph-workflow-service,crates/pantograph-workflow-service/src,crates/pantograph-workflow-service/src/workflow,crates/pantograph-workflow-service/tests,crates/pantograph-workflow-service/examples,crates/pantograph-embedded-runtime,crates/pantograph-uniffi,crates/pantograph-uniffi/src,crates/pantograph-uniffi/src/bin,crates/pantograph-rustler,crates/pantograph-rustler/src,crates/pantograph-frontend-http-adapter,crates/inference/torch,crates/inference/depth,crates/inference/audio}" -STRUCTURED_PRODUCER_DIRS="${TRACEABILITY_STRUCTURED_PRODUCER_DIRS:-src,src/generated,src-tauri/src/workflow,crates/pantograph-workflow-service,crates/pantograph-workflow-service/src,crates/pantograph-workflow-service/src/workflow,crates/pantograph-workflow-service/tests,crates/pantograph-embedded-runtime,crates/pantograph-uniffi,crates/pantograph-uniffi/src,crates/pantograph-uniffi/src/bin,crates/pantograph-rustler,crates/pantograph-rustler/src,crates/inference/torch,crates/inference/depth,crates/inference/audio,crates/inference/src/managed_runtime,crates/inference/src/managed_runtime/llama_cpp_platform,.pantograph,.pantograph/workflows,.pantograph/orchestrations}" - -required_headers=( - "## Purpose" - "## Contents" - "## Problem" - "## Constraints" - "## Decision" - "## Alternatives Rejected" - "## Invariants" - "## Revisit Triggers" - "## Dependencies" - "## Related ADRs" - "## Usage Examples" -) - -host_facing_headers=("## API Consumer Contract") -structured_producer_headers=("## Structured Producer Contract") - -banned_placeholders=( - "Source file used by modules in this directory." - "Subdirectory containing related implementation details." - "Keep files in this directory scoped to a single responsibility boundary." - "import { value } from './module';" -) - -have_rg=false -if command -v rg >/dev/null 2>&1; then - have_rg=true -fi - -contains_fixed_exact_line() { - local pattern="$1" - local file="$2" - - if [ "$have_rg" = true ]; then - rg -F -x -q "$pattern" "$file" - else - grep -F -x -q -- "$pattern" "$file" - fi -} - -contains_fixed_text() { - local pattern="$1" - local file="$2" - - if [ "$have_rg" = true ]; then - rg -F -q "$pattern" "$file" - else - grep -F -q -- "$pattern" "$file" - fi -} - -stdin_matches_regex() { - local pattern="$1" - - if [ "$have_rg" = true ]; then - rg -q "$pattern" - else - grep -E -q -- "$pattern" - fi -} - -stdin_matches_regex_i() { - local pattern="$1" - - if [ "$have_rg" = true ]; then - rg -i -q "$pattern" - else - grep -E -i -q -- "$pattern" - fi -} - -trim_dir() { - local dir_path="$1" - dir_path="${dir_path#./}" - dir_path="${dir_path%/}" - printf '%s\n' "$dir_path" -} - -csv_contains_dir() { - local needle - local haystack="$2" - local item - needle="$(trim_dir "$1")" - - [ -n "$haystack" ] || return 1 - - IFS=',' read -ra items <<< "$haystack" - for item in "${items[@]}"; do - if [ "$(trim_dir "$item")" = "$needle" ]; then - return 0 - fi - done - - return 1 -} - -extract_section_body() { - local header="$1" - local file="$2" - - awk -v header="$header" ' - $0 == header { in_section = 1; next } - in_section && /^## / { exit } - in_section { print } - ' "$file" -} - -file_in_source_root() { - local file="$1" - local root - - IFS=',' read -ra roots <<< "$SOURCE_ROOTS" - for root in "${roots[@]}"; do - root="$(trim_dir "$root")" - if [ "$file" = "$root" ] || [[ "$file" == "$root/"* ]]; then - return 0 - fi - done - - return 1 -} - -resolve_diff_range() { - if git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then - printf '%s...HEAD\n' "$BASE_REF" - elif git rev-parse --verify "origin/master" >/dev/null 2>&1; then - printf 'origin/master...HEAD\n' - elif git rev-parse --verify "main" >/dev/null 2>&1; then - printf 'main...HEAD\n' - elif git rev-parse --verify "master" >/dev/null 2>&1; then - printf 'master...HEAD\n' - elif git rev-parse --verify "HEAD~1" >/dev/null 2>&1; then - printf 'HEAD~1...HEAD\n' - else - return 1 - fi -} - -changed_files_for_mode() { - local diff_range - - if [ "$STAGED_ONLY" = "1" ]; then - git diff --cached --name-only --diff-filter=ACMR - return 0 - fi - - diff_range="$(resolve_diff_range)" || return 1 - git diff --name-only --diff-filter=ACMR "$diff_range" -} - -if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "Not inside a git repository." - exit 1 -fi - -if ! mapfile -t changed_files < <(changed_files_for_mode); then - echo "Skipping decision traceability check: unable to resolve changed files." - exit 0 -fi - -if [ "${#changed_files[@]}" -eq 0 ]; then - echo "No changed files detected for decision traceability check." - exit 0 -fi - -declare -A changed_lookup=() -for file in "${changed_files[@]}"; do - changed_lookup["$file"]=1 -done - -adr_changed=false -for file in "${changed_files[@]}"; do - if [[ "$file" == "$ADR_DIR/"*.md ]]; then - adr_changed=true - break - fi -done - -declare -A changed_dirs=() -for file in "${changed_files[@]}"; do - if file_in_source_root "$file"; then - changed_dirs["$(dirname "$file")"]=1 - fi -done - -if [ "${#changed_dirs[@]}" -eq 0 ]; then - echo "No source-directory changes detected for decision traceability check." - exit 0 -fi - -failures=0 -for module_dir in "${!changed_dirs[@]}"; do - readme_path="$module_dir/README.md" - required_headers_for_dir=("${required_headers[@]}") - - if csv_contains_dir "$module_dir" "$HOST_FACING_DIRS"; then - required_headers_for_dir+=("${host_facing_headers[@]}") - fi - if csv_contains_dir "$module_dir" "$STRUCTURED_PRODUCER_DIRS"; then - required_headers_for_dir+=("${structured_producer_headers[@]}") - fi - - if [ ! -f "$readme_path" ]; then - echo "Missing README.md for changed directory: $module_dir" - failures=$((failures + 1)) - continue - fi - - missing_header=false - for header in "${required_headers_for_dir[@]}"; do - if ! contains_fixed_exact_line "$header" "$readme_path"; then - echo "Missing required heading in $readme_path: $header" - missing_header=true - fi - done - if [ "$missing_header" = true ]; then - failures=$((failures + 1)) - fi - - none_format_invalid=false - for header in "${required_headers_for_dir[@]}"; do - section_body="$(extract_section_body "$header" "$readme_path")" - if printf '%s\n' "$section_body" | stdin_matches_regex_i '(^|[^[:alnum:]_])none([^[:alnum:]_]|$)'; then - if ! printf '%s\n' "$section_body" | stdin_matches_regex_i 'reason:'; then - echo "Section with None is missing Reason in $readme_path: $header" - none_format_invalid=true - fi - if ! printf '%s\n' "$section_body" | stdin_matches_regex_i 'revisit trigger:'; then - echo "Section with None is missing Revisit trigger in $readme_path: $header" - none_format_invalid=true - fi - fi - done - if [ "$none_format_invalid" = true ]; then - failures=$((failures + 1)) - fi - - placeholder_found=false - for phrase in "${banned_placeholders[@]}"; do - if contains_fixed_text "$phrase" "$readme_path"; then - echo "Banned placeholder phrase in $readme_path: $phrase" - placeholder_found=true - fi - done - if [ "$placeholder_found" = true ]; then - failures=$((failures + 1)) - fi - - if [ -z "${changed_lookup["$readme_path"]+set}" ] && [ "$adr_changed" = false ]; then - echo "Changed directory without decision traceability update: $module_dir" - echo "Update $readme_path or add/update an ADR under $ADR_DIR/." - failures=$((failures + 1)) - fi -done - -if [ "$failures" -gt 0 ]; then - echo "Decision traceability check failed ($failures issue(s))." - exit 1 -fi - -echo "Decision traceability check passed." +# The repository selects its map here; the caller must select the change input. +exec node "$(dirname "${BASH_SOURCE[0]}")/check-decision-traceability.mjs" \ + --map scripts/decision-traceability-map.json diff --git a/scripts/check-decision-traceability.test.mjs b/scripts/check-decision-traceability.test.mjs new file mode 100644 index 000000000..104f91296 --- /dev/null +++ b/scripts/check-decision-traceability.test.mjs @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const checker = fileURLToPath(new URL('./check-decision-traceability.sh', import.meta.url)); +const mapPath = 'scripts/decision-traceability-map.json'; +const decision = 'docs/adr/ADR-001-example.md'; +const guide = 'docs/consumer.md'; +const realGit = spawnSync('which', ['git'], { encoding: 'utf8' }).stdout.trim(); + +function fixture(t, options = {}) { + const root = mkdtempSync(path.join(tmpdir(), 'pantograph-traceability-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + function git(...args) { + const result = spawnSync(realGit, args, { + cwd: root, encoding: 'utf8', + // Synthetic identities belong only to disposable test commits. + env: { ...process.env, GIT_AUTHOR_NAME: 'Test Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid', GIT_COMMITTER_NAME: 'Test Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid' }, + }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + } + function write(file, text) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + writeFileSync(path.join(root, file), text); + } + function map(rows = []) { + return { + version: 1, + boundaries: [ + { id: 'consumer', triggers: [decision], artifact: guide, profile: 'contract-readme', knowledge: 'Consumer lifecycle' }, + { id: 'gate', triggers: [mapPath], artifact: 'scripts/README.md', profile: 'boundary-readme', knowledge: 'Coverage of the gate' }, + ...rows, + ], + }; + } + function writeMap(value = map()) { write(mapPath, `${JSON.stringify(value, null, 2)}\n`); } + function commit() { + git('add', '-A'); + const tree = git('write-tree'); + const previous = spawnSync(realGit, ['rev-parse', '--verify', 'HEAD'], { cwd: root, encoding: 'utf8' }); + const sha = git('commit-tree', tree, ...(previous.status === 0 ? ['-p', previous.stdout.trim()] : []), '-m', 'test: fixture snapshot'); + git('update-ref', 'HEAD', sha); + return sha; + } + function run(env = {}) { + const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('TRACEABILITY_') && key !== 'ADR_DIR')); + const result = spawnSync('bash', [checker], { cwd: root, encoding: 'utf8', env: { ...cleanEnv, TRACEABILITY_STAGED_ONLY: '1', ...env } }); + return { status: result.status, output: result.stdout + result.stderr }; + } + git('init', '-q'); + write('src/internal.rs', 'fn internal() {}\n'); + write(decision, '# Decision\n\nThe service owns the consumer lifecycle.\n'); + write(guide, '# Consumer\n\n[Decision](adr/ADR-001-example.md)\n'); + write('scripts/README.md', '# Gate\n\nCurrent impact coverage.\n'); + if (!options.withoutMap) writeMap(); + const base = commit(); + return { root, git, write, map, writeMap, commit, run, base }; +} + +function passed(result) { assert.equal(result.status, 0, result.output); assert.match(result.output, /Decision traceability passed:/); } +function failed(result, diagnostic) { assert.equal(result.status, 1, result.output); assert.match(result.output, diagnostic); assert.doesNotMatch(result.output, /Decision traceability passed:/); } + +test('unchanged contracts allow code-only changes, nested paths, spaces and newlines without README churn', t => { + const f = fixture(t); + f.write('src/new owner/nested\nmodule.rs', 'fn implementation() {}\n'); + f.git('add', '-A'); + passed(f.run()); +}); + +test('explicit valid input with no changes passes only after validating the map', t => { + const f = fixture(t); + passed(f.run()); + f.writeMap({ version: 1, boundaries: [] }); + f.git('add', mapPath); + failed(f.run(), /invalid: .*nonempty boundaries/); +}); + +test('a changed decision requires its canonical guide; an unrelated README or ADR does not count', t => { + const f = fixture(t); + f.write(decision, '# Decision\n\nConsumer shutdown must be awaited.\n'); + f.write('README.md', '# Unrelated documentation\n'); + f.git('add', '-A'); + failed(f.run(), /unavailable: boundary consumer requires an update to docs\/consumer.md/); + f.write(guide, '# Consumer\n\nAwait shutdown. [Decision](adr/ADR-001-example.md)\n'); + f.git('add', guide); + passed(f.run()); +}); + +test('staged mode reads guide and map from index, never unstaged repairs', t => { + const f = fixture(t); + f.write(decision, '# Changed decision\n'); + f.git('add', decision); + f.write(guide, '# Unstaged guide repair\n'); + failed(f.run(), /boundary consumer requires an update/); + f.git('add', guide); + f.write(guide, '[Unstaged broken link](missing.md)\n'); + f.writeMap({ invalid: true }); + passed(f.run()); +}); + +test('explicit range reads exact commits even when worktree and index differ', t => { + const f = fixture(t); + f.write('src/internal.rs', 'fn repaired() {}\n'); + const head = f.commit(); + f.writeMap({ invalid: true }); + f.git('add', mapPath); + passed(f.run({ TRACEABILITY_STAGED_ONLY: undefined, TRACEABILITY_MODE: 'range', TRACEABILITY_BASE_REF: f.base, TRACEABILITY_HEAD_REF: head })); +}); + +test('missing modes, revisions, invalid refs and contradictory modes fail clearly', t => { + const f = fixture(t); + failed(f.run({ TRACEABILITY_STAGED_ONLY: undefined }), /unavailable: select TRACEABILITY/); + failed(f.run({ TRACEABILITY_STAGED_ONLY: undefined, TRACEABILITY_MODE: 'range', TRACEABILITY_BASE_REF: f.base }), /unavailable: TRACEABILITY_HEAD_REF/); + failed(f.run({ TRACEABILITY_STAGED_ONLY: undefined, TRACEABILITY_MODE: 'range', TRACEABILITY_BASE_REF: 'missing-base', TRACEABILITY_HEAD_REF: 'HEAD' }), /unsupported: cannot read Git input \(rev-parse\)/); + failed(f.run({ TRACEABILITY_MODE: 'range' }), /invalid: contradictory traceability modes/); + failed(f.run({ TRACEABILITY_BASE_REF: f.base }), /invalid: staged mode cannot use range refs/); + failed(f.run({ TRACEABILITY_SOURCE_ROOTS: 'unrelated' }), /invalid: TRACEABILITY_SOURCE_ROOTS is retired/); +}); + +test('missing current or arbitrary prior map never silently succeeds', t => { + const f = fixture(t, { withoutMap: true }); + failed(f.run(), /unavailable: scripts\/decision-traceability-map.json is missing/); + f.writeMap(); + f.write('scripts/README.md', '# Gate adoption\n'); + f.git('add', '-A'); + failed(f.run(), new RegExp(`unavailable: scripts/decision-traceability-map.json is missing at ${f.base}`)); +}); + +test('missing canonical artifact and broken local ADR or ordinary local links fail', t => { + const f = fixture(t); + f.git('rm', guide); + failed(f.run(), /unavailable: docs\/consumer.md is missing/); + f.write(guide, '# Consumer\n\n[Decision](adr/ADR-099-missing.md)\n'); + f.git('add', guide); + failed(f.run(), /broken local reference in docs\/consumer.md: adr\/ADR-099-missing.md/); + f.write(guide, '# Consumer\n\n[Instructions](missing.md)\n'); + f.git('add', guide); + failed(f.run(), /broken local reference in docs\/consumer.md: missing.md/); +}); + +test('root-relative code-span ADR references are checked; external URLs are not fetched', t => { + const f = fixture(t); + f.write('docs/review.md', '# Review\n\n`docs/adr/ADR-099-missing.md`\n'); + f.git('add', '-A'); + failed(f.run(), /broken local reference in docs\/review.md: docs\/adr\/ADR-099-missing.md/); + f.write('docs/review.md', '# Review\n\n[External](https://example.invalid/ADR-099-remote.md)\n'); + f.git('add', '-A'); + passed(f.run()); +}); + +test('an unmapped added or changed ADR is unresolved, even with unrelated guide edits', t => { + const f = fixture(t); + f.write('docs/adr/ADR-002-new.md', '# New durable decision\n'); + f.write(guide, '# Updated consumer\n'); + f.git('add', '-A'); + failed(f.run(), /unavailable: changed ADR has no declared impact mapping: docs\/adr\/ADR-002-new.md/); +}); + +test('deleting a map row cannot hide the prior obligation', t => { + const f = fixture(t); + const next = f.map(); + next.boundaries = next.boundaries.filter(row => row.id !== 'consumer'); + f.writeMap(next); + f.write('scripts/README.md', '# Removed consumer map row\n'); + f.git('add', '-A'); + failed(f.run(), /boundary consumer requires an update to docs\/consumer.md/); +}); + +test('renaming a mapped ADR requires its guide and cannot leave an unchanged reader dangling', t => { + const f = fixture(t); + f.write('docs/reader.md', '# Reader\n\n[Decision](adr/ADR-001-example.md)\n'); + f.commit(); + const moved = 'docs/adr/ADR-003-renamed.md'; + f.git('mv', decision, moved); + const next = f.map(); + next.boundaries[0].triggers = [moved]; + f.writeMap(next); + f.write('scripts/README.md', '# Gate\n\nDecision path migrated.\n'); + f.git('add', '-A'); + failed(f.run(), /boundary consumer requires an update/); + f.write(guide, '# Consumer\n\n[Decision](adr/ADR-003-renamed.md)\n'); + f.git('add', guide); + failed(f.run(), /broken local reference in docs\/reader.md/); + f.write('docs/reader.md', '# Reader\n\n[Decision](adr/ADR-003-renamed.md)\n'); + f.git('add', '-A'); + passed(f.run()); +}); + +test('moving the canonical guide cannot leave unchanged readers dangling', t => { + const f = fixture(t); + f.write('docs/reader.md', '# Reader\n\n[Consumer](consumer.md)\n'); + f.commit(); + f.git('mv', guide, 'docs/new consumer.md'); + const next = f.map(); + next.boundaries[0].artifact = 'docs/new consumer.md'; + f.writeMap(next); + f.write('scripts/README.md', '# Gate\n\nConsumer guide moved.\n'); + f.git('add', '-A'); + failed(f.run(), /broken local reference in docs\/reader.md: consumer.md/); + f.write('docs/reader.md', '# Reader\n\n[Consumer]()\n'); + f.git('add', 'docs/reader.md'); + passed(f.run()); +}); + +test('contradictory trigger ownership, malformed maps and nonregular owners fail', t => { + const f = fixture(t); + const next = f.map(); + next.boundaries.push({ ...next.boundaries[0], id: 'duplicate' }); + f.writeMap(next); + f.git('add', '-A'); + failed(f.run(), /invalid: contradictory trigger ownership/); + f.write(mapPath, '{ invalid JSON'); + f.git('add', mapPath); + failed(f.run(), /invalid: cannot parse/); + f.writeMap(); + f.git('add', mapPath); + f.git('update-index', '--cacheinfo', `120000,${f.git('rev-parse', `HEAD:${guide}`)},${guide}`); + failed(f.run(), /unsupported: docs\/consumer.md must be a regular tracked file/); +}); + +for (const [name, body, expected] of [ + ['unreadable', 'echo "diff read denied" >&2; exit 42', /unsupported: cannot read Git input \(diff\): diff read denied/], + ['unterminated', "printf 'M\\0src/internal.rs'", /invalid: malformed Git diff: missing NUL terminator/], + ['malformed', "printf 'M\\0'", /invalid: malformed Git diff: incomplete status\/path pair/], + ['unsupported status', "printf 'U\\0src/internal.rs\\0'", /invalid: malformed Git diff: unsupported status\/path pair/], +]) { + test(`${name} Git diff fails instead of reporting no changed files`, t => { + const f = fixture(t); + const bin = path.join(f.root, 'test-bin'); + mkdirSync(bin); + writeFileSync(path.join(bin, 'git'), `#!/usr/bin/env bash\nif [ "$1" = diff ]; then ${body}; fi\nexec "${realGit}" "$@"\n`, { mode: 0o755 }); + failed(f.run({ PATH: `${bin}:${process.env.PATH}` }), expected); + }); +} + +test('untouched historical missing links do not expand the gate beyond affected authority', t => { + const f = fixture(t); + f.write('docs/old-plan.md', '# Historical proposal\n\n`docs/adr/ADR-099-not-adopted.md`\n'); + f.commit(); + f.write('src/internal.rs', 'fn repaired() {}\n'); + f.git('add', '-A'); + passed(f.run()); +}); + +test('a deleted mapped decision still requires its guide after removing the trigger row', t => { + const f = fixture(t); + f.git('rm', decision); + const next = f.map(); + next.boundaries = next.boundaries.filter(row => row.id !== 'consumer'); + f.writeMap(next); + f.write('scripts/README.md', '# Gate\n\nDecision retired.\n'); + f.git('add', '-A'); + failed(f.run(), /boundary consumer requires an update/); + f.write(guide, '# Consumer\n\nDecision retired; no remaining lifecycle promise.\n'); + f.git('add', guide); + passed(f.run()); +}); + +test('Markdown paths are document-relative and support encoded or angle-bracket spaces', t => { + const f = fixture(t); + f.write('docs/extra guide.md', '# Extra guide\n'); + f.write(guide, '# Consumer\n\n[Extra]() and [Encoded](extra%20guide.md)\n'); + f.git('add', '-A'); + passed(f.run()); + f.write(guide, '# Consumer\n\n[Broken]()\n'); + f.git('add', guide); + failed(f.run(), /broken local reference in docs\/consumer.md: missing(?:%20| )guide.md/); + f.write(guide, '# Consumer\n\n[Relative](docs/adr/ADR-001-example.md)\n'); + f.git('add', guide); + failed(f.run(), /docs\/docs\/adr\/ADR-001-example.md/); +}); + +test('CommonMark destinations, references and directory links resolve without parsing examples', t => { + const f = fixture(t); + f.write('docs/consumer(v2).md', '# Second consumer\n'); + f.write('docs/examples/README.md', '# Examples\n'); + f.write(guide, '# Consumer\n\n[Balanced](consumer(v2).md)\n[Directory](examples/)\n[Reference][next]\n\n[next]: consumer(v2).md\n\n```markdown\n[Example](missing.md)\n`docs/adr/ADR-999-example.md`\n```\n\n [Indented](missing.md)\n'); + f.git('add', '-A'); + passed(f.run()); + f.write(guide, '# Consumer\n\n[Missing directory](missing/)\n'); + f.git('add', guide); + failed(f.run(), /broken local reference.*missing\//); +}); + +test('map object and boundary ordering do not change declared knowledge', t => { + const f = fixture(t); + const next = f.map(); + next.boundaries = next.boundaries.reverse().map(row => Object.fromEntries(Object.entries(row).reverse())); + f.writeMap(next); + f.git('add', mapPath); + passed(f.run()); +}); + +test('changing the canonical owner requires disposition of the retained prior guide', t => { + const f = fixture(t); + const next = f.map(); + next.boundaries[0].artifact = 'docs/new-consumer.md'; + f.writeMap(next); + f.write('docs/new-consumer.md', '# Consumer\n\nUpdated lifecycle owner.\n'); + f.write('scripts/README.md', '# Gate\n\nConsumer ownership migrated.\n'); + f.write(decision, '# Decision\n\nNew consumer lifecycle.\n'); + f.git('add', '-A'); + failed(f.run(), /boundary consumer requires an update to docs\/consumer.md/); + f.write(guide, '# Consumer moved\n\nSee [current owner](new-consumer.md).\n'); + f.git('add', guide); + passed(f.run()); +}); + +test('PR callers select the fork point explicitly without changing exact range semantics', t => { + const f = fixture(t); + f.write('src/internal.rs', 'fn pr_repair() {}\n'); + const head = f.commit(); + f.git('checkout', '-b', 'target', f.base); + const next = f.map([{ id: 'target-only', triggers: ['docs/adr/ADR-002-target.md'], artifact: 'docs/target-guide.md', profile: 'contract-readme', knowledge: 'Target-only contract' }]); + f.writeMap(next); + f.write('docs/adr/ADR-002-target.md', '# Target decision\n'); + f.write('docs/target-guide.md', '# Target owner\n'); + f.write('scripts/README.md', '# Gate\n\nTarget-only contract added.\n'); + const target = f.commit(); + const env = { TRACEABILITY_STAGED_ONLY: undefined, TRACEABILITY_MODE: 'range', TRACEABILITY_BASE_REF: target, TRACEABILITY_HEAD_REF: head }; + // Exact range is intentionally not silently changed by the checker. + failed(f.run(env), /unavailable: docs\/target-guide.md is missing/); + const base = f.git('merge-base', '--all', target, head); + assert.equal(base, f.base); + const result = f.run({ ...env, TRACEABILITY_BASE_REF: base }); + passed(result); + assert.match(result.output, /1 changed path\(s\), 0 mapped impact\(s\)/); +}); diff --git a/scripts/check-svelte-a11y.mjs b/scripts/check-svelte-a11y.mjs index e4537e80d..97b78cd1f 100755 --- a/scripts/check-svelte-a11y.mjs +++ b/scripts/check-svelte-a11y.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node import { readdir, readFile } from 'node:fs/promises'; import { extname, join, relative } from 'node:path'; +import { collectRoleButtonViolations } from './svelte-role-button-check.mjs'; const ROOT = process.cwd(); const SCAN_ROOTS = ['src', 'packages/svelte-graph/src']; const IGNORE_DIRS = new Set(['node_modules', 'dist', 'target', '.git', '.svelte-kit']); -const INTERACTIVE_SEMANTIC_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea']); async function listSvelteFiles(dir, out = []) { const entries = await readdir(dir, { withFileTypes: true }); @@ -65,44 +65,6 @@ function collectButtonViolations(text, relPath) { return violations; } -function collectRoleButtonViolations(text, relPath) { - const violations = []; - const roleRegex = /<([\w:-]+)\b(?=[^>]*\srole\s*=\s*["']button["'])[^>]*>/g; - let match; - while ((match = roleRegex.exec(text)) !== null) { - const tag = match[1]; - if (INTERACTIVE_SEMANTIC_TAGS.has(tag)) continue; - - const open = match[0]; - const line = lineForIndex(text, match.index); - if (!/\stabindex\s*=/.test(open)) { - violations.push({ - file: relPath, - line, - rule: 'role-button-tabindex', - message: 'Generic role="button" element must declare tabindex.', - }); - } - if (!/\son:keydown\s*=|\sonkeydown\s*=/.test(open)) { - violations.push({ - file: relPath, - line, - rule: 'role-button-keydown', - message: 'Generic role="button" element must handle keyboard activation.', - }); - } - if (!hasAccessibleName(open)) { - violations.push({ - file: relPath, - line, - rule: 'role-button-accessible-name', - message: 'Generic role="button" element must include an accessible name.', - }); - } - } - return violations; -} - function collectIgnoreViolations(text, relPath) { const violations = []; const lines = text.split('\n'); diff --git a/scripts/check-uniffi-csharp-smoke.sh b/scripts/check-uniffi-csharp-smoke.sh index 0ec573a19..51fe2cb9c 100755 --- a/scripts/check-uniffi-csharp-smoke.sh +++ b/scripts/check-uniffi-csharp-smoke.sh @@ -6,7 +6,7 @@ cd "$repo_root" if ! command -v uniffi-bindgen-cs >/dev/null 2>&1; then echo "Missing required generator: uniffi-bindgen-cs" >&2 - echo "Install a UniFFI 0.28-compatible C# generator, for example uniffi-bindgen-cs 0.9.x." >&2 + echo "See bindings/csharp/README.md for the pinned UniFFI 0.28-compatible generator install." >&2 exit 1 fi @@ -72,6 +72,8 @@ require_generated_text 'public record FfiEmbeddedRuntimeConfig' require_generated_text 'namespace uniffi.pantograph_headless;' reject_generated_text 'Task WorkflowRun(String @requestJson)' require_generated_text 'Task WorkflowCreateSession(String @requestJson)' +require_generated_text 'Task WorkflowGraphRefreshCurrentValidationSummary(String @requestJson)' +require_generated_text 'Task PublishGraphSessionExecutableValidationSnapshot(String @requestJson)' dotnet_root="$(dirname "$(readlink -f "$(command -v dotnet)")")" sdk_dir="$dotnet_root/sdk" diff --git a/scripts/check-uniffi-embedded-runtime-surface.sh b/scripts/check-uniffi-embedded-runtime-surface.sh index 0a27bbb3e..aa8823864 100755 --- a/scripts/check-uniffi-embedded-runtime-surface.sh +++ b/scripts/check-uniffi-embedded-runtime-surface.sh @@ -62,6 +62,8 @@ require_metadata 'name: "workflow_list_session_queue"' require_metadata 'name: "workflow_cancel_session_queue_item"' require_metadata 'name: "workflow_reprioritize_session_queue_item"' require_metadata 'name: "workflow_set_session_keep_alive"' +require_metadata 'name: "workflow_graph_refresh_current_validation_summary"' +require_metadata 'name: "publish_graph_session_executable_validation_snapshot"' require_metadata 'name: "workflow_graph_list_node_definitions"' require_metadata 'name: "workflow_graph_get_node_definition"' require_metadata 'name: "workflow_graph_get_node_definitions_by_category"' diff --git a/scripts/decision-traceability-map.json b/scripts/decision-traceability-map.json new file mode 100644 index 000000000..53d9ea868 --- /dev/null +++ b/scripts/decision-traceability-map.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "boundaries": [ + { + "id": "headless-execution", + "triggers": [ + "docs/adr/ADR-001-headless-embedding-service-boundary.md", + "docs/adr/ADR-011-scheduler-only-workflow-execution.md" + ], + "artifact": "docs/headless-workflow.md", + "profile": "contract-readme", + "knowledge": "Host adapter ownership and the scheduler-session consumer lifecycle" + }, + { + "id": "runtime-operations", + "triggers": [ + "docs/adr/ADR-002-runtime-registry-ownership-and-lifecycle.md", + "docs/adr/ADR-003-runtime-redistributables-manager-boundary.md", + "docs/adr/ADR-007-managed-runtime-observability-ownership.md" + ], + "artifact": "docs/runtime-operations.md", + "profile": "runbook", + "knowledge": "Backend runtime lifecycle, readiness, observability, and recovery ownership" + }, + { + "id": "node-contract-ownership", + "triggers": [ + "docs/adr/ADR-006-canonical-node-contract-ownership.md", + "docs/adr/ADR-009-composed-node-contracts-and-migration.md" + ], + "artifact": "ARCHITECTURE.md", + "profile": "boundary-readme", + "knowledge": "Canonical primitive/composed node contracts and migration responsibility" + }, + { + "id": "rust-workspace-policy", + "triggers": ["docs/adr/ADR-017-rust-workspace-policy.md"], + "artifact": "docs/development.md", + "profile": "runbook", + "knowledge": "Contributor-facing workspace policy and verification procedure" + }, + { + "id": "traceability-policy", + "triggers": ["scripts/decision-traceability-map.json"], + "artifact": "scripts/README.md", + "profile": "boundary-readme", + "knowledge": "Declared impact coverage and operation of the traceability gate" + } + ] +} diff --git a/scripts/install-ubuntu-build-dependencies.sh b/scripts/install-ubuntu-build-dependencies.sh new file mode 100755 index 000000000..dfb5d3492 --- /dev/null +++ b/scripts/install-ubuntu-build-dependencies.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Shared prerequisites for the existing Ubuntu desktop-build CI consumers. +# This intentionally installs system packages; run only on the selected runner. +sudo apt-get update +sudo apt-get install -y \ + build-essential \ + libayatana-appindicator3-dev \ + libglib2.0-dev \ + libgtk-3-dev \ + libjavascriptcoregtk-4.1-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + pkg-config \ + protobuf-compiler diff --git a/scripts/package-uniffi-csharp-artifacts.sh b/scripts/package-uniffi-csharp-artifacts.sh index 006b6a592..0213f4c30 100755 --- a/scripts/package-uniffi-csharp-artifacts.sh +++ b/scripts/package-uniffi-csharp-artifacts.sh @@ -6,7 +6,7 @@ cd "$repo_root" if ! command -v uniffi-bindgen-cs >/dev/null 2>&1; then echo "Missing required generator: uniffi-bindgen-cs" >&2 - echo "Install a UniFFI 0.28-compatible C# generator, for example uniffi-bindgen-cs 0.9.x." >&2 + echo "See bindings/csharp/README.md for the pinned UniFFI 0.28-compatible generator install." >&2 exit 1 fi diff --git a/scripts/svelte-role-button-check.mjs b/scripts/svelte-role-button-check.mjs new file mode 100644 index 000000000..7b0e9fc17 --- /dev/null +++ b/scripts/svelte-role-button-check.mjs @@ -0,0 +1,57 @@ +import { parse } from 'svelte/compiler'; + +const INTERACTIVE_SEMANTIC_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea']); + +// A rendered text expression is name evidence, as in the existing button check. +// Attribute expressions (including event handlers) are never name evidence. +function hasRenderedName(node) { + if (!node || typeof node !== 'object') return false; + if (node.type === 'Text') return /[A-Za-z0-9]/.test(node.data); + if (node.type === 'ExpressionTag') return true; + if (node.type === 'Comment') return false; + if (node.type === 'RegularElement' && ['script', 'style'].includes(node.name)) return false; + return ['nodes', 'fragment', 'consequent', 'alternate', 'body', 'fallback'].some((key) => { + const value = node[key]; + return Array.isArray(value) ? value.some(hasRenderedName) : hasRenderedName(value); + }); +} + +/** Inspect parsed attributes so JavaScript arrows and quoted > cannot end a tag. */ +export function collectRoleButtonViolations(text, relPath) { + const ast = parse(text, { modern: true, filename: relPath }); + const violations = []; + + function visit(node) { + if (!node || typeof node !== 'object') return; + if (node.type === 'RegularElement' && !INTERACTIVE_SEMANTIC_TAGS.has(node.name)) { + const attributes = node.attributes; + const role = attributes.find((attribute) => attribute.type === 'Attribute' && attribute.name === 'role'); + const isButton = Array.isArray(role?.value) + && role.value.length === 1 + && role.value[0].type === 'Text' + && role.value[0].data === 'button'; + if (isButton) { + const hasAttribute = (name) => attributes.some((attribute) => attribute.type === 'Attribute' && attribute.name === name); + const hasKeydown = hasAttribute('onkeydown') || attributes.some((attribute) => attribute.type === 'OnDirective' && attribute.name === 'keydown'); + const line = text.slice(0, node.start).split('\n').length; + const add = (rule, message) => violations.push({ file: relPath, line, rule, message }); + if (!hasAttribute('tabindex')) { + add('role-button-tabindex', 'Generic role="button" element must declare tabindex.'); + } + if (!hasKeydown) { + add('role-button-keydown', 'Generic role="button" element must handle keyboard activation.'); + } + if (!hasAttribute('aria-label') && !hasAttribute('aria-labelledby') && !hasRenderedName(node.fragment)) { + add('role-button-accessible-name', 'Generic role="button" element must include an accessible name.'); + } + } + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + } + + visit(ast.fragment); + return violations; +} diff --git a/scripts/svelte-role-button-check.test.mjs b/scripts/svelte-role-button-check.test.mjs new file mode 100644 index 000000000..6b65e31cc --- /dev/null +++ b/scripts/svelte-role-button-check.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { collectRoleButtonViolations } from './svelte-role-button-check.mjs'; + +const check = (source) => collectRoleButtonViolations(source, 'Fixture.svelte'); +const rules = (source) => check(source).map((violation) => violation.rule); + +test('arrow callbacks do not hide later keyboard and name attributes', () => { + assert.deepEqual(check(' select()} onkeydown={(event) => activate(event)} aria-label="Select" />'), []); +}); + +test('quoted greater-than signs and legacy event directives retain complete attributes', () => { + assert.deepEqual(check('
select()} on:keydown={activate} aria-labelledby="label" />'), []); +}); + +test('missing keyboard activation remains a violation after an arrow callback', () => { + assert.deepEqual(rules(' select()} aria-label="Select" />'), ['role-button-keydown']); +}); + +test('all required attributes are checked independently with exact location', () => { + const violations = check('\n
'); + assert.deepEqual(violations.map(({ rule }) => rule), ['role-button-tabindex', 'role-button-keydown', 'role-button-accessible-name']); + assert.ok(violations.every(({ file, line }) => file === 'Fixture.svelte' && line === 2)); +}); + +test('comments and script strings are not rendered elements', () => { + assert.deepEqual(check(''), []); +}); + +test('nested control-flow elements are inspected and native controls remain exempt', () => { + assert.deepEqual(rules('{#if visible}
{/if}'), ['role-button-keydown']); +}); + +test('malformed Svelte fails closed instead of silently skipping checks', () => { + assert.throws(() => check('
} />')); +}); + + +test('visible descendant text supplies a name, but handler expressions do not', () => { + assert.deepEqual(check('
{label}
'), []); + assert.deepEqual(rules('
activate()}>
'), ['role-button-accessible-name']); +}); diff --git a/src/components/nodes/workflow/PumaLibNode.svelte b/src/components/nodes/workflow/PumaLibNode.svelte index 9d52b2382..1399b4783 100644 --- a/src/components/nodes/workflow/PumaLibNode.svelte +++ b/src/components/nodes/workflow/PumaLibNode.svelte @@ -31,17 +31,13 @@ let { id, data, selected = false }: Props = $props(); - let modelId = $state(undefined); + let modelId = $derived(typeof data.model_id === 'string' ? data.model_id : undefined); let availableModels: PortOption[] = $state([]); let isLoading = $state(false); let loadError = $state(null); let selectionError = $state(null); let searchQuery = $state(''); - $effect(() => { - modelId = typeof data.model_id === 'string' ? data.model_id : undefined; - }); - let filteredModels = $derived( searchQuery ? availableModels.filter((model) => {