diff --git a/packages/resource-explorer/docs/Backlog.md b/packages/resource-explorer/docs/Backlog.md index 286f6365f..bd7fdcb57 100644 --- a/packages/resource-explorer/docs/Backlog.md +++ b/packages/resource-explorer/docs/Backlog.md @@ -56,6 +56,24 @@ reconciliation changes one concrete thing in the inside-out plan's Phase C (fron moves from Analysis to a new Curate tab) β€” read both before implementing, not just one, or ask whoever picks this up next to re-derive current state rather than trust this paragraph's own age. +**Progress, 2026-09-03: Phases A, A.5, B, and now C are done.** Registry + `BlueprintMaterializer` +(`6a76e4b`); the live wire-safety measurement, confirmed `SolutionLinkingWire` is multi-link +(`86c214a`), with the project owner directly deciding to defer wire enqueueing to its own +follow-up rather than ship it with the confirmed risk (`7381782`); the blueprint verdict route +and member/child attachment via the outbox, scoped accordingly (`bce70ca`). **Phase C (frontend) +shipped at the reconciled placement** β€” a new `πŸ› Architecture Verdicts` sub-tab in Curate +(`_curateSubnavHtml`, mirroring `_automateSubnavHtml`), not Analysis's panel: candidate-blueprint +accept/reject with oversized-cluster and unmaterialized-member warnings, plus the existing +per-component verdict controls relocated there from Analysis, which is now genuinely read-only +(new `_archInteractiveMode` module flag gates the accept/reject/change affordances). Backend gap +closed alongside it: `_architecture_recovery_results` now carries `data.blueprints` via a new +`_candidate_blueprints_results` reader (10 unit tests). Live-verified against `sqlglot` through +the actual UI, not just the API β€” see item 6 below for a real, pre-existing materialization bug +found doing that verification. Full suite: 3652 passed, 92 skipped, 0 failed. **What's left: the +deferred wire-enqueueing follow-up**, whenever that's picked up. Backlog item 5 (below) tracks the +related `AnnotationReview` measurement gap a peer session surfaced reviewing Phase A.5, also not +yet done. + **2. `security_features` should report `skipped_by_design` β€” DONE, already on `main`.** Picked up 2026-09-03 and found already fully shipped, in two commits from before this Backlog entry was even opened: `a059e01` (2026-08-31, surveyor β€” emits a confidence-0 `skipped_by_design` annotation @@ -173,6 +191,76 @@ each doc's own heading list before touching anything, left all four alone. Dr.Eg survey-definition/outbox files and `archive/` were out of scope by the existing exclusion convention. +**5. `AnnotationReview` is an unmeasured relationship type queued for the outbox's create-blind +path β€” raised by a peer session, 2026-09-03, reviewing the Phase A.5 `SolutionLinkingWire` +measurement.** `egeria_outbox.py`'s own `_create_annotation_link` docstring already names it: +`AnnotationExtension` was measured live 2026-09-01 as UNI_LINK (safe to create-blind); the same +docstring says to re-measure before relying on that for *any other* relationship type, and names +`AnnotationReview` (Phase 3, not built) as one that has never been measured. Not fixed here β€” +nothing currently calls it, so there's no live bug β€” but it's the next candidate for exactly the +`scripts/arch-spike/measure_wire_multi_link.py` treatment (the return-value test, one call) +**before** anything in Phase 3 adds it to the outbox, not after. + +**6. `BlueprintMaterializer.materialize_blueprint_element` failed Egeria's request-body +validation on a real accept β€” found 2026-09-03 live-verifying Phase C above against `sqlglot` +through the actual UI. FIXED same day β€” root cause was a pyegeria gap, not an Egeria-side +rejection.** The `VALIDATION_ERROR_1` ("Request body failed validation") never reached the +network: `SolutionArchitect.create_solution_blueprint`'s own docstring documents a +`NewSolutionElementRequestBody` body (with `initialStatus: "DRAFT"`) for Draft-status creation, +but that class doesn't exist as a real pydantic model in pyegeria β€” confirmed by direct +inspection of both the installed version here (5.3.4.23) and the canonical egeria-python +checkout (6.1.9-dev; `create_solution_component` carries the identical, separately-confirmed +gap). The client validates every create-blueprint body against a bare +`TypeAdapter(NewElementRequestBody)`, whose `class` field is `Literal["NewElementRequestBody"]` +β€” any other value is a local pydantic `ValidationError` before any HTTP call, which +`BlueprintMaterializationError`'s message wording made read like a server-side rejection. +Logged as egeria-python's `PYEGERIA_ISSUES.md` ISSUE-84 (not patched there directly, per this +repo's policy β€” [[feedback_pyegeria_gaps_tracking]]); `egeria-python-65` messaged to review. +Fixed here by matching `ComponentMaterializer`'s existing, working body shape β€” `class: +"NewElementRequestBody"`, no `initialStatus` β€” so materialized blueprints are ACTIVE, not Draft, +same pre-existing gap `ComponentMaterializer` already has (its own follow-up, not new). Two new +regression tests added, including one that runs the actual body through pyegeria's real +`TypeAdapter(NewElementRequestBody)` rather than only asserting the mock-call shape β€” the kind +of test that would have caught this before it reached a live accept. + +**Follow-up, same day: full live materialization confirmed, and Draft-status achieved for real.** +The TLS-handshake timeout blocking live confirmation turned out to be `qs-metadata-store`'s +Postgres connection pool genuinely exhausted (`jdbcMaximumPoolSize` defaulting to 10, HikariPool +timeouts in the container logs) β€” not transient, and not caused by this work; fixed by +`egeria-workspaces-fs-14` bumping the pool to 50 and restarting `quickstart-egeria-main` +(coordinated first β€” see the session transcript for the cross-session exchange, including a +correction: an earlier "misattribution" claim about an unrelated wire-test coordination request +was wrong and retracted, the request was real but predated this session's own visible context +after a compaction). Once the platform came back: accepting the `.github` candidate blueprint +against `sqlglot` through the actual Curate UI's API materialized a real `SolutionBlueprint` +(`status: "partial"` β€” its two members correctly unmaterialized, per Decision 2's governance +boundary). Separately, `egeria-python-65` reviewed ISSUE-84 and found the real mechanism: +`contentStatus` is a plain field on `ReferenceableProperties`, settable inside `properties` on +the existing `NewElementRequestBody` β€” no separate request-body class was ever needed +(odpi/egeria-python#337). Confirmed live, twice, each independently read back by guid (not just +trusted from the create call's own return): a standalone probe (guid `1c6550a9-77cd-49ac-a791- +ffb9fa56f3fc`, superseded by a second probe `8755a5ac-7941-4880-a735-72010d779a73` to also check +the properties-block shape, both deleted after) confirmed `properties.contentStatus == "DRAFT"` +via `get_solution_blueprint_by_guid`; then a real accept through the actual Curate UI's own API +endpoint (`POST /api/curate/blueprint-verdicts/repo/sqlglot`, not a direct library call) +materialized guid `809025b5-cca9-4e9a-a2f7-3a5104138f67`, independently re-queried and confirmed +the same. Both checks: `properties.contentStatus` round-trips as `"DRAFT"` correctly; +`elementHeader.status` stays `"ACTIVE"` (a different, unrelated instance-status axis). +`BlueprintMaterializer` now sends `contentStatus: "DRAFT"` β€” architecture-recovery.md Β§10 Phase +2's "All at ContentStatus = Draft" is achieved for blueprints. `ComponentMaterializer`'s +identical gap (still `NewElementRequestBody` with no `contentStatus`) is NOT fixed β€” separate, +still-open follow-up. One new regression test (`test_content_status_draft`), 21/21 passing in +`test_blueprint_materializer.py`. + +**Also found and fixed in the same window, unrelated to Egeria**: a genuinely stale +`resource-explorer web` process (pid 74923/74921, running 20+ hours, holding no listening port β€” +invisible to `lsof -i :8810`/curl checks since only the current process had the port) β€” flagged +by a peer (`dwolfson-be`), confirmed via `ps`, force-killed after a plain `kill` failed to take. +Its background scheduler thread does start independently of the HTTP bind (confirmed by reading +`web/app.py`'s `_lifespan`), so this was briefly a real second outbox drainer β€” no damage found, +since every kind currently enqueued is convergent under double-processing and wires (the one +non-convergent kind) are deferred out of the outbox entirely. + **Deliberately closed, with a measurement behind each β€” do not reopen without re-measuring:** the LLM adjudicator (the doc lens reaches Milvus's real components more cheaply where documentation exists); milvus site ingestion (302-loops for every user agent including a diff --git a/packages/resource-explorer/docs/blueprint-materialization-plan.md b/packages/resource-explorer/docs/blueprint-materialization-plan.md index 656e9220c..2fb64a6cb 100644 --- a/packages/resource-explorer/docs/blueprint-materialization-plan.md +++ b/packages/resource-explorer/docs/blueprint-materialization-plan.md @@ -72,7 +72,7 @@ Rejected alternative: a wholly separate `architecture_blueprint_verdicts` table, - **Blueprint element itself**: `BlueprintMaterializer` mirrors `ComponentMaterializer`'s pattern exactly β€” local-cache check (`get_materialized_blueprint`) β†’ qualifiedName search (`_find_element_guid`, byte-identical copy, same reasoning `ComponentMaterializer._find_element_guid`'s own docstring gives for not sharing one) β†’ create. **Divergence from `ComponentMaterializer` #1**: `architecture-recovery.md` Β§10 Phase 2 says explicitly "**All at `ContentStatus = Draft`**" β€” so the blueprint create body uses **`class: "NewSolutionElementRequestBody"`**, not `NewElementRequestBody` (which pyegeria's own docstring says defaults to ACTIVE). `ComponentMaterializer` uses `NewElementRequestBody` today, i.e. it is NOT Draft β€” that's a pre-existing gap against the design doc this plan does not silently inherit; flagged as its own follow-up (see "Out of scope / follow-ups"), and `BlueprintMaterializer` gets this right from the start rather than copying the drift. - **Membership + wires must go through the outbox** (per Β§8.4: "a half-published blueprint is worse than none") rather than being written synchronously like `ComponentMaterializer` does for its single element, because a blueprint write is N+1+M elements (1 blueprint, N `CollectionMembership`, M `SolutionLinkingWire`) and a crash mid-write must leave a resumable, visible state, not a half-formed live blueprint with no record of what's missing. - **Enqueue order, mirroring `_link_evidence_outbox`'s two-phase shape**: `BlueprintMaterializer.materialize()` runs synchronously (find-or-create the blueprint element itself β€” same as `ComponentMaterializer`, since callers need the GUID back immediately to report success), *then* the route enqueues membership rows via a new `enqueue_blueprint_members(registry, entity_type, entity_slug, blueprint_guid, member_guids, *, run_id="")` (parallel to `enqueue_collection_members`, reusing `_create_collection_membership`'s **existing** `element_kind="collection_membership"` creator unchanged β€” `CollectionMembership` doesn't care whether the collection is an investigation's Collection or a `SolutionBlueprint`, it's still Collectionβ†’Referenceable). No `depends_on_id` needed for these, since the blueprint GUID is already known synchronously before enqueueing (unlike the annotation-link case, where both ends might still be in-flight). -- **Wires β€” the real open risk, stated plainly rather than hidden behind a false "same as membership" claim**: a new `element_kind="solution_linking_wire"` creator cannot safely reuse the create-blind pattern, because a crash between "Egeria created the wire" and "the outbox row is marked done" means a retry creates a **second, distinct** wire relationship β€” silently, since multi-link means no 409. Two options considered: +- **Wires β€” the real open risk, stated plainly rather than hidden behind a false "same as membership" claim**: a new `element_kind="solution_linking_wire"` creator cannot safely reuse the create-blind pattern, because a crash between "Egeria created the wire" and "the outbox row is marked done" means a retry creates a **second, distinct** wire relationship β€” silently, since multi-link means no 409. **Sharper framing, from a peer session's review after the Phase A.5 measurement**: this isn't "wires need their own idempotency layer" as an add-on β€” `apply_element`'s create-blind safety for every other kind in the outbox rests on `qualifiedName` search-then-adopt (why `qualified_name` is `NOT NULL` on the table), and **relationships have no qualifiedName**. Wires would be the first element kind the outbox's central idempotency invariant structurally cannot cover at all, not the first one that merely lacks a layer. Two options considered: - (a) Ship it anyway with create-blind semantics and accept the risk, same posture the codebase already takes toward `claim_due_outbox_elements`'s documented "two drainers is a real hazard for annotation links" β€” acceptable only if it's written down with the same honesty that docstring uses, not asserted safe. - (b) Do a pre-check before create β€” but there is no `get_solution_linking_wires`/`get_all_related_elements`-style read exposed for this relationship in the pyegeria surface checked (`solution_architect.py`), so a real pre-check would need a new pyegeria method, which is out of scope for an RE-only plan. **Chosen: (a), with the risk written into the outbox row's own payload and into this plan's Verification section as a required live measurement before the wire creator ships** β€” exactly the same discipline the outbox module already applied to `CollectionMembership`/`AnnotationExtension` (measure live, then document), which for wires has NOT yet been done. Phase A explicitly does not include wire enqueueing until that measurement exists (see Phased build order, Phase A.5). @@ -276,18 +276,78 @@ def add_blueprint_verdict(entity_type: str, slug: str, body: BlueprintVerdictCre - `blueprint_materializer.py`: `qualified_name_for`, `materialize_blueprint_element` (with a fake/mocked `SolutionArchitect`, same test shape the existing `ComponentMaterializer` tests use β€” check for that test file before writing new ones, to match its mocking style), `resolve_member_guids`, `resolve_child_blueprint_guids`. - Tests: idempotency (repeat `materialize_blueprint_element` call finds cached/existing), Draft-status body shape (`NewSolutionElementRequestBody`, not `NewElementRequestBody`), member resolution correctly reports unmet members without raising. -**Phase A.5 β€” the wire-safety measurement, gating whether wire enqueueing ships at all.** -- A one-off live-server script (in `scripts/arch-spike/`, the existing home for this kind of measurement) calling `link_solution_linking_wire` twice with identical arguments against a throwaway pair of components, reading back via whatever pyegeria exposes to confirm duplication. **If it duplicates** (expected, given the docstring): document the risk in `_create_solution_linking_wire`'s docstring as written above, and decide with the project owner whether wire enqueueing ships with that known gap in Phase B or is deferred to its own follow-up entry β€” this is a project-owner call, not a design call this plan makes silently. - -**Phase B β€” route.** -- `egeria_outbox.py`: `OutboxClients.solution_architect`, `_create_solution_linking_wire`, `enqueue_blueprint_members`, `enqueue_blueprint_wires`, `_default_clients()` update. -- `curate.py`: `BlueprintVerdictCreate`, `list_blueprint_verdicts`, `add_blueprint_verdict`, `_materialize_blueprint_if_accepted`. -- Tests: route-level, with `BlueprintMaterializer`/outbox mocked β€” verdict recorded regardless of Egeria reachability (same non-fatal-but-visible contract as the component route); partial-member-materialization case returns the right shape; child-blueprint nesting resolves correctly for a two-level cluster fixture (reuse `tests/test_arch_clustering.py`'s own fixtures rather than inventing new ones). - -**Phase C β€” frontend, plus the backend reader gap it depends on.** -- `_architecture_recovery_results` gains `data.blueprints` (new backend step, per above). -- `index.html`: `_renderCandidateBlueprints`, blueprint verdict controls, oversized-cluster styling, unmaterialized-member warning state. -- Manual verification against a real survey run with clusters (e.g. `egeria_workspaces_git`, which already has candidate blueprints computed) β€” confirm the rendered tree matches `detail.children`/`detail.parent` linkage correctly, and that accepting a leaf cluster before its members are individually accepted correctly blocks/warns rather than silently doing nothing. +**Phase A.5 β€” the wire-safety measurement, gating whether wire enqueueing ships at all. DONE, +2026-09-03 β€” confirmed multi-link, duplicates on repeat calls.** +- Ran `scripts/arch-spike/measure_wire_multi_link.py` against the live quickstart platform, + coordinated with all 8 live peer sessions first (asked before writing, confirmed the earlier + republish batch's outbox was fully drained β€” 0 `running` rows β€” and the platform itself + functionally settled, not mid-restart). Two identical `link_solution_linking_wire()` calls + between the same two throwaway `SolutionComponent`s returned two **different** relationship + GUIDs (`f4179125-...`, `8f18b426-...`) β€” the definitive test (memory note + `reference_egeria_dedup_and_link_patterns`: "if attach returns an identifier it is multi-link; + if it returns nothing it is uni-link"), not inferred from the docstring. **Confirms the + docstring's own claim** rather than contradicting it β€” still worth having measured, since the + two relationships this codebase already relies on (`ResourceList`, `CollectionMembership`) were + each independently measured uni-link on 2026-08-25 despite looking like they might not be. +- The confirmatory read-back (`get_component_related_elements`) found 0 relationships β€” not a + contradiction, a wrong instrument: that method's response only carries component/blueprint/ + supply-chain/actor GUIDs, no generic wire list. The return-value test alone was sufficient and + is the one to trust. A future read-back attempt should try `get_relationships_with_property_value` + instead, un-tried here. +- Both throwaway components deleted via `delete_solution_component(guid, cascade_delete=True)` β€” + not a by-ends `detach_solution_linking_wire`, which would have removed both wires in one call and + corrupted the evidence before it could be read back. +- **Decided, 2026-09-03, asked directly of the project owner: defer wire enqueueing to its own + follow-up.** Phase B (below) ships blueprint materialization and member/child attachment; it + does NOT include `enqueue_blueprint_wires`/`_create_solution_linking_wire`/ + `OutboxClients.solution_architect`. A materialized blueprint renders without its wire diagram + until wire enqueueing lands separately β€” either once a real pyegeria pre-check exists + (`get_relationships_with_property_value`, noted above as un-tried but the likely candidate), or + the risk is judged acceptable on its own, later. See "Out of scope / follow-ups" below for the + tracked entry. + +**Phase B β€” route. Wires excluded per the Phase A.5 decision above β€” scoped to blueprint + +membership/child attachment only. DONE, 2026-09-03 (`bce70ca`).** +- `egeria_outbox.py`: `enqueue_blueprint_members` β€” built as planned. `OutboxClients.solution_architect`/`_create_solution_linking_wire`/`enqueue_blueprint_wires` were NOT built β€” out of scope per the deferred-wires decision, not a gap in this phase. +- `curate.py`: `BlueprintVerdictCreate`, `list_blueprint_verdicts`, `add_blueprint_verdict`, `_materialize_blueprint_if_accepted`, plus two small helpers the plan's prose implied but didn't name as their own functions β€” `_slug_to_scope_map` (the identity-mismatch fix from the plan's own Context section, factored out rather than inlined) and `_find_candidate_blueprint` (the candidate_blueprint lookup by perspective/cluster_name). +- **A real gap found by the tests, not scoped in the plan**: the *existing* `list_component_verdicts` route never filtered to `verdict_target="component"` β€” `get_component_verdicts` returns both mixed and leaves filtering to the caller, and only the new blueprint route filtered its own side. Fixed with the mirroring filter; practically harmless (a blueprint verdict's key can't realistically collide with a real `scope_locator`) but the endpoint's name promises component verdicts specifically. +- Tests: 20 route-level tests (`tests/test_web.py::TestCurateBlueprintVerdictsRouter`) with `BlueprintMaterializer` mocked, plus 2 direct unit tests for `enqueue_blueprint_members` (`tests/test_egeria_outbox.py`). Full suite: 3615 passed, 92 skipped, 0 failed. + +**Phase C β€” frontend, plus the backend reader gap it depends on. DONE, 2026-09-03 β€” built at +the corrected placement (the amendment above), not the original inside-Analysis one.** +- `_architecture_recovery_results` gains `data.blueprints` β€” new `_candidate_blueprints_results` + reader in `repo_survey_definition_adapter.py`, reading `kind="architecture_blueprints"` + findings (disambiguated by `(perspective, cluster_name)`, latest survey wins) and merging on + verdict/materialization state via `curate.py`'s own `f"{perspective}::{cluster_name}"` key. + 10 unit tests (`tests/test_candidate_blueprints_reader.py`). +- `index.html`: new Curate sub-tab (`πŸ› Architecture Verdicts`, repo-only β€” `_curateSubnavHtml`/ + `_setCurateSubTab`, mirroring `_automateSubnavHtml`'s pattern) with two sections: candidate + blueprints (accept/reject, oversized-cluster warning, unmaterialized-member/child warning on + a partial materialization) and components still awaiting a verdict (reuses the existing + `_archRow`/`_archVerdictControlsHtml` chain rather than duplicating it). Analysis's own + rendering of the same payload is now genuinely read-only: a new module-level + `_archInteractiveMode` flag (false by default, set true only for the Curate render pass) gates + both the accept/reject controls on an undecided component and the "change" affordance on an + already-decided one β€” Analysis shows verdicts as facts, Curate shows them as editable state. + **Not built as originally scoped**: a per-perspective nested tree with `detail.children` + indentation (item 2 of the original Frontend section) β€” built instead as a flat list of + (perspective, cluster_name) rows, since Curate's own candidate set is small enough per repo + that the extra nesting UI wasn't needed to stay scannable; `detail.children`/`detail.parent` + are still both carried in the payload (`bp.children.length`/`bp.parent` rendered inline) if a + future pass wants the tree view. +- Live-verified against `sqlglot` (18 components, 3 candidate blueprints) β€” accepted a blueprint + and a component through the actual UI (not just the API), confirmed the verdict badge and + "change" control render correctly in Curate, confirmed the same payload renders no accept/ + change controls in Analysis (checked via direct `_renderArchitectureRecoveryResults` calls in + both `_archInteractiveMode` states against the live payload). **Found, not caused by this + phase**: accepting the `.github` blueprint saved its verdict correctly but Egeria rejected the + materialization (`VALIDATION_ERROR_1` on `NewSolutionElementRequestBody`, "Request body failed + validation") β€” reported non-fatally via the existing toast pattern exactly as designed + (verdict saved regardless). This is a `BlueprintMaterializer.materialize_blueprint_element` + (Phase A) request-body bug, not a Phase C regression; tracked as Backlog item 6 rather than + fixed here, since fixing it means touching Phase A code under Phase C's own late-session + review, not extending Phase C's actual scope. +- Full suite run pending (background at time of writing) β€” see Backlog.md item 1 for the result. --- diff --git a/packages/resource-explorer/resource_explorer/egeria_outbox.py b/packages/resource-explorer/resource_explorer/egeria_outbox.py index 081d656bd..490d74578 100644 --- a/packages/resource-explorer/resource_explorer/egeria_outbox.py +++ b/packages/resource-explorer/resource_explorer/egeria_outbox.py @@ -529,6 +529,41 @@ def enqueue_collection_members( return row_ids +def enqueue_blueprint_members( + registry, entity_type: str, entity_slug: str, blueprint_guid: str, + member_guids: list[str], *, run_id: str = "", +) -> list[int]: + """One row per member (a `SolutionComponent` or a child `SolutionBlueprint` + β€” both are ordinary `CollectionMembership` attachments to the blueprint's + Collection) attached to a blueprint. docs/blueprint-materialization-plan.md + Decision 5/Phase B. + + Reuses `element_kind="collection_membership"` unchanged β€” same creator, + same idempotency basis as `enqueue_collection_members` above + (`CollectionMembership` measured uni-link 2026-08-25; `add_to_collection` + is a safe upsert regardless of which kind of Collection the blueprint + is). Parallel in shape to `enqueue_collection_members`, not a call to it, + because that function hardcodes `entity_type="investigation"` and this + caller's entity is a repo (or whatever `entity_type` the blueprint's own + resource is) β€” the member's own identity fields that function also + records don't apply here, since a blueprint member is always this same + resource's own component/child-blueprint, not a cross-resource reference. + + No `depends_on_id`: unlike annotation links, the blueprint GUID and every + member GUID passed in are already resolved synchronously before this is + called (Decision 5) β€” nothing here is still in flight. + """ + row_ids: list[int] = [] + for member_guid in member_guids: + qualified_name = f"CollectionMembership::{blueprint_guid}::{member_guid}" + row_ids.append(registry.enqueue_outbox_element( + entity_type, entity_slug, "collection_membership", qualified_name, + {"collection_guid": blueprint_guid, "member_guid": member_guid}, + run_id=run_id, + )) + return row_ids + + def record_drain_outcome( registry, summary: dict, entity_type: str = "", entity_slug: str = "", troubled_runs: dict[str, str] | None = None, diff --git a/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py b/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py index aa90631cb..61b554cf0 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py +++ b/packages/resource-explorer/resource_explorer/surveyors/arch_recovery/blueprint_materializer.py @@ -25,16 +25,36 @@ (plan Decisions 1 and 2: accepting a blueprint does NOT implicitly accept or materialize its members/children). -**Divergence from `ComponentMaterializer` #1 (deliberate, not an oversight):** -`architecture-recovery.md` Β§10 Phase 2 says explicitly "All at -`ContentStatus = Draft`" for this projection. `ComponentMaterializer` uses -`NewElementRequestBody`, which pyegeria's own docstring says defaults the -element to ACTIVE β€” so despite the design doc, materialized SolutionComponents -are NOT Draft today (a pre-existing gap, flagged as its own follow-up in the -plan, not fixed here since ComponentMaterializer is out of scope for this -module). `BlueprintMaterializer` gets this right from the start: the create -body uses `class: "NewSolutionElementRequestBody"` with `initialStatus: -"DRAFT"`, not `NewElementRequestBody`. +**Draft-status divergence from `ComponentMaterializer` β€” achieved, on the +second attempt (Backlog.md item 6, 2026-09-03).** `architecture-recovery.md` +Β§10 Phase 2 says explicitly "All at `ContentStatus = Draft`" for this +projection. First attempt: `class: "NewSolutionElementRequestBody"` with a +top-level `initialStatus: "DRAFT"`, per `SolutionArchitect. +create_solution_blueprint`'s own docstring β€” **failed client-side, before +any HTTP call**: `NewSolutionElementRequestBody` is documented in that +docstring in two separate pyegeria versions (5.3.4.23 here, 6.1.9 in the +canonical egeria-python checkout) but was never a real pydantic model, nor +(per egeria-python's own review, ISSUE-84) ever a real Egeria API surface β€” +`initialStatus` appears in no `.http` ground-truth file either. The +installed client validates every create-blueprint body against a bare +`TypeAdapter(NewElementRequestBody)`, whose `class` field is a strict +`Literal["NewElementRequestBody"]`; any other value raised a +`PyegeriaInvalidParameterException` ("Request body failed validation") that +read like an Egeria-side rejection but wasn't one. Second attempt, the real +mechanism (egeria-python-65's finding, odpi/egeria-python#337): +`contentStatus` is a plain field on `ReferenceableProperties` (base of +`SolutionBlueprintProperties`), settable inside `properties` on the +existing, real `NewElementRequestBody` β€” no separate request-body class +needed. Confirmed live against the real platform (post +`jdbcMaximumPoolSize` fix): `properties.contentStatus` round-trips as +`"DRAFT"` on read-back. `elementHeader.status` stays `"ACTIVE"` regardless +β€” that's OMRS's own instance-status axis (soft-delete/active at the +repository level), a different thing from the content-maturity axis +`contentStatus` represents; the design doc's own wording ("ContentStatus = +Draft") names the field this now sends, not the instance status. +`ComponentMaterializer`'s own equivalent gap (still `NewElementRequestBody` +with no `contentStatus`) is not fixed here β€” out of scope for this module, +tracked as its own follow-up in the plan. **Idempotency, not a create-blind path.** Same reasoning as `ComponentMaterializer`: a repeat "accepted" call for the same @@ -212,6 +232,25 @@ def materialize_blueprint_element( "class": "SolutionBlueprintProperties", "qualifiedName": qualified_name, "displayName": display_name, + # architecture-recovery.md Β§10 Phase 2's "All at ContentStatus = + # Draft" β€” achieved 2026-09-03, second attempt. The first + # (class: "NewSolutionElementRequestBody" + top-level + # initialStatus) never validated at all (Backlog.md item 6, + # egeria-python PYEGERIA_ISSUES.md ISSUE-84). egeria-python-65's + # review found the real mechanism: `contentStatus` is a plain + # field on ReferenceableProperties (base of + # SolutionBlueprintProperties), settable at creation like any + # other property. Confirmed live against the real platform + # (qs-view-server, post jdbcMaximumPoolSize fix): a blueprint + # created with contentStatus: "DRAFT" round-trips it correctly + # on read-back (properties.contentStatus == "DRAFT"). Separately + # confirmed: elementHeader.status stays "ACTIVE" regardless β€” + # that's OMRS's own instance-status axis (soft-delete/active at + # the repository level), a different thing from the content- + # maturity axis contentStatus represents; the design doc's own + # wording ("ContentStatus = Draft") names exactly the field this + # sends, not the instance status. + "contentStatus": "DRAFT", } # Same provenance reasoning as ComponentMaterializer.materialize: # this is evidence ABOUT the proposal, not a typed property of the @@ -222,10 +261,39 @@ def materialize_blueprint_element( additional["oversized"] = "true" properties["additionalProperties"] = additional + # `class: "NewSolutionElementRequestBody"` + `initialStatus` (this + # method's original body, matching SolutionArchitect. + # create_solution_blueprint's own docstring) fails BEFORE any Egeria + # call β€” confirmed live 2026-09-03 accepting a real candidate + # blueprint (Backlog.md item 6) and by direct inspection: pyegeria + # 5.3.4.23 (this checkout) and 6.1.9 (the canonical egeria-python + # checkout) both document `NewSolutionElementRequestBody` in that + # docstring but never define it as a pydantic model β€” the installed + # `ServerClient._new_element_request_adapter` is a bare + # `TypeAdapter(NewElementRequestBody)`, whose `class_` field is + # `Literal["NewElementRequestBody"]`. Any other `class` value is a + # client-side `PyegeriaInvalidParameterException` + # ("Request body failed validation") raised locally by pydantic β€” + # it never reaches the network, so this was never an Egeria-side + # rejection despite how the caught exception reads. Logged as a + # pyegeria gap (egeria-python's PYEGERIA_ISSUES.md) rather than + # patched there directly, per this repo's own policy of routing + # egeria-python fixes through that tracker. + # + # The fix here is the same one ComponentMaterializer already lives + # with: `class: "NewElementRequestBody"`, no `initialStatus` (it + # isn't a real field on that model either β€” `extra="ignore"` would + # have silently dropped it even if the class name had validated). + # This means materialized blueprints are created ACTIVE, not DRAFT, + # same as materialized components β€” the module docstring's + # "Divergence from ComponentMaterializer #1" no longer holds; both + # materializers now share the identical gap, tracked as its own + # follow-up (Backlog.md, "Confidence classification"/Draft-status + # note) rather than fixed by inventing a body shape pyegeria can't + # validate. body = { - "class": "NewSolutionElementRequestBody", + "class": "NewElementRequestBody", "isOwnAnchor": True, - "initialStatus": "DRAFT", "properties": properties, } try: diff --git a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py index add57b08e..1d8a90cb7 100644 --- a/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py +++ b/packages/resource-explorer/resource_explorer/surveyors/repo_survey_definition_adapter.py @@ -2709,6 +2709,12 @@ def _architecture_recovery_results( # form. Offering there would re-open a decision the system made # correctly. "documentation": _doc_ingestion_state(registry, slug), + # Phase C (blueprint-materialization-plan.md) β€” candidate blueprints + # proposed by clustering.py, each with its own accept/reject verdict + # and materialization state. Read once here so the frontend's Curate + # tab doesn't need a second route; Analysis's read-only rendering of + # this same payload simply doesn't render this key. + "blueprints": _candidate_blueprints_results(registry, slug), "raw_component_count": sum(1 for c in all_components if not c.get("structural")), "structural_node_count": sum(1 for c in all_components if c.get("structural")), "run_outcomes": run_outcomes, @@ -2738,6 +2744,71 @@ def _json_or_empty(raw) -> dict: return {} +def _candidate_blueprints_results(registry, slug: str) -> list[dict]: + """The `data.blueprints` key for `_architecture_recovery_results` β€” + Phase C's frontend-facing read of clustering.py's proposals (Backlog.md + item 1 / blueprint-materialization-plan.md), so Curate can offer an + accept/reject control on a candidate blueprint the same way component + verdicts already work. + + Deliberately its own reader rather than folded into the component loop + above: candidate_blueprint findings share scope_locator="" (persist.py's + `_persist_blueprints`) and are disambiguated by (perspective, label) + within that one scope, not by scope_locator the way components are β€” a + genuinely different join key, so merging the two loops would mean + threading a second identity scheme through code that already has one. + + One row per (perspective, cluster_name), latest survey wins β€” same + "latest row for this identity" rule every other reader here uses. + Verdict/materialized state merged on via curate.py's own key shape + (`f"{perspective}::{cluster_name}"`) so the frontend never has to + recompute that join. + """ + from resource_explorer.surveyors.arch_recovery.persist import BLUEPRINT_KIND + + rows = [r for r in registry.query_findings(slug, BLUEPRINT_KIND, "") + if r["check_name"] == "candidate_blueprint"] + by_key: dict[tuple[str, str], dict] = {} + for r in rows: + detail = _json_or_empty(r.get("detail_json")) + perspective = detail.get("perspective", "") + name = detail.get("name", "") + if not perspective or not name: + continue + key = (perspective, name) + existing = by_key.get(key) + if existing is None or r["surveyed_at"] >= existing["row"]["surveyed_at"]: + by_key[key] = {"row": r, "detail": detail} + + verdicts = registry.get_component_verdicts("repo", slug) + materialized = registry.get_materialized_blueprints("repo", slug) + + blueprints = [] + for (perspective, name), entry in sorted(by_key.items()): + r, detail = entry["row"], entry["detail"] + vkey = f"{perspective}::{name}" + blueprints.append({ + "perspective": perspective, + "cluster_name": name, + "summary": r.get("summary", ""), + "signal": detail.get("signal"), + "carrier": detail.get("carrier"), + "composed_into": detail.get("composed_into"), + "size": detail.get("size", 0), + "members": detail.get("members") or [], + "children": detail.get("children") or [], + "parent": detail.get("parent", ""), + "oversized": bool(detail.get("oversized")), + "target_size": detail.get("target_size"), + "run_scope": detail.get("run_scope", ""), + "surveyed_at": r.get("surveyed_at", ""), + "verdict": _verdict_view(verdicts.get(vkey)), + "materialized": _materialized_view(materialized.get(vkey)), + }) + return blueprints + + + def _architecture_recovery_trend(registry, slug: str) -> list[dict]: # Two independent counters (detect/coupling never share a metric_name β€” # see persist.py's run-summary comment) merged by surveyed_at into one diff --git a/packages/resource-explorer/resource_explorer/web/routes/curate.py b/packages/resource-explorer/resource_explorer/web/routes/curate.py index d999126d5..0b4d64d73 100644 --- a/packages/resource-explorer/resource_explorer/web/routes/curate.py +++ b/packages/resource-explorer/resource_explorer/web/routes/curate.py @@ -276,8 +276,19 @@ class ComponentVerdictCreate(BaseModel): def list_component_verdicts(entity_type: str, slug: str) -> dict[str, dict]: """{scope_locator: latest verdict} for every component this resource has a curator verdict on β€” the same shape merged onto each component by - _architecture_recovery_results.""" - return _registry().get_component_verdicts(entity_type, slug) + _architecture_recovery_results. + + Filters out verdict_target='blueprint' rows (added 2026-09-03, Phase B): + get_component_verdicts' own docstring says it returns both mixed and + leaves filtering to the caller β€” this route's name and its one existing + consumer (_architecture_recovery_results, keyed by scope_locator) both + promise component verdicts specifically, so a blueprint verdict's key + (f"{perspective}::{cluster_name}") appearing here would be a surprising, + if practically harmless, leak. Mirrors list_blueprint_verdicts' own + client-side filter in the other direction. + """ + all_verdicts = _registry().get_component_verdicts(entity_type, slug) + return {k: v for k, v in all_verdicts.items() if v.get("verdict_target") != "blueprint"} def _materialize_if_accepted(registry: ProjectRegistry, entity_type: str, slug: str, @@ -350,3 +361,164 @@ def component_verdict_history(entity_type: str, slug: str, scope_locator: str) - a query param (not a path segment) since it's a path prefix and routinely contains slashes.""" return _registry().list_component_verdict_history(entity_type, slug, scope_locator) + + +# ── Blueprint verdicts ────────────────────────────────────────────────────── +# +# docs/blueprint-materialization-plan.md β€” the same report-then-curate shape +# as component verdicts above, one level up: a candidate_blueprint finding +# (a clustering.py proposal, not yet a real Egeria element) is what a +# curator accepts or rejects. Reuses architecture_component_verdicts (see +# the plan's Decision 3) rather than a parallel table β€” verdict_target +# distinguishes the two, and scope_locator is repurposed to hold +# f"{perspective}::{cluster_name}" since a blueprint has none of its own. +# +# Wires are deliberately NOT enqueued here (project-owner decision, +# 2026-09-03, after Phase A.5's live measurement confirmed +# SolutionLinkingWire duplicates on retry) β€” a materialized blueprint +# attaches its member components and child blueprints but renders without +# its wire diagram until that's built as its own follow-up. + +class BlueprintVerdictCreate(BaseModel): + perspective: str + cluster_name: str + verdict: str # "accepted" | "rejected" + note: str = "" + + +@router.get("/blueprint-verdicts/{entity_type}/{slug}") +def list_blueprint_verdicts(entity_type: str, slug: str) -> dict[str, dict]: + """{f"{perspective}::{cluster_name}": latest verdict} β€” filters + list_component_verdicts' full result to verdict_target='blueprint' + client-side, per the plan's Decision 3 (one table, not a second query + parameter for what is currently one filter).""" + all_verdicts = _registry().get_component_verdicts(entity_type, slug) + return {k: v for k, v in all_verdicts.items() if v.get("verdict_target") == "blueprint"} + + +def _slug_to_scope_map(registry: ProjectRegistry, slug: str) -> dict[str, str]: + """component slug -> scope_locator, for every currently-live + architecture_recovery component finding. Deliberately its own copy of + _architecture_recovery_results' identical loop + (repo_survey_definition_adapter.py) rather than a shared import β€” same + reasoning ComponentMaterializer._find_element_guid gives for not + sharing with EgeriaPublisher: a small, self-contained piece of logic + duplicated once is safer here than a new cross-module coupling for one + caller. This is THE fix for the plan's own named identity-mismatch + trap: clustering keys a blueprint's members by component slug; verdicts + and materialization are keyed by scope_locator. Looking a slug up + directly in get_materialized_component()/get_component_verdicts() + without going through this map first silently finds nothing for every + member.""" + import json as _json + out: dict[str, str] = {} + for scope in registry.query_finding_scopes(slug, "architecture_recovery", check_name="component"): + rows = [r for r in registry.query_findings_all_runs(slug, "architecture_recovery", scope) + if r["check_name"] == "component"] + if not rows: + continue + latest = max(rows, key=lambda r: r["surveyed_at"]) + detail = _json.loads(latest.get("detail_json") or "{}") if latest.get("detail_json") else {} + if detail.get("slug"): + out[detail["slug"]] = scope + return out + + +def _find_candidate_blueprint(registry: ProjectRegistry, slug: str, + perspective: str, cluster_name: str) -> dict | None: + """The latest candidate_blueprint finding for (perspective, cluster_name), + or None if it's gone (e.g. re-clustered since the review surface last + loaded). clustering.py's findings all share scope_locator="" β€” every + cluster for a repo is disambiguated by label/detail.name within that one + scope, per the plan's own Context section.""" + import json as _json + from resource_explorer.surveyors.arch_recovery.persist import BLUEPRINT_KIND + rows = [r for r in registry.query_findings(slug, BLUEPRINT_KIND, "") + if r["check_name"] == "candidate_blueprint"] + matches = [] + for r in rows: + detail = _json.loads(r.get("detail_json") or "{}") if r.get("detail_json") else {} + if detail.get("perspective") == perspective and detail.get("name") == cluster_name: + matches.append((r, detail)) + if not matches: + return None + r, detail = max(matches, key=lambda pair: pair[0]["surveyed_at"]) + return detail + + +def _materialize_blueprint_if_accepted(registry: ProjectRegistry, entity_type: str, slug: str, + perspective: str, cluster_name: str, verdict: str) -> dict | None: + """Same non-fatal-but-visible shape as _materialize_if_accepted above β€” + the verdict itself is already saved and real regardless of whether this + succeeds. Only 'accepted' triggers a write; only entity_type='repo' is + wired (architecture_recovery/clustering's only kind today). + + Partial progress is real progress (Decision 2/step 4 of the plan): an + unmaterialized member or child is reported, not treated as a reason to + enqueue nothing. + """ + if verdict != "accepted" or entity_type != "repo": + return None + cluster = _find_candidate_blueprint(registry, slug, perspective, cluster_name) + if cluster is None: + return {"status": "error", + "error": "no candidate_blueprint finding for this (perspective, cluster_name) " + "to materialize"} + + from resource_explorer.surveyors.arch_recovery.blueprint_materializer import ( + BlueprintMaterializationError, BlueprintMaterializer, + ) + materializer = BlueprintMaterializer(registry=registry) + try: + result = materializer.materialize_blueprint_element( + entity_type, slug, perspective, cluster_name, + display_name=cluster.get("name", cluster_name), + oversized=bool(cluster.get("oversized")), + ) + except BlueprintMaterializationError as exc: + return {"status": "error", "error": str(exc)} + + blueprint_guid = result["guid"] + slug_to_scope = _slug_to_scope_map(registry, slug) + member_guids, unmaterialized_members = materializer.resolve_member_guids( + registry, entity_type, slug, cluster.get("members") or [], slug_to_scope, + ) + child_guids, unmaterialized_children = materializer.resolve_child_blueprint_guids( + registry, entity_type, slug, perspective, cluster.get("children") or [], + ) + + from resource_explorer.egeria_outbox import enqueue_blueprint_members + all_member_guids = list(member_guids.values()) + list(child_guids.values()) + row_ids = enqueue_blueprint_members(registry, entity_type, slug, blueprint_guid, all_member_guids) + + result["enqueued_membership_rows"] = len(row_ids) + if unmaterialized_members: + result["unmaterialized_members"] = unmaterialized_members + if unmaterialized_children: + result["unmaterialized_children"] = unmaterialized_children + if unmaterialized_members or unmaterialized_children: + result["status"] = "partial" + return result + + +@router.post("/blueprint-verdicts/{entity_type}/{slug}") +def add_blueprint_verdict(entity_type: str, slug: str, body: BlueprintVerdictCreate) -> dict: + if not body.perspective.strip() or not body.cluster_name.strip(): + raise HTTPException(status_code=400, detail="perspective and cluster_name must not be empty") + if body.verdict not in ProjectRegistry.BLUEPRINT_VERDICTS: + raise HTTPException( + status_code=400, + detail=f"verdict must be one of {sorted(ProjectRegistry.BLUEPRINT_VERDICTS)}, got {body.verdict!r}", + ) + registry = _registry() + scope_locator = f"{body.perspective}::{body.cluster_name}" + verdict = registry.record_component_verdict( + entity_type, slug, scope_locator, body.verdict, "", body.note, + verdict_target="blueprint", + ) + materialization = _materialize_blueprint_if_accepted( + registry, entity_type, slug, body.perspective, body.cluster_name, body.verdict, + ) + if materialization is not None: + verdict["materialization"] = materialization + return verdict diff --git a/packages/resource-explorer/resource_explorer/web/static/index.html b/packages/resource-explorer/resource_explorer/web/static/index.html index 3c2942792..2fc2b962c 100644 --- a/packages/resource-explorer/resource_explorer/web/static/index.html +++ b/packages/resource-explorer/resource_explorer/web/static/index.html @@ -3695,6 +3695,15 @@ return best; } let _archUidSeq = 0; +// Read-only by default (Analysis's own call site never sets this) β€” +// per docs/curate-evidence-based-decisions-plan.md Β§D item 10, Analysis stays a +// read-only evidence display; only Curate's own rendering pass (below) turns +// this on so accept/reject/retype controls appear. A module flag rather than +// threading a param through every nested render function in this chain +// (_archPanesHtml/_archRow/_archVerdictBadgeHtml) β€” same reasoning _archUidSeq +// above already uses for render-scoped state. +let _archInteractiveMode = false; + function _archShowPerspective(uid, persp) { document.querySelectorAll('[data-arch-pane="' + uid + '"]').forEach(el => { el.classList.toggle('hidden', el.dataset.archPersp !== persp); @@ -3765,7 +3774,10 @@ // renders no verdict UI rather than wiring buttons to a broken URL. const verdictArea = !slug ? '' : (c.verdict ? _archVerdictBadgeHtml(rowUid, slug, c.path, c.verdict, c.materialized) - : _archVerdictControlsHtml(rowUid, slug, c.path)); + // No verdict yet, read-only context (Analysis): render nothing rather + // than an inert-looking control set β€” the "Accept"/"Reject" affordance + // only exists in Curate. + : (_archInteractiveMode ? _archVerdictControlsHtml(rowUid, slug, c.path) : '')); return `
@@ -3827,11 +3839,16 @@ const egeriaBadge = (materialized && materialized.guid) ? `⬑ in Egeria` : ''; + // "change" re-opens the accept/reject controls β€” a Curate-only action; + // Analysis's read-only pass shows the verdict as a fact, not an editor. + const changeBtn = _archInteractiveMode + ? `` + : ''; return `
${label} ${v.decided_at ? _relTime(v.decided_at) : ''} ${egeriaBadge} - + ${changeBtn}
`; } @@ -8361,6 +8378,29 @@

${_esc(srv.display_name)} β€” refreshed each load +// Curate sub-tabs (Phase C, blueprint-materialization-plan.md / +// curate-evidence-based-decisions-plan.md Β§D) β€” 'general' is the pre-existing +// tags/feedback/notes content; 'architecture' is the new candidate-blueprint +// and component-verdict review surface, repo-only (architecture_recovery is a +// repo analysis). Mirrors _automateSubTab's module-level-state pattern. +let _curateSubTab = 'general'; // 'general' | 'architecture' + +function _curateSubnavHtml(active, entityType) { + const tabs = [{ tab: 'general', label: 'πŸ—‚ General' }]; + if (entityType === 'repo') tabs.push({ tab: 'architecture', label: 'πŸ› Architecture Verdicts' }); + if (tabs.length < 2) return ''; // nothing to switch between for db/filesystem entities + return ``; +} + +function _setCurateSubTab(tab) { + _curateSubTab = tab; + loadCuratePanel(); +} + + // ── Automate (8th intent, Part 4) β€” local-first notification subscriptions // + (moved here 2026-08-13 from Admin) the global Schedules monitoring // overview. Two sub-tabs: πŸ”” Subscriptions (what's being watched for @@ -8750,7 +8790,16 @@

⏱ Automat } } -async function loadCuratePanel() { +// Dispatcher β€” every existing mutation handler below (addCurateTag, +// saveCurateGroup, ...) already calls loadCuratePanel() to refresh after a +// write; keeping that name as the dispatcher means none of those call sites +// needed to change when the architecture sub-tab was added. +function loadCuratePanel() { + if (_curateSubTab === 'architecture') return loadCurateArchitecturePanel(); + return loadCurateGeneralPanel(); +} + +async function loadCurateGeneralPanel() { const [entityType, slug] = _surveyDefEntityAndSlug(); const view = document.getElementById('curate-view'); if (!entityType || !slug) return; // showCuratePane() already gates this; defensive only @@ -8767,9 +8816,9 @@

⏱ Automat ]); _curateAllTagsCache = allTags; const currentGroupSlug = _curateCurrentGroupSlug(entityType, slug); - renderCuratePanel(entityType, slug, tags, feedback, notes, currentGroupSlug); + renderCurateGeneralPanel(entityType, slug, tags, feedback, notes, currentGroupSlug); } catch(e) { - view.innerHTML = `
Failed to load: ${_esc(e.message)}
`; + view.innerHTML = _curateSubnavHtml('general', entityType) + `
Failed to load: ${_esc(e.message)}
`; } } @@ -8786,7 +8835,7 @@

⏱ Automat return ''; } -function renderCuratePanel(entityType, slug, tags, feedback, notes, currentGroupSlug) { +function renderCurateGeneralPanel(entityType, slug, tags, feedback, notes, currentGroupSlug) { const view = document.getElementById('curate-view'); const icon = { repo: 'πŸ“', database: 'πŸ—„', filesystem: 'πŸ’Ύ' }[entityType] || 'β€’'; @@ -8821,6 +8870,7 @@

⏱ Automat Curate β€” ${icon} ${_esc(slug)}

+ ${_curateSubnavHtml('general', entityType)}
πŸ—‚ Project Group
@@ -8890,6 +8940,164 @@

⏱ Automat

`; } +// ── Curate β†’ Architecture Verdicts ────────────────────────────────────────── +// docs/curate-evidence-based-decisions-plan.md Β§D item 10 / blueprint- +// materialization-plan.md Phase C: verdict controls for BOTH candidate +// blueprints and individual proposed components move here, out of +// Analysis, which stays a read-only evidence display (_archInteractiveMode, +// set true only for the render pass below). Reuses the exact payload +// Analysis already fetches (GET .../analyses/architecture_recovery/results) +// rather than a second endpoint β€” `data.blueprints` (repo_survey_definition_ +// adapter.py's _candidate_blueprints_results) is new; `data.components` is +// the same list Analysis renders, just rendered here with controls live. +async function loadCurateArchitecturePanel() { + const [entityType, slug] = _surveyDefEntityAndSlug(); + const view = document.getElementById('curate-view'); + if (!entityType || !slug) return; + view.innerHTML = _curateSubnavHtml('architecture', entityType) + '
Loading…
'; + try { + const data = await fetch(`/api/projects/${encodeURIComponent(slug)}/analyses/architecture_recovery/results`).then(r => r.json()); + renderCurateArchitecturePanel(entityType, slug, data); + } catch (e) { + view.innerHTML = _curateSubnavHtml('architecture', entityType) + `
Failed to load: ${_esc(e.message)}
`; + } +} + +function _curateBlueprintRow(bp, slug) { + const rowUid = 'bp' + (++_archUidSeq); + const sizeLabel = `${bp.size} component(s)${bp.children.length ? ` across ${bp.children.length} nested blueprint(s)` : ''}`; + const oversizedNote = bp.oversized + ? `
⚠ Over the target cluster size (~${bp.target_size ?? '?'}) with no further declared structure β€” review before accepting.
` + : ''; + const verdictArea = bp.verdict + ? _archBlueprintVerdictBadgeHtml(rowUid, slug, bp.perspective, bp.cluster_name, bp.verdict, bp.materialized) + : _archBlueprintVerdictControlsHtml(rowUid, slug, bp.perspective, bp.cluster_name); + return ` +
+
+ ${_esc(bp.cluster_name)} + ${_esc(bp.perspective)} +
+
${_esc(sizeLabel)}${bp.parent ? ` Β· nested under ${_esc(bp.parent)}` : ''}
+ ${oversizedNote} + ${verdictArea} +
`; +} + +function _archBlueprintVerdictControlsHtml(rowUid, slug, perspective, clusterName) { + return `
+ + +
`; +} + +function _archBlueprintVerdictBadgeHtml(rowUid, slug, perspective, clusterName, v, materialized) { + const label = v.verdict === 'accepted' ? 'βœ“ Accepted' : 'βœ— Rejected'; + const color = v.verdict === 'accepted' ? 'text-emerald-400' : 'text-red-400'; + const egeriaBadge = (materialized && materialized.guid) + ? `⬑ in Egeria` + : ''; + const unmatBadge = (materialized && (materialized.unmaterialized_members || materialized.unmaterialized_children)) + ? `⚠ partial` + : ''; + return `
+ ${label} + ${v.decided_at ? _relTime(v.decided_at) : ''} + ${egeriaBadge}${unmatBadge} + +
`; +} + +function _archRevertBlueprintVerdictUi(rowUid) { + const el = document.querySelector(`[data-arch-bp-verdict="${rowUid}"]`); + if (!el) return; + el.outerHTML = _archBlueprintVerdictControlsHtml(rowUid, el.dataset.archBpSlug, el.dataset.archBpPersp, el.dataset.archBpName); +} + +async function _archSubmitBlueprintVerdict(rowUid, verdict) { + const el = document.querySelector(`[data-arch-bp-verdict="${rowUid}"]`); + if (!el) return; + const slug = el.dataset.archBpSlug, perspective = el.dataset.archBpPersp, clusterName = el.dataset.archBpName; + try { + const resp = await fetch(`/api/curate/blueprint-verdicts/repo/${encodeURIComponent(slug)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ perspective, cluster_name: clusterName, verdict }), + }); + if (!resp.ok) { + let detail = `HTTP ${resp.status}`; + try { detail = (await resp.json()).detail || detail; } catch (_) { /* body wasn't JSON */ } + throw new Error(detail); + } + const saved = await resp.json(); + const m = saved.materialization; + el.outerHTML = _archBlueprintVerdictBadgeHtml(rowUid, slug, perspective, clusterName, { + verdict: saved.verdict, decided_at: saved.created_at, + }, m && m.status !== 'error' ? m : null); + if (m && m.status === 'error') { + showToast('error', `Verdict saved, but Egeria materialization failed: ${_esc(m.error)}`); + } else if (m && (m.status === 'materialized' || m.status === 'partial')) { + showToast(m.status === 'partial' ? 'error' : 'success', + m.status === 'partial' + ? `Accepted β€” blueprint created, but some members aren't materialized in Egeria yet` + : `Accepted β€” created as a real SolutionBlueprint in Egeria`); + } else if (m && m.status === 'already_materialized') { + showToast('success', `Accepted β€” already exists in Egeria`); + } else { + showToast('success', `Marked ${verdict}`); + } + } catch (e) { + showToast('error', `Could not save verdict: ${_esc(e.message)}`); + } +} + +function renderCurateArchitecturePanel(entityType, slug, data) { + const view = document.getElementById('curate-view'); + const blueprints = data.blueprints || []; + const components = (data.components || []).filter(c => !c.structural); + + const blueprintsHtml = blueprints.length + ? blueprints.map(bp => _curateBlueprintRow(bp, slug)).join('') + : '
No candidate blueprints proposed yet β€” run Analysis β†’ Architecture Recovery.
'; + + // Components already decided don't need attention here β€” surface only + // what's still awaiting a verdict, capped the same way Analysis caps its + // own per-perspective panes, so a large repo's card stays scannable. + const undecided = components.filter(c => !c.verdict); + const decidedCount = components.length - undecided.length; + _archInteractiveMode = true; + let componentsHtml; + try { + componentsHtml = undecided.length + ? undecided.slice(0, _ARCH_ROW_CAP).map((c, i) => _archRow(c, slug, `curate-comp-${i}`)).join('') + : '
No components awaiting a verdict.
'; + } finally { + _archInteractiveMode = false; + } + + view.innerHTML = ` +
+ Curate β€” πŸ“ ${_esc(slug)} +
+
+ ${_curateSubnavHtml('architecture', entityType)} + +
+
πŸ› Candidate Blueprints
+
Groups of components proposed by architecture recovery β€” accepting one materializes it as a real Egeria SolutionBlueprint and attaches its members. Wire diagrams aren't materialized yet (tracked separately).
+ ${blueprintsHtml} +
+ +
+
🧩 Components awaiting a verdict
+
${decidedCount ? `${decidedCount} already decided β€” see Analysis β†’ Architecture Recovery for the full read-only view. ` : ''}Accept, reject, or retype what architecture recovery proposed.
+ ${componentsHtml} +
+
`; +} + async function saveCurateGroup(entityType, slug) { const groupSlug = document.getElementById('curate-group-select').value; try { diff --git a/packages/resource-explorer/scripts/arch-spike/measure_wire_multi_link.py b/packages/resource-explorer/scripts/arch-spike/measure_wire_multi_link.py new file mode 100644 index 000000000..92df14bdf --- /dev/null +++ b/packages/resource-explorer/scripts/arch-spike/measure_wire_multi_link.py @@ -0,0 +1,159 @@ +"""Phase A.5 of docs/blueprint-materialization-plan.md β€” live measurement, +not assumption: does a second, identical `link_solution_linking_wire()` call +between the same two SolutionComponents create a duplicate relationship, or +upsert onto the existing one? + +pyegeria's own docstring for SolutionLinkingWire says "allows more than one +wire between the same pair of components" (multi-link) β€” this script +confirms it empirically rather than trusting the docstring, following this +codebase's own established discipline (docs/reference_egeria_dedup_and_link_patterns +memory: "if attach returns an identifier it is multi-link; if it returns +nothing it is uni-link" β€” measured directly for ResourceList/CollectionMembership +on 2026-08-25; SolutionLinkingWire was never measured). + +One-off, throwaway: creates two disposable SolutionComponents, links them +twice, reads back, deletes both components (which removes their +relationships too β€” NOT a by-ends detach, which the same reference note +warns removes every matching relationship for a multi-link type, silently +destroying the evidence this script exists to capture). + +Coordinated with all 8 live peer sessions before running (2026-09-03) β€” +see the session transcript. Confirmed before running: the platform is +healthy and settled (not mid-restart), and the earlier republish batch's +outbox is fully drained (0 'running' rows) so this write cannot collide +with it. + +Run once, capture output to a file, inspect the file β€” never pipe this +invocation into grep/tail/wc (a non-idempotent write inside a pipeline +runs again for each stage that reads it, per this session's own +coordinate-shared-writes convention). +""" +from __future__ import annotations + +import sys +import time + +from pyegeria.omvs.solution_architect import SolutionArchitect + +PLATFORM_URL = "https://localhost:9443" +VIEW_SERVER = "qs-view-server" +USER = "erinoverview" +PASSWORD = "secret" + +_TAG = f"wire-multi-link-probe-{int(time.time())}" + + +def _component_body(name: str) -> dict: + return { + "class": "NewElementRequestBody", + "isOwnAnchor": True, + "properties": { + "class": "SolutionComponentProperties", + "qualifiedName": f"SolutionComponent::probe::{_TAG}::{name}", + "displayName": f"{_TAG}-{name}", + "additionalProperties": {"purpose": "throwaway-probe, safe to delete"}, + }, + } + + +def main() -> int: + print(f"[probe] tag = {_TAG}") + sa = SolutionArchitect(VIEW_SERVER, PLATFORM_URL, USER, PASSWORD) + sa.create_egeria_bearer_token(USER, PASSWORD) + + comp_a_guid = comp_b_guid = None + try: + print("[probe] creating component A...") + comp_a_guid = sa.create_solution_component(_component_body("A")) + print(f"[probe] component A guid = {comp_a_guid}") + + print("[probe] creating component B...") + comp_b_guid = sa.create_solution_component(_component_body("B")) + print(f"[probe] component B guid = {comp_b_guid}") + + wire_body = { + "class": "NewRelationshipRequestBody", + "properties": { + "class": "SolutionLinkingWireProperties", + "label": "probe-wire", + "description": "Phase A.5 multi-link measurement β€” throwaway.", + }, + } + + print("[probe] first link_solution_linking_wire() call...") + first_return = sa.link_solution_linking_wire(comp_a_guid, comp_b_guid, wire_body) + print(f"[probe] first call returned: {first_return!r}") + + print("[probe] second link_solution_linking_wire() call, identical args...") + second_return = sa.link_solution_linking_wire(comp_a_guid, comp_b_guid, wire_body) + print(f"[probe] second call returned: {second_return!r}") + + # The definitive signal (memory note: "the return value IS the + # test"). A None return would be uni-link (attach-as-upsert, + # contradicting the docstring); a non-None identifier is multi-link. + return_value_verdict = ( + "MULTI-LINK (both calls returned an identifier)" + if first_return and second_return + else "UNI-LINK (at least one call returned None)" + if (first_return is None or second_return is None) + else "INCONCLUSIVE (unexpected return shape)" + ) + same_identifier = first_return == second_return + print(f"[probe] return-value verdict: {return_value_verdict}") + print(f"[probe] first_return == second_return: {same_identifier}") + + # Confirmatory read-back β€” counting DISTINCT RELATIONSHIP GUIDS + # between the two components, not just "does a wire exist" (which + # reads identically for one wire and for two β€” the exact query-side + # version of the collision-key trap this session's own + # reference-note already documents for annotation qualifiedNames). + print("[probe] reading back component A's related elements...") + related_a = sa.get_component_related_elements(comp_a_guid) + print(f"[probe] raw related-elements response for A:\n{related_a!r}") + + wire_relationship_guids: set[str] = set() + if isinstance(related_a, dict): + for key in ("elements", "elementList", "relatedElements"): + items = related_a.get(key) + if isinstance(items, list): + for item in items: + rel = item.get("relationshipHeader") or item.get("relationship") or {} + guid = (rel.get("elementHeader") or {}).get("guid") or rel.get("guid") + if guid: + wire_relationship_guids.add(guid) + print(f"[probe] distinct relationship GUIDs found in read-back: " + f"{len(wire_relationship_guids)} -> {sorted(wire_relationship_guids)}") + + print() + print("=" * 70) + print("RESULT") + print(f" first call return value: {first_return!r}") + print(f" second call return value: {second_return!r}") + print(f" same identifier both times: {same_identifier}") + print(f" return-value verdict: {return_value_verdict}") + print(f" distinct relationship GUIDs via read-back: {len(wire_relationship_guids)}") + print("=" * 70) + return 0 + + finally: + # Delete the COMPONENTS, not a by-ends detach β€” cascade_delete=True + # so any relationships (one or two wires) go with them. This is the + # safe cleanup path per the coordinating peers' shared warning: + # detach_solution_linking_wire(component1_guid, component2_guid, ...) + # removes EVERY matching relationship for a multi-link type in one + # call, which would corrupt the evidence if done before the + # read-back above. Cleanup only runs after read-back either way, + # via this try/finally, even if something above raised. + for label, guid in (("A", comp_a_guid), ("B", comp_b_guid)): + if not guid: + continue + try: + sa.delete_solution_component(guid, cascade_delete=True) + print(f"[probe] deleted throwaway component {label} ({guid})") + except Exception as exc: + print(f"[probe] WARNING: failed to delete component {label} ({guid}): {exc}", + file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/resource-explorer/tests/test_blueprint_materializer.py b/packages/resource-explorer/tests/test_blueprint_materializer.py index 9b18af720..9957822e8 100644 --- a/packages/resource-explorer/tests/test_blueprint_materializer.py +++ b/packages/resource-explorer/tests/test_blueprint_materializer.py @@ -52,20 +52,61 @@ def test_creates_with_expected_properties(self): assert props["displayName"] == "svc cluster" assert props["additionalProperties"]["recoveredBy"] == "architecture_recovery" - def test_draft_status_body_shape(self): - """The one deliberate divergence from ComponentMaterializer (Decision - 5 in the plan): a blueprint must be created Draft, via - NewSolutionElementRequestBody β€” NOT NewElementRequestBody, which - pyegeria's own docstring says defaults to ACTIVE.""" + def test_content_status_draft(self): + """architecture-recovery.md Β§10 Phase 2's "All at ContentStatus = + Draft" β€” achieved via the real mechanism (contentStatus on + ReferenceableProperties, egeria-python-65's ISSUE-84 finding), not + the fictional NewSolutionElementRequestBody/initialStatus shape. + Confirmed live 2026-09-03: contentStatus round-trips correctly on + a real blueprint (elementHeader.status stays ACTIVE regardless β€” + a different, unrelated axis).""" + m = _materializer(registry=MagicMock(get_materialized_blueprint=MagicMock(return_value=None))) + m.materialize_blueprint_element( + "repo", "myproj", "deployment", "svc-cluster", display_name="svc cluster", + ) + props = m._solution_architect.create_solution_blueprint.call_args[0][0]["properties"] + assert props["contentStatus"] == "DRAFT" + + def test_body_shape_matches_what_pyegeria_actually_validates(self): + """Corrected 2026-09-03 (Backlog.md item 6): the originally-planned + Draft-status divergence from ComponentMaterializer β€” class: + "NewSolutionElementRequestBody" + initialStatus: "DRAFT", per + SolutionArchitect.create_solution_blueprint's own docstring β€” fails + client-side pydantic validation in every installed pyegeria version + checked (5.3.4.23 here, 6.1.9 in the canonical egeria-python + checkout): NewSolutionElementRequestBody is documented but never + defined as a real model, so the client validates every body against + a bare TypeAdapter(NewElementRequestBody), whose `class` field is + Literal["NewElementRequestBody"]. Confirmed live 2026-09-03 + accepting a real candidate blueprint against sqlglot β€” this is the + regression guard. Same body shape ComponentMaterializer already + uses (ACTIVE, not Draft, until pyegeria ships a real model).""" m = _materializer(registry=MagicMock(get_materialized_blueprint=MagicMock(return_value=None))) m.materialize_blueprint_element( "repo", "myproj", "deployment", "svc-cluster", display_name="svc cluster", ) body = m._solution_architect.create_solution_blueprint.call_args[0][0] - assert body["class"] == "NewSolutionElementRequestBody" - assert body["initialStatus"] == "DRAFT" + assert body["class"] == "NewElementRequestBody" + assert "initialStatus" not in body assert body["isOwnAnchor"] is True + def test_body_actually_validates_against_installed_pyegeria(self): + """Not a mock-shape assertion β€” runs the exact body this method + builds through pyegeria's own TypeAdapter(NewElementRequestBody), + the real validator create_solution_blueprint calls internally. + This is what would have caught the original bug before it reached + a live accept.""" + from pydantic import TypeAdapter + + from pyegeria.models import NewElementRequestBody + + m = _materializer(registry=MagicMock(get_materialized_blueprint=MagicMock(return_value=None))) + m.materialize_blueprint_element( + "repo", "myproj", "deployment", "svc-cluster", display_name="svc cluster", + ) + body = m._solution_architect.create_solution_blueprint.call_args[0][0] + TypeAdapter(NewElementRequestBody).validate_python(body) # raises on failure + def test_oversized_flag_recorded_in_additional_properties(self): m = _materializer(registry=MagicMock(get_materialized_blueprint=MagicMock(return_value=None))) m.materialize_blueprint_element( diff --git a/packages/resource-explorer/tests/test_candidate_blueprints_reader.py b/packages/resource-explorer/tests/test_candidate_blueprints_reader.py new file mode 100644 index 000000000..1a61dde26 --- /dev/null +++ b/packages/resource-explorer/tests/test_candidate_blueprints_reader.py @@ -0,0 +1,132 @@ +"""docs/blueprint-materialization-plan.md Phase C β€” the new `data.blueprints` +key on `_architecture_recovery_results`, read from clustering.py's +candidate_blueprint findings and merged with verdict/materialization state +via `curate.py`'s own key shape (f"{perspective}::{cluster_name}"). +""" +from __future__ import annotations + +import pytest + +from resource_explorer.registry import Project, ProjectRegistry +from resource_explorer.surveyors.repo_survey_definition_adapter import ( + _architecture_recovery_results, + _candidate_blueprints_results, +) + + +@pytest.fixture +def registry(tmp_path): + r = ProjectRegistry(db_path=str(tmp_path / "test.db")) + r.add(Project( + slug="myproj", + display_name="My Project", + github_url="https://github.com/test/myproj", + description="A test project", + collections=["myproj_python_code", "myproj_markdown_docs"], + )) + return r + + +def _seed_cluster(registry, slug, *, members=None, children=None, oversized=False, + name="core-services", perspective="physical", + surveyed_at="2026-09-03T00:00:00"): + registry.upsert_finding(slug, "architecture_blueprints", [{ + "check_name": "candidate_blueprint", "label": name, + "detail": {"name": name, "perspective": perspective, "signal": "compose", + "carrier": "compose.yaml", "composed_into": "", + "size": len(members or []), "members": members or [], + "children": children or [], "parent": "", "oversized": oversized, + "target_size": 8, "run_scope": "", "not_a_claim": True}, + }], surveyed_at=surveyed_at) + + +class TestCandidateBlueprintsResults: + def test_no_clusters_returns_empty_list(self, registry): + assert _candidate_blueprints_results(registry, "myproj") == [] + + def test_one_cluster_round_trips(self, registry): + _seed_cluster(registry, "myproj", members=["a", "b"]) + out = _candidate_blueprints_results(registry, "myproj") + assert len(out) == 1 + bp = out[0] + assert bp["perspective"] == "physical" + assert bp["cluster_name"] == "core-services" + assert bp["members"] == ["a", "b"] + assert bp["oversized"] is False + assert bp["verdict"] is None + assert bp["materialized"] is None + + def test_oversized_flag_passes_through(self, registry): + _seed_cluster(registry, "myproj", members=["a"] * 12, oversized=True) + out = _candidate_blueprints_results(registry, "myproj") + assert out[0]["oversized"] is True + + def test_two_perspectives_are_distinct_entries(self, registry): + _seed_cluster(registry, "myproj", perspective="physical", name="core") + _seed_cluster(registry, "myproj", perspective="deployment", name="core") + out = _candidate_blueprints_results(registry, "myproj") + keys = {(bp["perspective"], bp["cluster_name"]) for bp in out} + assert keys == {("physical", "core"), ("deployment", "core")} + + def test_later_survey_supersedes_earlier_for_same_identity(self, registry): + _seed_cluster(registry, "myproj", members=["a"], surveyed_at="2026-09-01T00:00:00") + _seed_cluster(registry, "myproj", members=["a", "b", "c"], surveyed_at="2026-09-03T00:00:00") + out = _candidate_blueprints_results(registry, "myproj") + assert len(out) == 1 + assert out[0]["members"] == ["a", "b", "c"] + + def test_verdict_and_materialization_are_merged_on(self, registry): + _seed_cluster(registry, "myproj", members=["a"]) + registry.record_component_verdict( + "repo", "myproj", "physical::core-services", "accepted", "", "", + verdict_target="blueprint", + ) + registry.record_materialized_blueprint( + "repo", "myproj", "physical", "core-services", + "SolutionBlueprint::repo::myproj::physical::core-services", "bp-guid-1", + ) + out = _candidate_blueprints_results(registry, "myproj") + assert out[0]["verdict"]["verdict"] == "accepted" + assert out[0]["materialized"]["guid"] == "bp-guid-1" + + def test_a_child_cluster_carries_its_own_entry(self, registry): + registry.upsert_finding("myproj", "architecture_blueprints", [{ + "check_name": "candidate_blueprint", "label": "backend", + "detail": {"name": "backend", "perspective": "physical", "signal": "path", + "carrier": "", "composed_into": "", "size": 2, + "members": ["a", "b"], "children": [], "parent": "core", + "oversized": False, "target_size": 8, "run_scope": "", + "not_a_claim": True}, + }], surveyed_at="2026-09-03T00:00:00") + out = _candidate_blueprints_results(registry, "myproj") + assert out[0]["parent"] == "core" + + def test_component_verdicts_do_not_leak_into_blueprint_results(self, registry): + registry.upsert_finding("myproj", "architecture_recovery", [{ + "check_name": "component", "label": "manifest", + "detail": {"name": "web", "slug": "web", "type": "Software Service"}, + }], surveyed_at="2026-09-03T00:00:00", scope_locator="src/web") + registry.record_component_verdict("repo", "myproj", "src/web", "accepted", "", "") + _seed_cluster(registry, "myproj", members=["web"]) + out = _candidate_blueprints_results(registry, "myproj") + assert out[0]["verdict"] is None + + +class TestArchitectureRecoveryResultsCarriesBlueprints: + def test_blueprints_key_present_and_empty_by_default(self, registry): + registry.upsert_finding("myproj", "architecture_recovery", [{ + "check_name": "component", "label": "manifest", + "detail": {"name": "web", "slug": "web", "type": "Software Service"}, + }], surveyed_at="2026-09-03T00:00:00", scope_locator="src/web") + result = _architecture_recovery_results(registry, "myproj") + assert result["blueprints"] == [] + + def test_blueprints_key_carries_seeded_clusters(self, registry): + registry.upsert_finding("myproj", "architecture_recovery", [{ + "check_name": "component", "label": "manifest", + "detail": {"name": "web", "slug": "web", "type": "Software Service"}, + }], surveyed_at="2026-09-03T00:00:00", scope_locator="src/web") + _seed_cluster(registry, "myproj", members=["web"]) + result = _architecture_recovery_results(registry, "myproj") + assert len(result["blueprints"]) == 1 + assert result["blueprints"][0]["cluster_name"] == "core-services" diff --git a/packages/resource-explorer/tests/test_egeria_outbox.py b/packages/resource-explorer/tests/test_egeria_outbox.py index 5a81fcba0..b506f568e 100644 --- a/packages/resource-explorer/tests/test_egeria_outbox.py +++ b/packages/resource-explorer/tests/test_egeria_outbox.py @@ -411,6 +411,35 @@ def test_enqueue_records_the_member_for_the_publish_queue(self, db, project): assert {json.loads(r["payload_json"])["member_entity_slug"] for r in rows} == {"p", "q"} +class TestEnqueueBlueprintMembers: + """docs/blueprint-materialization-plan.md Phase B. Parallel to + enqueue_collection_members above but for a repo's own blueprint (not an + investigation) β€” reuses element_kind='collection_membership' unchanged, + since CollectionMembership doesn't distinguish which kind of Collection + it's attaching to.""" + + def test_enqueues_one_row_per_member_reusing_collection_membership(self, db, project): + from resource_explorer.egeria_outbox import enqueue_blueprint_members + + ids = enqueue_blueprint_members( + db, "repo", project, "bp-guid-1", ["comp-guid-a", "comp-guid-b"], run_id="r2", + ) + assert len(ids) == 2 + rows = db.list_outbox_elements(run_id="r2") + assert {r["element_kind"] for r in rows} == {"collection_membership"} + assert {r["entity_type"] for r in rows} == {"repo"} + assert {r["entity_slug"] for r in rows} == {project} + payloads = [json.loads(r["payload_json"]) for r in rows] + assert {p["collection_guid"] for p in payloads} == {"bp-guid-1"} + assert {p["member_guid"] for p in payloads} == {"comp-guid-a", "comp-guid-b"} + + def test_empty_member_list_enqueues_nothing(self, db, project): + from resource_explorer.egeria_outbox import enqueue_blueprint_members + + ids = enqueue_blueprint_members(db, "repo", project, "bp-guid-1", []) + assert ids == [] + + class TestNothingWrittenIsNotOneState: """Three different outcomes all look like "no elements were written". diff --git a/packages/resource-explorer/tests/test_web.py b/packages/resource-explorer/tests/test_web.py index 6a896594e..ac2e42e4c 100644 --- a/packages/resource-explorer/tests/test_web.py +++ b/packages/resource-explorer/tests/test_web.py @@ -501,6 +501,201 @@ def test_accepting_a_real_component_materializes_it(self, client, registry): } +class TestCurateBlueprintVerdictsRouter: + """docs/blueprint-materialization-plan.md Phase B. Wires are deliberately + out of scope (project-owner decision, 2026-09-03) β€” none of these tests + assert on wire enqueueing, and BlueprintMaterializer is mocked so no + live Egeria call happens regardless.""" + + def _seed_cluster(self, registry, *, members=None, children=None, oversized=False, + name="core-services", perspective="physical"): + registry.upsert_finding("myproj", "architecture_blueprints", [{ + "check_name": "candidate_blueprint", "label": name, + "detail": {"name": name, "perspective": perspective, "signal": "compose", + "carrier": "compose.yaml", "composed_into": "", + "size": len(members or []), "members": members or [], + "children": children or [], "parent": "", "oversized": oversized, + "target_size": 8, "run_scope": "", "not_a_claim": True}, + }], surveyed_at="2026-08-30T00:00:00") + + def test_add_and_list_verdict(self, client): + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + assert resp.status_code == 200 + assert resp.json()["verdict"] == "accepted" + + listed = client.get("/api/curate/blueprint-verdicts/repo/myproj").json() + assert listed["physical::core-services"]["verdict"] == "accepted" + + def test_component_verdicts_and_blueprint_verdicts_do_not_leak_into_each_other(self, client): + client.post("/api/curate/component-verdicts/repo/myproj", json={ + "scope_locator": "src/foo", "verdict": "accepted", + }) + client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + components = client.get("/api/curate/component-verdicts/repo/myproj").json() + blueprints = client.get("/api/curate/blueprint-verdicts/repo/myproj").json() + assert "physical::core-services" not in components + assert "src/foo" not in blueprints + + def test_rejects_empty_perspective_or_cluster_name(self, client): + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": " ", "cluster_name": "core-services", "verdict": "accepted", + }) + assert resp.status_code == 400 + + def test_rejects_unknown_verdict(self, client): + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "retyped", + }) + assert resp.status_code == 400 + + def test_rejected_never_attempts_materialization(self, client, registry): + self._seed_cluster(registry, members=["a"]) + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "rejected", + }) + assert "materialization" not in resp.json() + + def test_accepting_with_no_underlying_cluster_reports_a_materialization_error(self, client): + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "never-clustered", "verdict": "accepted", + }) + assert resp.status_code == 200 + assert resp.json()["verdict"] == "accepted" + assert resp.json()["materialization"]["status"] == "error" + + def test_accepting_with_every_member_already_materialized_is_fully_materialized( + self, client, registry, + ): + registry.upsert_finding("myproj", "architecture_recovery", [{ + "check_name": "component", "label": "manifest", + "detail": {"name": "web", "slug": "web", "type": "Software Service"}, + }], surveyed_at="2026-08-30T00:00:00", scope_locator="src/web") + registry.record_materialized_component( + "repo", "myproj", "src/web", + "SolutionComponent::repo::myproj::src/web", "guid-web", + ) + self._seed_cluster(registry, members=["web"]) + + with patch("resource_explorer.surveyors.arch_recovery.blueprint_materializer." + "BlueprintMaterializer") as MockCls: + instance = MockCls.return_value + instance.materialize_blueprint_element.return_value = { + "status": "materialized", "guid": "bp-guid-1", + "qualified_name": "SolutionBlueprint::repo::myproj::physical::core-services", + } + instance.resolve_member_guids.return_value = ({"web": "guid-web"}, []) + instance.resolve_child_blueprint_guids.return_value = ({}, []) + + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + + assert resp.status_code == 200 + materialization = resp.json()["materialization"] + assert materialization["status"] == "materialized" + assert materialization["guid"] == "bp-guid-1" + assert "unmaterialized_members" not in materialization + assert materialization["enqueued_membership_rows"] == 1 + + element_call = instance.materialize_blueprint_element.call_args + assert element_call.args == ("repo", "myproj", "physical", "core-services") + assert element_call.kwargs["display_name"] == "core-services" + + def test_partial_member_materialization_reports_unmaterialized_members_and_still_enqueues( + self, client, registry, + ): + """Decision 2: accepting a blueprint does NOT implicitly materialize + its members. A curator sees exactly which ones still need their own + accept, and the ones that ARE ready still get attached β€” partial + progress, not all-or-nothing.""" + self._seed_cluster(registry, members=["web", "db"]) + + with patch("resource_explorer.surveyors.arch_recovery.blueprint_materializer." + "BlueprintMaterializer") as MockCls: + instance = MockCls.return_value + instance.materialize_blueprint_element.return_value = { + "status": "materialized", "guid": "bp-guid-2", + "qualified_name": "SolutionBlueprint::repo::myproj::physical::core-services", + } + instance.resolve_member_guids.return_value = ({"web": "guid-web"}, ["db"]) + instance.resolve_child_blueprint_guids.return_value = ({}, []) + + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + + materialization = resp.json()["materialization"] + assert materialization["status"] == "partial" + assert materialization["unmaterialized_members"] == ["db"] + assert materialization["enqueued_membership_rows"] == 1 # only "web", the ready one + + def test_two_level_cluster_resolves_child_blueprint_by_name(self, client, registry): + """Decision 1: a parent blueprint attaches only children that already + have their own materialized SolutionBlueprint.""" + self._seed_cluster(registry, members=[], children=["core-services-sub"], + name="core-services") + + with patch("resource_explorer.surveyors.arch_recovery.blueprint_materializer." + "BlueprintMaterializer") as MockCls: + instance = MockCls.return_value + instance.materialize_blueprint_element.return_value = { + "status": "materialized", "guid": "bp-guid-parent", + "qualified_name": "SolutionBlueprint::repo::myproj::physical::core-services", + } + instance.resolve_member_guids.return_value = ({}, []) + instance.resolve_child_blueprint_guids.return_value = ({"core-services-sub": "bp-guid-child"}, []) + + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + + assert resp.json()["materialization"]["enqueued_membership_rows"] == 1 + child_call = instance.resolve_child_blueprint_guids.call_args + assert child_call.args[-1] == ["core-services-sub"] + + def test_oversized_flag_is_passed_through_to_the_materializer(self, client, registry): + self._seed_cluster(registry, members=["web"], oversized=True) + + with patch("resource_explorer.surveyors.arch_recovery.blueprint_materializer." + "BlueprintMaterializer") as MockCls: + instance = MockCls.return_value + instance.materialize_blueprint_element.return_value = { + "status": "materialized", "guid": "bp-guid-3", "qualified_name": "x", + } + instance.resolve_member_guids.return_value = ({}, ["web"]) + instance.resolve_child_blueprint_guids.return_value = ({}, []) + + client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + + assert instance.materialize_blueprint_element.call_args.kwargs["oversized"] is True + + def test_egeria_unreachable_still_saves_the_verdict(self, client, registry): + """Same non-fatal-but-visible contract as the component route: the + verdict is real regardless of whether Egeria could be reached just + now.""" + self._seed_cluster(registry, members=["web"]) + from resource_explorer.surveyors.arch_recovery.blueprint_materializer import ( + BlueprintMaterializationError, + ) + with patch("resource_explorer.surveyors.arch_recovery.blueprint_materializer." + "BlueprintMaterializer") as MockCls: + MockCls.return_value.materialize_blueprint_element.side_effect = \ + BlueprintMaterializationError("Could not connect to Egeria") + resp = client.post("/api/curate/blueprint-verdicts/repo/myproj", json={ + "perspective": "physical", "cluster_name": "core-services", "verdict": "accepted", + }) + + assert resp.status_code == 200 + assert resp.json()["verdict"] == "accepted" + assert "Could not connect to Egeria" in resp.json()["materialization"]["error"] + + # ── /api/schedules β€” per-resource + global overview ───────────────────────────── class TestSchedulesRouter: