generated from coulomb/repo-seed
Compare commits
5 Commits
251637c1b4
...
d31b1376c8
| Author | SHA1 | Date | |
|---|---|---|---|
| d31b1376c8 | |||
| d2056c9046 | |||
| 91f329f878 | |||
| eec7e2a9f6 | |||
| ae4f567a2b |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Kaizen Agents
|
||||||
|
|
||||||
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
|
|
||||||
|
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||||
|
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||||
|
|
||||||
|
Common agents:
|
||||||
|
|
||||||
|
| Agent | Category | When to use |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||||
|
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||||
|
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||||
|
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||||
|
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||||
|
| `project-management` | process | Track status, determine next steps |
|
||||||
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## First Session Protocol
|
||||||
|
|
||||||
|
Triggered when `get_domain_summary("railiance")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/railiance/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/railiance/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
|
**Step 2 — Survey in-progress work**
|
||||||
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
|
```
|
||||||
|
workplans/railiance-fabric-WP-NNNN-<slug>.md ← write this first
|
||||||
|
```
|
||||||
|
Then register in the hub:
|
||||||
|
```
|
||||||
|
create_workstream(topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38", title="...", owner="...", description="...")
|
||||||
|
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5 — Record the setup**
|
||||||
|
```
|
||||||
|
add_progress_event(
|
||||||
|
summary="First session: structured railiance into N workstreams, M tasks",
|
||||||
|
event_type="milestone",
|
||||||
|
topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38",
|
||||||
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Delete or archive this file once past first session -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Repo boundary
|
||||||
|
|
||||||
|
This repo owns **railiance-fabric** only. It does not own:
|
||||||
|
|
||||||
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
|
- SSH key management → railiance-infra/
|
||||||
|
- State hub code → state-hub/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**Purpose:** railiance-fabric - (fill in purpose)
|
||||||
|
|
||||||
|
**Domain:** railiance
|
||||||
|
**Repo slug:** railiance-fabric
|
||||||
|
**Topic ID:** ca369340-a64e-442e-98f1-a4fa7dc74a38
|
||||||
84
.claude/rules/session-protocol.md
Normal file
84
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
State Hub: http://127.0.0.1:8000
|
||||||
|
|
||||||
|
**Step 1 — Orient**
|
||||||
|
|
||||||
|
Read the offline-safe brief first — it works without a live hub connection:
|
||||||
|
```bash
|
||||||
|
cat .custodian-brief.md
|
||||||
|
```
|
||||||
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
|
```
|
||||||
|
get_domain_summary("railiance")
|
||||||
|
```
|
||||||
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
|
**Step 2 — Check inbox**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
get_messages(to_agent="railiance-fabric", unread_only=True)
|
||||||
|
```
|
||||||
|
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||||
|
requests before proceeding.
|
||||||
|
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=railiance-fabric&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Scan workplans**
|
||||||
|
```bash
|
||||||
|
ls workplans/
|
||||||
|
```
|
||||||
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`todo`/`in_progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `railiance` — title, task counts, blocking decisions
|
||||||
|
2. **Pending tasks** from `workplans/` + any `[repo:railiance-fabric]` hub tasks
|
||||||
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||||
|
4. **Suggested next action** — highest-priority open item
|
||||||
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
|
||||||
|
**Session close:**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", topic_id="ca369340-a64e-442e-98f1-a4fa7dc74a38", workstream_id="<uuid>")
|
||||||
|
```
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic_id":"ca369340-a64e-442e-98f1-a4fa7dc74a38","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||||
|
```
|
||||||
|
If workplan files were modified, ensure the local copy is up to date first:
|
||||||
|
```bash
|
||||||
|
git -C <repo_path> pull --ff-only
|
||||||
|
cd ~/state-hub && make fix-consistency REPO=railiance-fabric
|
||||||
|
```
|
||||||
|
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||||
|
use the combined target which pulls before fixing:
|
||||||
|
```bash
|
||||||
|
cd ~/state-hub && make fix-consistency-remote REPO=railiance-fabric
|
||||||
|
```
|
||||||
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
|
- **Language:**
|
||||||
|
- **Key deps:**
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TODO: Fill in the standard commands for this repo
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
# Lint / type check
|
||||||
|
|
||||||
|
# Build / package (if applicable)
|
||||||
|
```
|
||||||
28
.claude/rules/workplan-convention.md
Normal file
28
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/railiance-fabric-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `RAILIANCE-WP`
|
||||||
|
|
||||||
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
|
prefix: `YYMMDD-railiance-fabric-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|
||||||
|
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||||
|
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||||
|
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||||
|
multiple planned phases into a normal workplan.
|
||||||
|
|
||||||
|
Ecosystem todos from other agents arrive as `[repo:railiance-fabric]` hub tasks —
|
||||||
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
|
the workstream.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
24
AGENTS.md
24
AGENTS.md
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## Repo Identity
|
## Repo Identity
|
||||||
|
|
||||||
**Purpose:** Railiance Fabric defines the repo-owned declaration model, validation tooling, graph queries, and State Hub export contract for the Railiance ecosystem graph.
|
**Purpose:** railiance-fabric - (fill in purpose)
|
||||||
|
|
||||||
**Domain:** railiance
|
**Domain:** railiance
|
||||||
**Repo slug:** railiance-fabric
|
**Repo slug:** railiance-fabric
|
||||||
**Topic ID:** `ca369340-a64e-442e-98f1-a4fa7dc74a38`
|
**Topic ID:** `ca369340-a64e-442e-98f1-a4fa7dc74a38`
|
||||||
**Workplan prefix:** `RAIL-FAB-WP-`
|
**Workplan prefix:** `RAILIANCE-WP-`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
**Start:**
|
**Start:**
|
||||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
2. Check inbox: `GET /messages/?to_agent=railiance-fabric&unread_only=true`; mark read
|
2. Check inbox: `GET /messages/?to_agent=railiance-fabric&unread_only=true`; mark read
|
||||||
3. Scan workplans: `ls workplans/` — note `status: active` files and open tasks
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
**During work:**
|
**During work:**
|
||||||
@@ -93,7 +93,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
1. Update workplan file task statuses to reflect progress
|
1. Update workplan file task statuses to reflect progress
|
||||||
2. Log: `POST /progress/` with a summary of what changed
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
3. Note for the custodian operator: after workplan file changes, run from
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
`~/the-custodian/state-hub`:
|
`~/state-hub`:
|
||||||
```bash
|
```bash
|
||||||
make fix-consistency REPO=railiance-fabric
|
make fix-consistency REPO=railiance-fabric
|
||||||
```
|
```
|
||||||
@@ -106,10 +106,10 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
Work items originate as files in this repo — not in the hub. The hub is a
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
read/cache/index layer that rebuilds from files.
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
**File location:** `workplans/RAIL-FAB-WP-NNNN-<slug>.md`
|
**File location:** `workplans/RAILIANCE-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
**Archived location:** completed workplans may move to
|
**Archived location:** finished workplans may move to
|
||||||
`workplans/archived/YYMMDD-RAIL-FAB-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
`workplans/archived/YYMMDD-RAILIANCE-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
the completion/archive date; the frontmatter `id` does not change.
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
@@ -121,12 +121,12 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
id: RAIL-FAB-WP-NNNN
|
id: RAILIANCE-WP-NNNN
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "..."
|
title: "..."
|
||||||
domain: railiance
|
domain: railiance
|
||||||
repo: railiance-fabric
|
repo: railiance-fabric
|
||||||
status: active | done
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: ...
|
topic_slug: ...
|
||||||
created: "YYYY-MM-DD"
|
created: "YYYY-MM-DD"
|
||||||
@@ -135,13 +135,17 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
**Task block format** (one per `##` section):
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
```
|
```
|
||||||
## Task Title
|
## Task Title
|
||||||
|
|
||||||
` ` `task
|
` ` `task
|
||||||
id: RAIL-FAB-WP-NNNN-T01
|
id: RAILIANCE-WP-NNNN-T01
|
||||||
status: todo | in_progress | done | blocked
|
status: todo | in_progress | done | blocked
|
||||||
priority: high | medium | low
|
priority: high | medium | low
|
||||||
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
|||||||
11
CLAUDE.md
Normal file
11
CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# railiance-fabric — Claude Code Instructions
|
||||||
|
|
||||||
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/repo-boundary.md
|
||||||
|
@.claude/rules/agents.md
|
||||||
@@ -35,6 +35,7 @@ railiance-fabric unresolved
|
|||||||
railiance-fabric blast-radius openbao-kv-v2-mount
|
railiance-fabric blast-radius openbao-kv-v2-mount
|
||||||
railiance-fabric export --format json
|
railiance-fabric export --format json
|
||||||
railiance-fabric export --format mermaid
|
railiance-fabric export --format mermaid
|
||||||
|
railiance-fabric export --format graph-explorer
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/discovery-queries.md` for command details.
|
See `docs/discovery-queries.md` for command details.
|
||||||
@@ -94,7 +95,15 @@ GET /repositories/{repo_slug}/inventory
|
|||||||
GET /repositories/{repo_slug}/snapshots
|
GET /repositories/{repo_slug}/snapshots
|
||||||
GET /repositories/{repo_slug}/snapshots/diff
|
GET /repositories/{repo_slug}/snapshots/diff
|
||||||
GET /search?q=jsonschema
|
GET /search?q=jsonschema
|
||||||
|
GET /ui/graph-explorer
|
||||||
|
GET /exports/graph-explorer/manifest
|
||||||
|
GET /exports/graph-explorer
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/registry-onboarding.md` for the multi-repo manifest and operating
|
See `docs/registry-onboarding.md` for the multi-repo manifest and operating
|
||||||
loop.
|
loop.
|
||||||
|
|
||||||
|
The graph explorer export is the first executable slice of the interactive
|
||||||
|
Fabric map. See `docs/graph-explorer-transfer-review.md` for the repo-scoping
|
||||||
|
transfer review and `docs/graph-explorer-contract.md` for the shared manifest
|
||||||
|
and payload contract.
|
||||||
|
|||||||
@@ -78,8 +78,19 @@ Export the graph as Mermaid:
|
|||||||
railiance-fabric export --format mermaid
|
railiance-fabric export --format mermaid
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Export the graph as the manifest-compatible graph explorer payload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
railiance-fabric export --format graph-explorer
|
||||||
|
```
|
||||||
|
|
||||||
The JSON export has two top-level arrays:
|
The JSON export has two top-level arrays:
|
||||||
|
|
||||||
- `nodes`: service, capability, interface, dependency, and binding nodes
|
- `nodes`: service, capability, interface, dependency, and binding nodes
|
||||||
- `edges`: graph relationships such as `provides`, `exposes`,
|
- `edges`: graph relationships such as `provides`, `exposes`,
|
||||||
`available_via`, `consumes`, `binds:<status>`, and `uses_interface`
|
`available_via`, `consumes`, `binds:<status>`, and `uses_interface`
|
||||||
|
|
||||||
|
The graph explorer payload wraps those nodes and edges as Cytoscape-compatible
|
||||||
|
elements with stable keys, layers, display state, visual facets, source
|
||||||
|
references, and deep links. The registry service exposes the same projection at
|
||||||
|
`GET /exports/graph-explorer`.
|
||||||
|
|||||||
152
docs/graph-explorer-contract.md
Normal file
152
docs/graph-explorer-contract.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Graph Explorer Contract
|
||||||
|
|
||||||
|
This note defines the first manifest and payload contract for the interactive
|
||||||
|
Fabric map and the possible reusable graph explorer engine.
|
||||||
|
|
||||||
|
The contract is intentionally host-neutral. Fabric and repo-scoping should be
|
||||||
|
able to use the same interaction shell by exposing a manifest and a graph
|
||||||
|
payload with stable fields.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `schemas/graph-explorer-manifest.schema.yaml` validates a host manifest.
|
||||||
|
- `schemas/graph-explorer-payload.schema.yaml` validates graph payloads.
|
||||||
|
- `railiance_fabric.graph_explorer` provides the first Fabric registry
|
||||||
|
manifest and payload projection.
|
||||||
|
|
||||||
|
## Registry Endpoints
|
||||||
|
|
||||||
|
The registry service exposes the first Fabric projection:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /ui/graph-explorer
|
||||||
|
GET /exports/graph-explorer/manifest
|
||||||
|
GET /exports/graph-explorer
|
||||||
|
```
|
||||||
|
|
||||||
|
The local CLI can emit the same payload for repo-local inspection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
railiance-fabric export --format graph-explorer
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest tells a graph shell where to load data, which fields are stable,
|
||||||
|
which layers exist, which filter fields are available, and which modes the host
|
||||||
|
supports.
|
||||||
|
|
||||||
|
The payload is compatible with Cytoscape-style element arrays:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiVersion": "railiance.fabric/v1alpha1",
|
||||||
|
"kind": "GraphExplorerPayload",
|
||||||
|
"manifest_id": "railiance-fabric.registry-map",
|
||||||
|
"mode": "full",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "repo:railiance-fabric",
|
||||||
|
"stableKey": "repo:railiance-fabric",
|
||||||
|
"kind": "Repository",
|
||||||
|
"layer": "repository",
|
||||||
|
"label": "Railiance Fabric",
|
||||||
|
"displayState": "show"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden_elements": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Payload Semantics
|
||||||
|
|
||||||
|
Every element must include:
|
||||||
|
|
||||||
|
- `id`: the current element id used by the graph library.
|
||||||
|
- `stableKey`: the durable id used by profile rules, manual overrides, layout
|
||||||
|
state, and deep links.
|
||||||
|
- `kind`: host-specific entity kind.
|
||||||
|
- `layer`: host-declared layer used for layout, grouping, and color.
|
||||||
|
- `displayState`: one of `show`, `blur`, or `hide`.
|
||||||
|
|
||||||
|
Edges are ordinary elements whose data also includes:
|
||||||
|
|
||||||
|
- `source`
|
||||||
|
- `target`
|
||||||
|
- `edgeType`
|
||||||
|
- `strength`
|
||||||
|
- `sameLayer`
|
||||||
|
|
||||||
|
Hosts should also include useful optional fields when available: `label`,
|
||||||
|
`name`, `description`, `repo`, `domain`, `lifecycle`, `reviewState`,
|
||||||
|
`freshnessState`, `confidence`, `visualSize`, `ownership`, `unresolved`,
|
||||||
|
`sourceReferences`, and `deepLinks`.
|
||||||
|
|
||||||
|
## Display State Ownership
|
||||||
|
|
||||||
|
The contract allows either the host service or the engine to evaluate display
|
||||||
|
state.
|
||||||
|
|
||||||
|
The precedence rule is fixed:
|
||||||
|
|
||||||
|
1. Default element state is `show`.
|
||||||
|
2. Rules are applied in list order; later matching rules override earlier
|
||||||
|
matching rules.
|
||||||
|
3. Manual overrides win last.
|
||||||
|
4. Edges connected to hidden nodes are hidden.
|
||||||
|
5. Edges connected to blurred nodes may carry a contextual muted class or data
|
||||||
|
hint.
|
||||||
|
|
||||||
|
Repo-scoping currently evaluates this host-side. A future extracted engine can
|
||||||
|
evaluate it client-side for static exports, but host-provided `displayState`
|
||||||
|
must remain valid input.
|
||||||
|
|
||||||
|
## Fabric Layers
|
||||||
|
|
||||||
|
The first Fabric manifest declares:
|
||||||
|
|
||||||
|
| Layer | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `repository` | Registered source repositories, including registered-only repos. |
|
||||||
|
| `service` | Service declarations. |
|
||||||
|
| `capability` | Capability declarations. |
|
||||||
|
| `interface` | Interface declarations. |
|
||||||
|
| `dependency` | Dependency declarations, including unresolved dependency nodes. |
|
||||||
|
| `binding` | Binding assertions between consumer dependencies and providers. |
|
||||||
|
| `library` | Future library/SBOM inventory nodes. |
|
||||||
|
|
||||||
|
## Repo-Scoping Compatibility
|
||||||
|
|
||||||
|
Repo-scoping can adapt without a rewrite because its current graph payload
|
||||||
|
already exposes most required fields:
|
||||||
|
|
||||||
|
- `id`, `stableKey`, `kind`, `layer`, labels, and metadata-rich data objects.
|
||||||
|
- `displayState`, `visibilitySource`, and `visibilityReason`.
|
||||||
|
- edge `source`, `target`, `dependencyType`, `strength`, `sameLayer`, and
|
||||||
|
visual width.
|
||||||
|
- profile data, filter rules, manual overrides, hidden elements, and orphaned
|
||||||
|
overrides.
|
||||||
|
|
||||||
|
The main adapter work is manifest generation and small field aliases:
|
||||||
|
`dependencyType` can map to `edgeType`, repo-scoping layers become manifest
|
||||||
|
layers, and existing profile endpoints can be listed under manifest
|
||||||
|
`endpoints.profiles`.
|
||||||
|
|
||||||
|
## Extraction Boundary
|
||||||
|
|
||||||
|
The extracted `graph-explorer-engine` should own:
|
||||||
|
|
||||||
|
- graph rendering and layout controls
|
||||||
|
- filter and manual override UI
|
||||||
|
- hover popups and selection detail panels
|
||||||
|
- profile UI when the host declares profile endpoints
|
||||||
|
- URL state and copied state blobs
|
||||||
|
- schema definitions and compatibility tests
|
||||||
|
|
||||||
|
Host repos should own:
|
||||||
|
|
||||||
|
- graph projection and metadata enrichment
|
||||||
|
- profile persistence
|
||||||
|
- authentication and authorization
|
||||||
|
- domain-specific graph modes
|
||||||
|
- deep links back to source systems
|
||||||
84
docs/graph-explorer-transfer-review.md
Normal file
84
docs/graph-explorer-transfer-review.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Graph Explorer Transfer Review
|
||||||
|
|
||||||
|
This note closes the first implementation step for `RAIL-FAB-WP-0008-T01`.
|
||||||
|
It reviews the repo-scoping dependency graph work from `RREG-WP-0010` and
|
||||||
|
`RREG-WP-0011` and turns the transferable parts into Fabric requirements.
|
||||||
|
|
||||||
|
## Source Implementation
|
||||||
|
|
||||||
|
Repo-scoping already has a useful graph explorer shape:
|
||||||
|
|
||||||
|
- `docs/adr-dependency-graph-visualization-framework.md` chooses Cytoscape.js
|
||||||
|
because the required interaction model is graph-native: pan, zoom, selection,
|
||||||
|
layouts, styling, filtering, and path exploration.
|
||||||
|
- `docs/dependency-visualization-exploration.md` defines layered graph data,
|
||||||
|
`show` / `blur` / `hide` display states, deterministic filters, manual
|
||||||
|
overrides, and saved view profiles.
|
||||||
|
- `src/repo_scoping/core/service.py` emits Cytoscape-compatible payloads with
|
||||||
|
stable keys, metadata-rich node and edge data, visibility state, hidden
|
||||||
|
elements, profile application, confidence sizing, and edge strength sizing.
|
||||||
|
- `src/repo_scoping/web_ui/views.py` provides the first UI shell: profile
|
||||||
|
controls, structured filters, manual overrides, focus depth, hover popups,
|
||||||
|
selection details, and a layered layout.
|
||||||
|
- `tests/test_web_api.py` verifies graph endpoints, ad hoc filters, profile
|
||||||
|
creation, duplicate profiles, latest-profile defaulting, and UI wiring.
|
||||||
|
|
||||||
|
## Reusable Behaviors
|
||||||
|
|
||||||
|
Fabric should carry these behaviors into the shared graph explorer contract:
|
||||||
|
|
||||||
|
- Cytoscape-compatible element arrays with `data` and optional `classes`.
|
||||||
|
- Stable element keys that survive refreshes, so layout state, profile rules,
|
||||||
|
and deep links do not churn.
|
||||||
|
- Metadata-rich nodes and edges rather than UI-only labels.
|
||||||
|
- Explicit `layer`, `kind`, `reviewState`, `freshnessState`, and
|
||||||
|
`displayState` fields.
|
||||||
|
- Visibility actions of `show`, `blur`, and `hide`, with later rules overriding
|
||||||
|
earlier rules and manual overrides winning last.
|
||||||
|
- Hidden element reporting, so over-filtered views are recoverable.
|
||||||
|
- Edge treatment for context-muted nodes.
|
||||||
|
- View profiles that can persist filter rules and manual overrides when a host
|
||||||
|
provides profile endpoints.
|
||||||
|
- Hover popups for compact inspection and selection panels for full detail.
|
||||||
|
- Layout modes for full graph, impact or filtered graph, selected path, and
|
||||||
|
neighborhood focus.
|
||||||
|
- Visual hints for confidence, strength, same-layer edges, stale or unresolved
|
||||||
|
state, and review state.
|
||||||
|
|
||||||
|
## Adapter-Owned Semantics
|
||||||
|
|
||||||
|
These repo-scoping concepts should remain in the repo-scoping adapter and not
|
||||||
|
be hard-coded into the engine:
|
||||||
|
|
||||||
|
- Layer names of `fact`, `evidence`, `feature`, `capability`, `ability`, and
|
||||||
|
`scope`.
|
||||||
|
- Candidate graph approval semantics.
|
||||||
|
- Dependency impact analysis over base and target analysis runs.
|
||||||
|
- Document fact normalization and README/SCOPE de-duplication.
|
||||||
|
- Scope curation recommendations.
|
||||||
|
|
||||||
|
Fabric has its own adapter semantics:
|
||||||
|
|
||||||
|
- Layer names of `repository`, `service`, `capability`, `interface`,
|
||||||
|
`dependency`, `binding`, and `library`.
|
||||||
|
- Registered-only repositories without accepted graph snapshots.
|
||||||
|
- State Hub repo ids, workplan links, and registry endpoints as deep links.
|
||||||
|
- Unresolved dependencies where provider bindings are missing or disputed.
|
||||||
|
- Blast-radius and provider-chain exploration across accepted Fabric
|
||||||
|
declarations.
|
||||||
|
|
||||||
|
## Extraction Recommendation
|
||||||
|
|
||||||
|
Start in `railiance-fabric` with a host-neutral manifest and payload contract,
|
||||||
|
plus a Fabric registry projection. The first UI shell can live locally while
|
||||||
|
the contract is still moving.
|
||||||
|
|
||||||
|
Extract into a separate repository only after two host adapters are proven:
|
||||||
|
|
||||||
|
1. Fabric registry map adapter.
|
||||||
|
2. Repo-scoping dependency graph adapter.
|
||||||
|
|
||||||
|
The likely extracted repository is `graph-explorer-engine`. It should own the
|
||||||
|
static interaction shell, schema definitions, layout/filter/profile client
|
||||||
|
logic, and adapter manifest contract. Host repos should keep graph projection,
|
||||||
|
profile persistence, authentication, and domain-specific deep links.
|
||||||
@@ -64,4 +64,7 @@ GET /exports/state-hub
|
|||||||
GET /exports/backstage
|
GET /exports/backstage
|
||||||
GET /exports/xregistry
|
GET /exports/xregistry
|
||||||
GET /exports/libraries/xregistry
|
GET /exports/libraries/xregistry
|
||||||
|
GET /ui/graph-explorer
|
||||||
|
GET /exports/graph-explorer/manifest
|
||||||
|
GET /exports/graph-explorer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .loader import declaration_files, load_yaml
|
from .loader import declaration_files, load_yaml
|
||||||
from .graph import FabricGraph, build_graph
|
from .graph import FabricGraph, build_graph
|
||||||
|
from .graph_explorer import fabric_graph_explorer_payload
|
||||||
from .validation import validate_roots
|
from .validation import validate_roots
|
||||||
|
|
||||||
|
|
||||||
@@ -57,9 +58,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
blast.add_argument("interface", help="Interface type or interface declaration id.")
|
blast.add_argument("interface", help="Interface type or interface declaration id.")
|
||||||
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||||
|
|
||||||
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.")
|
export = sub.add_parser("export", help="Export graph as JSON, Mermaid, or graph-explorer payload.")
|
||||||
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
|
||||||
export.add_argument("--format", choices=["json", "mermaid"], default="json")
|
export.add_argument("--format", choices=["json", "mermaid", "graph-explorer"], default="json")
|
||||||
|
|
||||||
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
|
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
|
||||||
registry_sub = registry.add_subparsers(dest="registry_command", required=True)
|
registry_sub = registry.add_subparsers(dest="registry_command", required=True)
|
||||||
@@ -131,7 +132,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
graph = _load_graph_or_exit(args.paths)
|
graph = _load_graph_or_exit(args.paths)
|
||||||
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json())
|
if args.format == "mermaid":
|
||||||
|
print(graph.to_mermaid())
|
||||||
|
elif args.format == "graph-explorer":
|
||||||
|
print(json.dumps(fabric_graph_explorer_payload(graph.to_export()), indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
print(graph.to_json())
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "registry":
|
if args.command == "registry":
|
||||||
|
|||||||
@@ -267,13 +267,16 @@ def _escape_mermaid(value: str) -> str:
|
|||||||
|
|
||||||
def _export_attributes(declaration: Declaration) -> dict[str, Any]:
|
def _export_attributes(declaration: Declaration) -> dict[str, Any]:
|
||||||
spec = declaration.spec
|
spec = declaration.spec
|
||||||
|
base = _base_export_attributes(declaration)
|
||||||
if declaration.kind == "ServiceDeclaration":
|
if declaration.kind == "ServiceDeclaration":
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"provides_capabilities": list(spec.get("provides_capabilities", [])),
|
"provides_capabilities": list(spec.get("provides_capabilities", [])),
|
||||||
"exposes_interfaces": list(spec.get("exposes_interfaces", [])),
|
"exposes_interfaces": list(spec.get("exposes_interfaces", [])),
|
||||||
}
|
}
|
||||||
if declaration.kind == "CapabilityDeclaration":
|
if declaration.kind == "CapabilityDeclaration":
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"capability_type": spec.get("capability_type", ""),
|
"capability_type": spec.get("capability_type", ""),
|
||||||
"service_id": spec.get("service_id", ""),
|
"service_id": spec.get("service_id", ""),
|
||||||
"interface_ids": list(spec.get("interface_ids", [])),
|
"interface_ids": list(spec.get("interface_ids", [])),
|
||||||
@@ -281,6 +284,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if declaration.kind == "InterfaceDeclaration":
|
if declaration.kind == "InterfaceDeclaration":
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"interface_type": spec.get("interface_type", ""),
|
"interface_type": spec.get("interface_type", ""),
|
||||||
"service_id": spec.get("service_id", ""),
|
"service_id": spec.get("service_id", ""),
|
||||||
"capability_ids": list(spec.get("capability_ids", [])),
|
"capability_ids": list(spec.get("capability_ids", [])),
|
||||||
@@ -291,6 +295,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
|
|||||||
requires = spec.get("requires", {})
|
requires = spec.get("requires", {})
|
||||||
interface = spec.get("interface", {})
|
interface = spec.get("interface", {})
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"consumer_service_id": spec.get("consumer_service_id", ""),
|
"consumer_service_id": spec.get("consumer_service_id", ""),
|
||||||
"requires_capability_id": requires.get("capability_id", ""),
|
"requires_capability_id": requires.get("capability_id", ""),
|
||||||
"requires_capability_type": requires.get("capability_type", ""),
|
"requires_capability_type": requires.get("capability_type", ""),
|
||||||
@@ -300,9 +305,20 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if declaration.kind == "BindingAssertion":
|
if declaration.kind == "BindingAssertion":
|
||||||
return {
|
return {
|
||||||
|
**base,
|
||||||
"dependency_id": spec.get("dependency_id", ""),
|
"dependency_id": spec.get("dependency_id", ""),
|
||||||
"provider_capability_id": spec.get("provider_capability_id", ""),
|
"provider_capability_id": spec.get("provider_capability_id", ""),
|
||||||
"provider_interface_id": spec.get("provider_interface_id", ""),
|
"provider_interface_id": spec.get("provider_interface_id", ""),
|
||||||
"status": spec.get("status", ""),
|
"status": spec.get("status", ""),
|
||||||
}
|
}
|
||||||
return {}
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _base_export_attributes(declaration: Declaration) -> dict[str, Any]:
|
||||||
|
source_links = declaration.metadata.get("source_links", [])
|
||||||
|
return {
|
||||||
|
"owner": declaration.metadata.get("owner", ""),
|
||||||
|
"description": declaration.spec.get("description", ""),
|
||||||
|
"source_path": str(declaration.path),
|
||||||
|
"source_links": source_links if isinstance(source_links, list) else [],
|
||||||
|
}
|
||||||
|
|||||||
521
railiance_fabric/graph_explorer.py
Normal file
521
railiance_fabric/graph_explorer.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DISPLAY_STATES = ("show", "blur", "hide")
|
||||||
|
LAYER_ORDER = (
|
||||||
|
"repository",
|
||||||
|
"service",
|
||||||
|
"capability",
|
||||||
|
"interface",
|
||||||
|
"dependency",
|
||||||
|
"binding",
|
||||||
|
"library",
|
||||||
|
)
|
||||||
|
|
||||||
|
_KIND_LAYER = {
|
||||||
|
"Repository": "repository",
|
||||||
|
"ServiceDeclaration": "service",
|
||||||
|
"CapabilityDeclaration": "capability",
|
||||||
|
"InterfaceDeclaration": "interface",
|
||||||
|
"DependencyDeclaration": "dependency",
|
||||||
|
"BindingAssertion": "binding",
|
||||||
|
"Library": "library",
|
||||||
|
}
|
||||||
|
|
||||||
|
_LAYER_COLORS = {
|
||||||
|
"repository": "#475569",
|
||||||
|
"service": "#0f766e",
|
||||||
|
"capability": "#2563eb",
|
||||||
|
"interface": "#7c3aed",
|
||||||
|
"dependency": "#b45309",
|
||||||
|
"binding": "#be123c",
|
||||||
|
"library": "#0891b2",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EDGE_STRENGTH = {
|
||||||
|
"provides": "strong",
|
||||||
|
"exposes": "strong",
|
||||||
|
"available_via": "medium",
|
||||||
|
"consumes": "medium",
|
||||||
|
"uses_interface": "medium",
|
||||||
|
"declares": "weak",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]:
|
||||||
|
"""Return the host manifest for the reusable graph explorer shell."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"apiVersion": "railiance.fabric/v1alpha1",
|
||||||
|
"kind": "GraphExplorerManifest",
|
||||||
|
"id": "railiance-fabric.registry-map",
|
||||||
|
"title": "Railiance Fabric Registry Map",
|
||||||
|
"description": "Manifest for exploring registered Fabric ecosystem entities.",
|
||||||
|
"engine": {
|
||||||
|
"suggested_package": "graph-explorer-engine",
|
||||||
|
"preferred_library": "cytoscape",
|
||||||
|
"display_state_owner": "host-or-engine",
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"graph": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": f"{base_url}/exports/graph-explorer",
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": f"{base_url}/exports/graph-explorer/manifest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"node_id_field": "id",
|
||||||
|
"stable_key_field": "stableKey",
|
||||||
|
"edge_id_field": "id",
|
||||||
|
"source_field": "source",
|
||||||
|
"target_field": "target",
|
||||||
|
},
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"id": layer,
|
||||||
|
"label": layer.replace("_", " ").title(),
|
||||||
|
"order": index,
|
||||||
|
"color": _LAYER_COLORS[layer],
|
||||||
|
}
|
||||||
|
for index, layer in enumerate(LAYER_ORDER)
|
||||||
|
],
|
||||||
|
"grouping_fields": ["domain", "repo", "layer", "kind", "lifecycle", "unresolved"],
|
||||||
|
"search_fields": [
|
||||||
|
"id",
|
||||||
|
"stableKey",
|
||||||
|
"label",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"repo",
|
||||||
|
"domain",
|
||||||
|
"kind",
|
||||||
|
"layer",
|
||||||
|
"edgeType",
|
||||||
|
],
|
||||||
|
"filter": {
|
||||||
|
"actions": list(DISPLAY_STATES),
|
||||||
|
"fields": [
|
||||||
|
{"id": "kind", "label": "Kind", "type": "string"},
|
||||||
|
{"id": "layer", "label": "Layer", "type": "string"},
|
||||||
|
{"id": "repo", "label": "Repo", "type": "string"},
|
||||||
|
{"id": "domain", "label": "Domain", "type": "string"},
|
||||||
|
{"id": "lifecycle", "label": "Lifecycle", "type": "string"},
|
||||||
|
{"id": "reviewState", "label": "Review State", "type": "string"},
|
||||||
|
{"id": "unresolved", "label": "Unresolved", "type": "boolean"},
|
||||||
|
{"id": "edgeType", "label": "Edge Type", "type": "string"},
|
||||||
|
{"id": "strength", "label": "Strength", "type": "string"},
|
||||||
|
{"id": "sameLayer", "label": "Same Layer", "type": "boolean"},
|
||||||
|
{"id": "text", "label": "Text", "type": "string"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"visual_encodings": {
|
||||||
|
"node_color": "layer",
|
||||||
|
"node_shape": "kind",
|
||||||
|
"node_size": "confidence",
|
||||||
|
"edge_width": "strength",
|
||||||
|
"edge_style": "sameLayer",
|
||||||
|
"edge_opacity": "displayState",
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"node_fields": [
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"layer",
|
||||||
|
"repo",
|
||||||
|
"domain",
|
||||||
|
"lifecycle",
|
||||||
|
"unresolved",
|
||||||
|
"description",
|
||||||
|
"sourceReferences",
|
||||||
|
"deepLinks",
|
||||||
|
],
|
||||||
|
"edge_fields": [
|
||||||
|
"edgeType",
|
||||||
|
"strength",
|
||||||
|
"source",
|
||||||
|
"target",
|
||||||
|
"sourceLayer",
|
||||||
|
"targetLayer",
|
||||||
|
"sameLayer",
|
||||||
|
"deepLinks",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"modes": [
|
||||||
|
{
|
||||||
|
"id": "full",
|
||||||
|
"label": "Full",
|
||||||
|
"description": "Show the complete registered ecosystem graph.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "selected-path",
|
||||||
|
"label": "Selected Path",
|
||||||
|
"description": "Show selected nodes with predecessor and successor context.",
|
||||||
|
"requires_selection": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "neighborhood",
|
||||||
|
"label": "Neighborhood",
|
||||||
|
"description": "Show the selected node neighborhood by configurable depth.",
|
||||||
|
"requires_selection": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "onboarding-gaps",
|
||||||
|
"label": "Onboarding Gaps",
|
||||||
|
"description": "Highlight registered repos without accepted Fabric graph snapshots.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "unresolved",
|
||||||
|
"label": "Unresolved",
|
||||||
|
"description": "Highlight dependencies that have no accepted provider binding.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"profile_persistence": "none",
|
||||||
|
"shareable_state": {
|
||||||
|
"url_parameters": True,
|
||||||
|
"profile_id": False,
|
||||||
|
"state_blob": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fabric_graph_explorer_payload(
|
||||||
|
graph: dict[str, Any],
|
||||||
|
repositories: list[dict[str, Any]] | None = None,
|
||||||
|
snapshot_repo_slugs: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Project a Fabric graph export into the shared graph explorer payload."""
|
||||||
|
|
||||||
|
source_nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
|
||||||
|
source_edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
||||||
|
repositories = repositories or []
|
||||||
|
snapshot_repo_slugs = snapshot_repo_slugs or set()
|
||||||
|
|
||||||
|
layers_by_id = {
|
||||||
|
str(node.get("id", "")): _layer_for_kind(str(node.get("kind", "")))
|
||||||
|
for node in source_nodes
|
||||||
|
}
|
||||||
|
source_repo_slugs = {
|
||||||
|
str(node.get("repo", "")).strip()
|
||||||
|
for node in source_nodes
|
||||||
|
if str(node.get("repo", "")).strip()
|
||||||
|
}
|
||||||
|
registered_repo_slugs = {
|
||||||
|
str(repo.get("slug", "")).strip()
|
||||||
|
for repo in repositories
|
||||||
|
if str(repo.get("slug", "")).strip()
|
||||||
|
}
|
||||||
|
repo_slugs = set(source_repo_slugs)
|
||||||
|
repo_slugs.update(registered_repo_slugs)
|
||||||
|
repo_slugs.update(snapshot_repo_slugs)
|
||||||
|
repo_slugs.discard("")
|
||||||
|
unresolved = _unresolved_dependency_ids(source_nodes, source_edges)
|
||||||
|
|
||||||
|
elements: list[dict[str, Any]] = []
|
||||||
|
repository_index = {str(repo.get("slug", "")): repo for repo in repositories}
|
||||||
|
for slug in sorted(repo_slugs):
|
||||||
|
repo = repository_index.get(slug, {})
|
||||||
|
has_snapshot = slug in snapshot_repo_slugs or slug in source_repo_slugs
|
||||||
|
is_registered = slug in registered_repo_slugs
|
||||||
|
repo_id = f"repo:{slug}"
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": repo_id,
|
||||||
|
"stableKey": repo_id,
|
||||||
|
"kind": "Repository",
|
||||||
|
"layer": "repository",
|
||||||
|
"label": str(repo.get("name") or slug),
|
||||||
|
"name": str(repo.get("name") or slug),
|
||||||
|
"description": (
|
||||||
|
"Registered repository with accepted Fabric graph snapshot."
|
||||||
|
if has_snapshot
|
||||||
|
else "Registered repository without an accepted Fabric graph snapshot."
|
||||||
|
),
|
||||||
|
"repo": slug,
|
||||||
|
"domain": "railiance",
|
||||||
|
"lifecycle": "active" if has_snapshot else "registered-only",
|
||||||
|
"reviewState": "accepted" if has_snapshot else "candidate",
|
||||||
|
"freshnessState": "current" if has_snapshot else "missing",
|
||||||
|
"unresolved": is_registered and not has_snapshot,
|
||||||
|
"confidence": 1.0 if has_snapshot else 0.3,
|
||||||
|
"visualSize": 56 if has_snapshot else 42,
|
||||||
|
"ownership": "registry",
|
||||||
|
"displayState": "show",
|
||||||
|
"visibilitySource": "default",
|
||||||
|
"visibilityReason": "default",
|
||||||
|
"sourceReferences": [],
|
||||||
|
"deepLinks": _repository_links(repo),
|
||||||
|
},
|
||||||
|
"classes": "repository accepted" if has_snapshot else "repository candidate unresolved",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for node in source_nodes:
|
||||||
|
node_id = str(node.get("id", ""))
|
||||||
|
if not node_id:
|
||||||
|
continue
|
||||||
|
kind = str(node.get("kind", ""))
|
||||||
|
layer = _layer_for_kind(kind)
|
||||||
|
is_unresolved = node_id in unresolved
|
||||||
|
attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {}
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": node_id,
|
||||||
|
"stableKey": node_id,
|
||||||
|
"kind": kind,
|
||||||
|
"layer": layer,
|
||||||
|
"label": str(node.get("name") or node_id),
|
||||||
|
"name": str(node.get("name") or node_id),
|
||||||
|
"description": _node_description(kind, attributes),
|
||||||
|
"repo": str(node.get("repo", "")),
|
||||||
|
"domain": str(node.get("domain", "")),
|
||||||
|
"lifecycle": str(node.get("lifecycle", "")),
|
||||||
|
"reviewState": "accepted",
|
||||||
|
"freshnessState": "current",
|
||||||
|
"unresolved": is_unresolved,
|
||||||
|
"confidence": 0.45 if is_unresolved else 1.0,
|
||||||
|
"visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50,
|
||||||
|
"ownership": str(attributes.get("owner") or "repo"),
|
||||||
|
"attributes": attributes,
|
||||||
|
"displayState": "show",
|
||||||
|
"visibilitySource": "default",
|
||||||
|
"visibilityReason": "default",
|
||||||
|
"sourceReferences": _source_references(node),
|
||||||
|
"deepLinks": _node_links(node_id, attributes),
|
||||||
|
},
|
||||||
|
"classes": " ".join(
|
||||||
|
part
|
||||||
|
for part in (layer, kind, "unresolved" if is_unresolved else "accepted")
|
||||||
|
if part
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
edge_index = 0
|
||||||
|
for edge in source_edges:
|
||||||
|
source = str(edge.get("from", ""))
|
||||||
|
target = str(edge.get("to", ""))
|
||||||
|
edge_type = str(edge.get("type", ""))
|
||||||
|
if not source or not target:
|
||||||
|
continue
|
||||||
|
source_layer = layers_by_id.get(source, "unknown")
|
||||||
|
target_layer = layers_by_id.get(target, "unknown")
|
||||||
|
strength = _edge_strength(edge_type)
|
||||||
|
edge_id = f"edge:{edge_index}:{source}:{edge_type}:{target}"
|
||||||
|
edge_index += 1
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": edge_id,
|
||||||
|
"stableKey": edge_id,
|
||||||
|
"kind": "edge",
|
||||||
|
"layer": "relationship",
|
||||||
|
"label": edge_type,
|
||||||
|
"source": source,
|
||||||
|
"target": target,
|
||||||
|
"sourceLayer": source_layer,
|
||||||
|
"targetLayer": target_layer,
|
||||||
|
"edgeType": edge_type,
|
||||||
|
"dependencyType": edge_type,
|
||||||
|
"strength": strength,
|
||||||
|
"edgeWidth": _edge_width(strength),
|
||||||
|
"sameLayer": source_layer == target_layer,
|
||||||
|
"reviewState": "accepted",
|
||||||
|
"freshnessState": "current",
|
||||||
|
"displayState": "show",
|
||||||
|
"visibilitySource": "default",
|
||||||
|
"visibilityReason": "default",
|
||||||
|
"deepLinks": {},
|
||||||
|
},
|
||||||
|
"classes": " ".join(
|
||||||
|
part
|
||||||
|
for part in (
|
||||||
|
edge_type.replace(":", "-"),
|
||||||
|
strength,
|
||||||
|
"same-layer" if source_layer == target_layer else "",
|
||||||
|
)
|
||||||
|
if part
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for slug in sorted(repo_slugs):
|
||||||
|
repo_id = f"repo:{slug}"
|
||||||
|
for node in source_nodes:
|
||||||
|
node_id = str(node.get("id", ""))
|
||||||
|
if str(node.get("repo", "")) != slug or not node_id:
|
||||||
|
continue
|
||||||
|
edge_id = f"edge:{edge_index}:{repo_id}:declares:{node_id}"
|
||||||
|
edge_index += 1
|
||||||
|
target_layer = layers_by_id.get(node_id, "unknown")
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": edge_id,
|
||||||
|
"stableKey": edge_id,
|
||||||
|
"kind": "edge",
|
||||||
|
"layer": "relationship",
|
||||||
|
"label": "declares",
|
||||||
|
"source": repo_id,
|
||||||
|
"target": node_id,
|
||||||
|
"sourceLayer": "repository",
|
||||||
|
"targetLayer": target_layer,
|
||||||
|
"edgeType": "declares",
|
||||||
|
"dependencyType": "declares",
|
||||||
|
"strength": "weak",
|
||||||
|
"edgeWidth": 1,
|
||||||
|
"sameLayer": False,
|
||||||
|
"reviewState": "accepted",
|
||||||
|
"freshnessState": "current",
|
||||||
|
"displayState": "show",
|
||||||
|
"visibilitySource": "default",
|
||||||
|
"visibilityReason": "default",
|
||||||
|
"deepLinks": {},
|
||||||
|
},
|
||||||
|
"classes": "declares weak",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
visible_nodes = [element for element in elements if "source" not in element["data"]]
|
||||||
|
visible_edges = [element for element in elements if "source" in element["data"]]
|
||||||
|
return {
|
||||||
|
"apiVersion": "railiance.fabric/v1alpha1",
|
||||||
|
"kind": "GraphExplorerPayload",
|
||||||
|
"manifest_id": "railiance-fabric.registry-map",
|
||||||
|
"generated_at": _utc_now(),
|
||||||
|
"repository": {"slug": "registry", "name": "Railiance Fabric Registry"},
|
||||||
|
"mode": "full",
|
||||||
|
"profile": None,
|
||||||
|
"metrics": {
|
||||||
|
"node_count": len(visible_nodes),
|
||||||
|
"edge_count": len(visible_edges),
|
||||||
|
"hidden_count": 0,
|
||||||
|
"blurred_count": 0,
|
||||||
|
"registered_repo_count": len(registered_repo_slugs),
|
||||||
|
"repo_node_count": len(repo_slugs),
|
||||||
|
"registered_only_repo_count": sum(
|
||||||
|
1
|
||||||
|
for element in visible_nodes
|
||||||
|
if element["data"].get("repo") in registered_repo_slugs
|
||||||
|
and element["data"].get("lifecycle") == "registered-only"
|
||||||
|
),
|
||||||
|
"unresolved_count": len(unresolved),
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"rules": [],
|
||||||
|
"manual_overrides": {},
|
||||||
|
"orphaned_overrides": [],
|
||||||
|
"precedence": "later rules override earlier rules; manual overrides win last",
|
||||||
|
"connected_edge_behavior": "edges connected to hidden nodes are hidden",
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
"hidden_elements": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_for_kind(kind: str) -> str:
|
||||||
|
return _KIND_LAYER.get(kind, kind.lower() or "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_strength(edge_type: str) -> str:
|
||||||
|
if edge_type.startswith("binds:"):
|
||||||
|
status = edge_type.split(":", 1)[1]
|
||||||
|
if status in {"missing", "disputed"}:
|
||||||
|
return "weak"
|
||||||
|
if status in {"accepted", "exact"}:
|
||||||
|
return "strong"
|
||||||
|
return "medium"
|
||||||
|
return _EDGE_STRENGTH.get(edge_type, "medium")
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_width(strength: str) -> int:
|
||||||
|
return {"weak": 1, "medium": 3, "strong": 5}.get(strength, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _unresolved_dependency_ids(
|
||||||
|
nodes: list[dict[str, Any]],
|
||||||
|
edges: list[dict[str, Any]],
|
||||||
|
) -> set[str]:
|
||||||
|
dependency_ids = {
|
||||||
|
str(node.get("id", ""))
|
||||||
|
for node in nodes
|
||||||
|
if node.get("kind") == "DependencyDeclaration" and str(node.get("id", ""))
|
||||||
|
}
|
||||||
|
resolved: set[str] = set()
|
||||||
|
unresolved: set[str] = set()
|
||||||
|
for edge in edges:
|
||||||
|
edge_type = str(edge.get("type", ""))
|
||||||
|
source = str(edge.get("from", ""))
|
||||||
|
if source not in dependency_ids or not edge_type.startswith("binds:"):
|
||||||
|
continue
|
||||||
|
status = edge_type.split(":", 1)[1]
|
||||||
|
if status in {"accepted", "candidate", "exact", "compatible"}:
|
||||||
|
resolved.add(source)
|
||||||
|
if status in {"missing", "disputed"}:
|
||||||
|
unresolved.add(source)
|
||||||
|
return (dependency_ids - resolved) | unresolved
|
||||||
|
|
||||||
|
|
||||||
|
def _node_description(kind: str, attributes: object) -> str:
|
||||||
|
if not isinstance(attributes, dict):
|
||||||
|
return ""
|
||||||
|
description = str(attributes.get("description", ""))
|
||||||
|
if description:
|
||||||
|
return description
|
||||||
|
if kind == "CapabilityDeclaration":
|
||||||
|
return str(attributes.get("capability_type", ""))
|
||||||
|
if kind == "InterfaceDeclaration":
|
||||||
|
version = str(attributes.get("version", ""))
|
||||||
|
interface_type = str(attributes.get("interface_type", ""))
|
||||||
|
return " ".join(part for part in (interface_type, version) if part)
|
||||||
|
if kind == "DependencyDeclaration":
|
||||||
|
return str(
|
||||||
|
attributes.get("requires_capability_type")
|
||||||
|
or attributes.get("requires_capability_id")
|
||||||
|
or attributes.get("interface_type")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if kind == "BindingAssertion":
|
||||||
|
return str(attributes.get("status", ""))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _source_references(node: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
attributes = node.get("attributes")
|
||||||
|
references: list[dict[str, str]] = []
|
||||||
|
if isinstance(attributes, dict):
|
||||||
|
source_path = str(attributes.get("source_path") or "")
|
||||||
|
if source_path:
|
||||||
|
references.append({"label": "Declaration", "path": source_path})
|
||||||
|
for source in attributes.get("source_links", []):
|
||||||
|
if isinstance(source, dict):
|
||||||
|
references.append({key: str(value) for key, value in source.items()})
|
||||||
|
return references
|
||||||
|
|
||||||
|
|
||||||
|
def _node_links(node_id: str, attributes: dict[str, Any] | None = None) -> dict[str, str]:
|
||||||
|
links = {"registry": f"/graph/nodes/{node_id}"}
|
||||||
|
source_path = str((attributes or {}).get("source_path") or "")
|
||||||
|
if source_path:
|
||||||
|
links["sourceFile"] = source_path
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def _repository_links(repository: dict[str, Any]) -> dict[str, str]:
|
||||||
|
slug = str(repository.get("slug", ""))
|
||||||
|
links = {"registry": f"/repositories/{slug}"} if slug else {}
|
||||||
|
state_hub_repo_id = str(repository.get("state_hub_repo_id") or "")
|
||||||
|
if state_hub_repo_id:
|
||||||
|
links["stateHubRepo"] = f"/repos/by-id/{state_hub_repo_id}"
|
||||||
|
return links
|
||||||
364
railiance_fabric/graph_explorer_ui.py
Normal file
364
railiance_fabric/graph_explorer_ui.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def graph_explorer_page() -> str:
|
||||||
|
return """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fabric Map</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--bg: #f7f8fa;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #d6dce5;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #667085;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #2563eb;
|
||||||
|
--warn: #b45309;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; margin: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-size: 14px; }
|
||||||
|
.shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; }
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(120px, .6fr)) auto auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.main { display: grid; grid-template-columns: minmax(0, 1fr) 340px; min-height: 0; }
|
||||||
|
.canvas-wrap { position: relative; min-width: 0; min-height: 0; }
|
||||||
|
#graph-canvas { position: absolute; inset: 0; }
|
||||||
|
.side {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.side h1 { font-size: 17px; line-height: 1.2; margin: 0 0 10px; }
|
||||||
|
.section { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 12px; }
|
||||||
|
.section:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||||
|
label { color: var(--muted); display: grid; gap: 4px; font-size: 12px; }
|
||||||
|
input, select, button {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #ffffff; }
|
||||||
|
button:disabled { color: #98a2b3; cursor: default; }
|
||||||
|
.button-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.meta { color: var(--muted); font-size: 12px; margin: 0; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||||
|
.detail-list li { overflow-wrap: anywhere; }
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
max-width: 260px;
|
||||||
|
display: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 16px 40px rgba(23, 32, 51, .14);
|
||||||
|
padding: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.popup strong { display: block; margin-bottom: 4px; }
|
||||||
|
.notice { color: var(--warn); padding: 14px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell { min-height: 760px; }
|
||||||
|
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||||
|
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
||||||
|
.side { border-left: 0; border-top: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="shell">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label>Search <input id="search" autocomplete="off"></label>
|
||||||
|
<label>Layer <select id="layer-filter"><option value="">Any</option></select></label>
|
||||||
|
<label>Review <select id="review-filter"><option value="">Any</option><option>accepted</option><option>candidate</option></select></label>
|
||||||
|
<label>Unresolved <select id="unresolved-filter"><option value="">Any</option><option value="true">Only</option></select></label>
|
||||||
|
<button type="button" data-action="fit">Fit</button>
|
||||||
|
<button type="button" data-action="focus">Focus</button>
|
||||||
|
<button type="button" data-action="clear" class="primary">Reset</button>
|
||||||
|
<span id="counts" class="meta"></span>
|
||||||
|
</div>
|
||||||
|
<main class="main">
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<div id="graph-canvas" role="img" aria-label="Interactive Fabric graph"></div>
|
||||||
|
<div id="popup" class="popup"></div>
|
||||||
|
</div>
|
||||||
|
<aside class="side">
|
||||||
|
<section class="section">
|
||||||
|
<h1 id="detail-title">Fabric Map</h1>
|
||||||
|
<p id="detail-summary" class="meta">No selection</p>
|
||||||
|
<div id="detail-pills"></div>
|
||||||
|
<ul id="detail-list" class="detail-list"></ul>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="button" data-override="show">Show</button>
|
||||||
|
<button type="button" data-override="blur">Blur</button>
|
||||||
|
<button type="button" data-override="hide">Hide</button>
|
||||||
|
<button type="button" data-action="reset-overrides">Clear Overrides</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<p id="hidden-summary" class="meta">Hidden 0</p>
|
||||||
|
<div id="legend"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const manifestUrl = "/exports/graph-explorer/manifest";
|
||||||
|
const graphUrl = "/exports/graph-explorer";
|
||||||
|
const canvas = document.getElementById("graph-canvas");
|
||||||
|
const popup = document.getElementById("popup");
|
||||||
|
const searchInput = document.getElementById("search");
|
||||||
|
const layerFilter = document.getElementById("layer-filter");
|
||||||
|
const reviewFilter = document.getElementById("review-filter");
|
||||||
|
const unresolvedFilter = document.getElementById("unresolved-filter");
|
||||||
|
const counts = document.getElementById("counts");
|
||||||
|
const hiddenSummary = document.getElementById("hidden-summary");
|
||||||
|
const detailTitle = document.getElementById("detail-title");
|
||||||
|
const detailSummary = document.getElementById("detail-summary");
|
||||||
|
const detailPills = document.getElementById("detail-pills");
|
||||||
|
const detailList = document.getElementById("detail-list");
|
||||||
|
const legend = document.getElementById("legend");
|
||||||
|
let cy = null;
|
||||||
|
let selected = null;
|
||||||
|
let focusSet = null;
|
||||||
|
let manualOverrides = {};
|
||||||
|
let layerColors = {};
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
|
||||||
|
const elementText = (data) => [
|
||||||
|
data.id, data.stableKey, data.label, data.name, data.description,
|
||||||
|
data.repo, data.domain, data.kind, data.layer, data.edgeType
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
|
||||||
|
const matchesFilters = (element) => {
|
||||||
|
const data = element.data();
|
||||||
|
const text = searchInput.value.trim().toLowerCase();
|
||||||
|
if (text && !elementText(data).includes(text)) return false;
|
||||||
|
if (layerFilter.value && data.layer !== layerFilter.value) return false;
|
||||||
|
if (reviewFilter.value && data.reviewState !== reviewFilter.value) return false;
|
||||||
|
if (unresolvedFilter.value === "true" && data.unresolved !== true) return false;
|
||||||
|
if (focusSet && !focusSet.has(element.id())) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
if (!cy) return;
|
||||||
|
const hiddenNodes = new Set();
|
||||||
|
cy.nodes().forEach((node) => {
|
||||||
|
let state = matchesFilters(node) ? "show" : "hide";
|
||||||
|
if (manualOverrides[node.id()]) state = manualOverrides[node.id()];
|
||||||
|
node.data("displayState", state);
|
||||||
|
node.toggleClass("display-blur", state === "blur");
|
||||||
|
node.style("display", state === "hide" ? "none" : "element");
|
||||||
|
if (state === "hide") hiddenNodes.add(node.id());
|
||||||
|
});
|
||||||
|
cy.edges().forEach((edge) => {
|
||||||
|
let state = matchesFilters(edge) ? "show" : "hide";
|
||||||
|
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
|
||||||
|
if (manualOverrides[edge.id()]) state = manualOverrides[edge.id()];
|
||||||
|
edge.data("displayState", state);
|
||||||
|
edge.toggleClass("display-blur", state === "blur");
|
||||||
|
edge.style("display", state === "hide" ? "none" : "element");
|
||||||
|
});
|
||||||
|
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
||||||
|
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
||||||
|
const hidden = cy.elements().length - visibleNodes - visibleEdges;
|
||||||
|
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
||||||
|
hiddenSummary.textContent = `Hidden ${hidden}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetails = (element) => {
|
||||||
|
selected = element || null;
|
||||||
|
if (!element) {
|
||||||
|
detailTitle.textContent = "Fabric Map";
|
||||||
|
detailSummary.textContent = "No selection";
|
||||||
|
detailPills.innerHTML = "";
|
||||||
|
detailList.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = element.data();
|
||||||
|
detailTitle.textContent = data.name || data.label || data.id;
|
||||||
|
detailSummary.textContent = data.description || data.id;
|
||||||
|
detailPills.innerHTML = ["kind", "layer", "repo", "reviewState", "displayState"]
|
||||||
|
.map((field) => data[field] ? `<span class="pill">${escapeHtml(data[field])}</span>` : "")
|
||||||
|
.join("");
|
||||||
|
const links = data.deepLinks || {};
|
||||||
|
const refs = data.sourceReferences || [];
|
||||||
|
const rows = [
|
||||||
|
["id", data.id],
|
||||||
|
["source", data.source],
|
||||||
|
["target", data.target],
|
||||||
|
["edge", data.edgeType],
|
||||||
|
["strength", data.strength],
|
||||||
|
...Object.entries(links),
|
||||||
|
...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""])
|
||||||
|
];
|
||||||
|
detailList.innerHTML = rows
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFocus = () => {
|
||||||
|
if (!cy || !selected) return;
|
||||||
|
const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood());
|
||||||
|
focusSet = new Set(collection.map((item) => item.id()));
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLegend = (layers) => {
|
||||||
|
legend.innerHTML = layers.map((layer) =>
|
||||||
|
`<span class="pill"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(layer.color || "#667085")}"></span>${escapeHtml(layer.label)}</span>`
|
||||||
|
).join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const boot = async () => {
|
||||||
|
const [manifest, payload] = await Promise.all([
|
||||||
|
fetch(manifestUrl).then((response) => response.json()),
|
||||||
|
fetch(graphUrl).then((response) => response.json())
|
||||||
|
]);
|
||||||
|
layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color]));
|
||||||
|
(manifest.layers || []).forEach((layer) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = layer.id;
|
||||||
|
option.textContent = layer.label;
|
||||||
|
layerFilter.appendChild(option);
|
||||||
|
});
|
||||||
|
renderLegend(manifest.layers || []);
|
||||||
|
const elements = (payload.elements || []).map((element) => ({
|
||||||
|
...element,
|
||||||
|
data: {...element.data, color: layerColors[element.data.layer] || "#667085"}
|
||||||
|
}));
|
||||||
|
cy = cytoscape({
|
||||||
|
container: canvas,
|
||||||
|
elements,
|
||||||
|
layout: {name: "cose", animate: false, randomize: false},
|
||||||
|
style: [
|
||||||
|
{selector: "node", style: {
|
||||||
|
"background-color": "data(color)",
|
||||||
|
"border-color": "#172033",
|
||||||
|
"border-width": 1,
|
||||||
|
"color": "#172033",
|
||||||
|
"font-size": 11,
|
||||||
|
"height": "data(visualSize)",
|
||||||
|
"label": "data(label)",
|
||||||
|
"text-background-color": "#ffffff",
|
||||||
|
"text-background-opacity": .85,
|
||||||
|
"text-background-padding": 2,
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": 130,
|
||||||
|
"width": "data(visualSize)"
|
||||||
|
}},
|
||||||
|
{selector: "edge", style: {
|
||||||
|
"curve-style": "bezier",
|
||||||
|
"line-color": "#98a2b3",
|
||||||
|
"target-arrow-color": "#98a2b3",
|
||||||
|
"target-arrow-shape": "triangle",
|
||||||
|
"width": "data(edgeWidth)"
|
||||||
|
}},
|
||||||
|
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
||||||
|
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
||||||
|
{selector: ".display-blur", style: {"opacity": .24, "label": ""}},
|
||||||
|
{selector: ".display-blur:selected, .display-blur.hover", style: {"opacity": .78, "label": "data(label)"}},
|
||||||
|
{selector: ":selected", style: {"border-width": 4, "border-color": "#111827", "line-color": "#111827", "target-arrow-color": "#111827"}}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
||||||
|
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
||||||
|
cy.on("mouseover", "node, edge", (event) => {
|
||||||
|
event.target.addClass("hover");
|
||||||
|
const data = event.target.data();
|
||||||
|
popup.innerHTML = `<strong>${escapeHtml(data.name || data.label || data.id)}</strong><span class="meta">${escapeHtml(data.kind)} / ${escapeHtml(data.layer)}</span>`;
|
||||||
|
popup.style.left = `${Math.min(event.renderedPosition.x + 14, canvas.clientWidth - 270)}px`;
|
||||||
|
popup.style.top = `${Math.max(10, event.renderedPosition.y + 14)}px`;
|
||||||
|
popup.style.display = "block";
|
||||||
|
});
|
||||||
|
cy.on("mouseout", "node, edge", (event) => {
|
||||||
|
event.target.removeClass("hover");
|
||||||
|
popup.style.display = "none";
|
||||||
|
});
|
||||||
|
applyFilters();
|
||||||
|
};
|
||||||
|
|
||||||
|
[searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => {
|
||||||
|
control.addEventListener("input", () => { focusSet = null; applyFilters(); });
|
||||||
|
});
|
||||||
|
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||||
|
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
||||||
|
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
||||||
|
searchInput.value = "";
|
||||||
|
layerFilter.value = "";
|
||||||
|
reviewFilter.value = "";
|
||||||
|
unresolvedFilter.value = "";
|
||||||
|
focusSet = null;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
document.querySelector("[data-action='reset-overrides']").addEventListener("click", () => {
|
||||||
|
manualOverrides = {};
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-override]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (!selected) return;
|
||||||
|
manualOverrides[selected.id()] = button.dataset.override;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!window.cytoscape) {
|
||||||
|
canvas.innerHTML = "<p class='notice'>Cytoscape.js could not be loaded.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boot().catch((error) => {
|
||||||
|
canvas.innerHTML = `<p class='notice'>${escapeHtml(error.message)}</p>`;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
@@ -3,12 +3,15 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload
|
||||||
|
from .graph_explorer_ui import graph_explorer_page
|
||||||
from .registry import (
|
from .registry import (
|
||||||
RegistryError,
|
RegistryError,
|
||||||
RegistryStore,
|
RegistryStore,
|
||||||
@@ -23,6 +26,11 @@ from .registry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HtmlResponse:
|
||||||
|
body: str
|
||||||
|
|
||||||
|
|
||||||
class RegistryHandler(BaseHTTPRequestHandler):
|
class RegistryHandler(BaseHTTPRequestHandler):
|
||||||
store: RegistryStore
|
store: RegistryStore
|
||||||
|
|
||||||
@@ -39,6 +47,8 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
parts = _parts(path)
|
parts = _parts(path)
|
||||||
if path == "/health":
|
if path == "/health":
|
||||||
return HTTPStatus.OK, {"status": "ok"}
|
return HTTPStatus.OK, {"status": "ok"}
|
||||||
|
if parts == ["ui", "graph-explorer"]:
|
||||||
|
return HTTPStatus.OK, HtmlResponse(graph_explorer_page())
|
||||||
if path == "/status":
|
if path == "/status":
|
||||||
return HTTPStatus.OK, self.store.status()
|
return HTTPStatus.OK, self.store.status()
|
||||||
if parts == ["repositories"]:
|
if parts == ["repositories"]:
|
||||||
@@ -79,6 +89,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
return HTTPStatus.OK, backstage_projection(self.store.combined_graph())
|
return HTTPStatus.OK, backstage_projection(self.store.combined_graph())
|
||||||
if parts == ["exports", "xregistry"]:
|
if parts == ["exports", "xregistry"]:
|
||||||
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
|
return HTTPStatus.OK, xregistry_projection(self.store.combined_graph())
|
||||||
|
if parts == ["exports", "graph-explorer"]:
|
||||||
|
return HTTPStatus.OK, fabric_graph_explorer_payload(
|
||||||
|
self.store.combined_graph(),
|
||||||
|
self.store.list_repositories(),
|
||||||
|
{str(snapshot["repo_slug"]) for snapshot in self.store.latest_snapshots()},
|
||||||
|
)
|
||||||
|
if parts == ["exports", "graph-explorer", "manifest"]:
|
||||||
|
return HTTPStatus.OK, fabric_graph_explorer_manifest()
|
||||||
if parts == ["artifacts"]:
|
if parts == ["artifacts"]:
|
||||||
return HTTPStatus.OK, self.store.list_artifacts(
|
return HTTPStatus.OK, self.store.list_artifacts(
|
||||||
repo_slug=_query_optional(query, "repo_slug"),
|
repo_slug=_query_optional(query, "repo_slug"),
|
||||||
@@ -120,7 +138,10 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
status, body = action(parsed.path, query)
|
status, body = action(parsed.path, query)
|
||||||
self._send_json(int(status), body)
|
if isinstance(body, HtmlResponse):
|
||||||
|
self._send_text(int(status), body.body, "text/html; charset=utf-8")
|
||||||
|
else:
|
||||||
|
self._send_json(int(status), body)
|
||||||
except RegistryError as exc:
|
except RegistryError as exc:
|
||||||
self._send_json(exc.status_code, {"error": exc.message})
|
self._send_json(exc.status_code, {"error": exc.message})
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
@@ -140,8 +161,14 @@ class RegistryHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _send_json(self, status: int, body: Any) -> None:
|
def _send_json(self, status: int, body: Any) -> None:
|
||||||
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
|
payload = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
|
||||||
|
self._send_bytes(status, payload, "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
def _send_text(self, status: int, body: str, content_type: str) -> None:
|
||||||
|
self._send_bytes(status, body.encode("utf-8"), content_type)
|
||||||
|
|
||||||
|
def _send_bytes(self, status: int, payload: bytes, content_type: str) -> None:
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", content_type)
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(payload)
|
self.wfile.write(payload)
|
||||||
|
|||||||
232
schemas/graph-explorer-manifest.schema.yaml
Normal file
232
schemas/graph-explorer-manifest.schema.yaml
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||||
|
$id: "https://railiance.local/fabric/schemas/graph-explorer-manifest.schema.yaml"
|
||||||
|
title: "GraphExplorerManifest"
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- apiVersion
|
||||||
|
- kind
|
||||||
|
- id
|
||||||
|
- title
|
||||||
|
- endpoints
|
||||||
|
- identity
|
||||||
|
- layers
|
||||||
|
- filter
|
||||||
|
- modes
|
||||||
|
- profile_persistence
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
$ref: "./common.schema.yaml#/$defs/apiVersion"
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
const: GraphExplorerManifest
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
minLength: 3
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
engine:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
suggested_package:
|
||||||
|
type: string
|
||||||
|
preferred_library:
|
||||||
|
type: string
|
||||||
|
display_state_owner:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- host
|
||||||
|
- engine
|
||||||
|
- host-or-engine
|
||||||
|
endpoints:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- graph
|
||||||
|
properties:
|
||||||
|
graph:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
manifest:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
refilter:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
profiles:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
list:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
create:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
get:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
update:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
delete:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
duplicate:
|
||||||
|
$ref: "#/$defs/endpoint"
|
||||||
|
latest_default:
|
||||||
|
type: boolean
|
||||||
|
identity:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- node_id_field
|
||||||
|
- stable_key_field
|
||||||
|
- edge_id_field
|
||||||
|
- source_field
|
||||||
|
- target_field
|
||||||
|
properties:
|
||||||
|
node_id_field:
|
||||||
|
type: string
|
||||||
|
stable_key_field:
|
||||||
|
type: string
|
||||||
|
edge_id_field:
|
||||||
|
type: string
|
||||||
|
source_field:
|
||||||
|
type: string
|
||||||
|
target_field:
|
||||||
|
type: string
|
||||||
|
layers:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- label
|
||||||
|
- order
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
order:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
pattern: "^#[0-9a-fA-F]{6}$"
|
||||||
|
grouping_fields:
|
||||||
|
$ref: "#/$defs/stringList"
|
||||||
|
search_fields:
|
||||||
|
$ref: "#/$defs/stringList"
|
||||||
|
filter:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- actions
|
||||||
|
- fields
|
||||||
|
properties:
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
uniqueItems: true
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- show
|
||||||
|
- blur
|
||||||
|
- hide
|
||||||
|
fields:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- label
|
||||||
|
- type
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- string
|
||||||
|
- number
|
||||||
|
- boolean
|
||||||
|
- array
|
||||||
|
visual_encodings:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
detail:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
node_fields:
|
||||||
|
$ref: "#/$defs/stringList"
|
||||||
|
edge_fields:
|
||||||
|
$ref: "#/$defs/stringList"
|
||||||
|
modes:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
requires_selection:
|
||||||
|
type: boolean
|
||||||
|
profile_persistence:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- none
|
||||||
|
- host
|
||||||
|
shareable_state:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
url_parameters:
|
||||||
|
type: boolean
|
||||||
|
profile_id:
|
||||||
|
type: boolean
|
||||||
|
state_blob:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
$defs:
|
||||||
|
endpoint:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- method
|
||||||
|
- url
|
||||||
|
properties:
|
||||||
|
method:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- PATCH
|
||||||
|
- DELETE
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
stringList:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
258
schemas/graph-explorer-payload.schema.yaml
Normal file
258
schemas/graph-explorer-payload.schema.yaml
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||||
|
$id: "https://railiance.local/fabric/schemas/graph-explorer-payload.schema.yaml"
|
||||||
|
title: "GraphExplorerPayload"
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- apiVersion
|
||||||
|
- kind
|
||||||
|
- elements
|
||||||
|
- hidden_elements
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
$ref: "./common.schema.yaml#/$defs/apiVersion"
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
const: GraphExplorerPayload
|
||||||
|
manifest_id:
|
||||||
|
type: string
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
repository:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
profile:
|
||||||
|
anyOf:
|
||||||
|
- type: "null"
|
||||||
|
- $ref: "#/$defs/profile"
|
||||||
|
metrics:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: integer
|
||||||
|
- type: number
|
||||||
|
- type: boolean
|
||||||
|
- type: string
|
||||||
|
- type: "null"
|
||||||
|
filter:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/rule"
|
||||||
|
manual_overrides:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/$defs/displayState"
|
||||||
|
orphaned_overrides:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
precedence:
|
||||||
|
type: string
|
||||||
|
connected_edge_behavior:
|
||||||
|
type: string
|
||||||
|
elements:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/element"
|
||||||
|
hidden_elements:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/element"
|
||||||
|
impacts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
changed_fact_keys:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
$defs:
|
||||||
|
element:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- data
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- stableKey
|
||||||
|
- kind
|
||||||
|
- layer
|
||||||
|
- displayState
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/$defs/stableKey"
|
||||||
|
key:
|
||||||
|
$ref: "#/$defs/stableKey"
|
||||||
|
stableKey:
|
||||||
|
$ref: "#/$defs/stableKey"
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
layer:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
repo:
|
||||||
|
type: string
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
lifecycle:
|
||||||
|
type: string
|
||||||
|
reviewState:
|
||||||
|
type: string
|
||||||
|
freshnessState:
|
||||||
|
type: string
|
||||||
|
displayState:
|
||||||
|
$ref: "#/$defs/displayState"
|
||||||
|
visibilitySource:
|
||||||
|
type: string
|
||||||
|
visibilityReason:
|
||||||
|
type: string
|
||||||
|
confidence:
|
||||||
|
anyOf:
|
||||||
|
- type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
- type: "null"
|
||||||
|
visualSize:
|
||||||
|
type: number
|
||||||
|
minimum: 1
|
||||||
|
ownership:
|
||||||
|
type: string
|
||||||
|
unresolved:
|
||||||
|
type: boolean
|
||||||
|
source:
|
||||||
|
$ref: "#/$defs/stableKey"
|
||||||
|
target:
|
||||||
|
$ref: "#/$defs/stableKey"
|
||||||
|
sourceLayer:
|
||||||
|
type: string
|
||||||
|
targetLayer:
|
||||||
|
type: string
|
||||||
|
edgeType:
|
||||||
|
type: string
|
||||||
|
dependencyType:
|
||||||
|
type: string
|
||||||
|
strength:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- weak
|
||||||
|
- medium
|
||||||
|
- strong
|
||||||
|
edgeWidth:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
sameLayer:
|
||||||
|
type: boolean
|
||||||
|
connectedToBlurred:
|
||||||
|
type: boolean
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
attributes:
|
||||||
|
anyOf:
|
||||||
|
- type: object
|
||||||
|
additionalProperties: true
|
||||||
|
- type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
sourceReferences:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: integer
|
||||||
|
- type: number
|
||||||
|
- type: "null"
|
||||||
|
deepLinks:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
classes:
|
||||||
|
type: string
|
||||||
|
group:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- nodes
|
||||||
|
- edges
|
||||||
|
displayState:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- show
|
||||||
|
- blur
|
||||||
|
- hide
|
||||||
|
profile:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: integer
|
||||||
|
repository_id:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
default_mode:
|
||||||
|
type: string
|
||||||
|
filter_rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/$defs/rule"
|
||||||
|
manual_overrides:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/$defs/displayState"
|
||||||
|
rule:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required:
|
||||||
|
- action
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
$ref: "#/$defs/displayState"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
match:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
stableKey:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 240
|
||||||
|
pattern: "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$"
|
||||||
201
tests/test_graph_explorer.py
Normal file
201
tests/test_graph_explorer.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from railiance_fabric.cli import main as cli_main
|
||||||
|
from railiance_fabric.graph import build_graph
|
||||||
|
from railiance_fabric.graph_explorer import (
|
||||||
|
fabric_graph_explorer_manifest,
|
||||||
|
fabric_graph_explorer_payload,
|
||||||
|
)
|
||||||
|
from railiance_fabric.registry import RegistryStore
|
||||||
|
from railiance_fabric.schema_validation import draft202012_validator
|
||||||
|
from railiance_fabric.server import RegistryHandler
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_explorer_manifest_and_payload_validate() -> None:
|
||||||
|
graph = build_graph([Path(".")]).to_export()
|
||||||
|
manifest = fabric_graph_explorer_manifest()
|
||||||
|
payload = fabric_graph_explorer_payload(
|
||||||
|
graph,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "railiance-fabric",
|
||||||
|
"name": "Railiance Fabric",
|
||||||
|
"state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "registered-only",
|
||||||
|
"name": "Registered Only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{"railiance-fabric"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
||||||
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
|
||||||
|
nodes = [element for element in payload["elements"] if "source" not in element["data"]]
|
||||||
|
edges = [element for element in payload["elements"] if "source" in element["data"]]
|
||||||
|
registered_only = next(
|
||||||
|
element for element in nodes if element["data"]["id"] == "repo:registered-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert registered_only["data"]["reviewState"] == "candidate"
|
||||||
|
assert registered_only["data"]["unresolved"] is True
|
||||||
|
assert any(edge["data"]["edgeType"] == "declares" for edge in edges)
|
||||||
|
assert any(node["data"]["sourceReferences"] for node in nodes if node["data"]["kind"] != "Repository")
|
||||||
|
assert payload["metrics"]["registered_repo_count"] == 2
|
||||||
|
assert payload["metrics"]["unresolved_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_exports_graph_explorer_payload(capsys) -> None:
|
||||||
|
assert cli_main(["export", "--format", "graph-explorer"]) == 0
|
||||||
|
payload = json.loads(capsys.readouterr().out)
|
||||||
|
|
||||||
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
assert payload["kind"] == "GraphExplorerPayload"
|
||||||
|
assert any(
|
||||||
|
element["data"].get("sourceReferences")
|
||||||
|
for element in payload["elements"]
|
||||||
|
if "source" not in element["data"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_explorer_payload_accepts_repo_scoping_shape() -> None:
|
||||||
|
payload = {
|
||||||
|
"apiVersion": "railiance.fabric/v1alpha1",
|
||||||
|
"kind": "GraphExplorerPayload",
|
||||||
|
"manifest_id": "repo-scoping.dependency-graph",
|
||||||
|
"mode": "full",
|
||||||
|
"profile": {
|
||||||
|
"id": 1,
|
||||||
|
"repository_id": 1,
|
||||||
|
"name": "Evidence Audit",
|
||||||
|
"description": "Show supporting evidence.",
|
||||||
|
"default_mode": "full",
|
||||||
|
"filter_rules": [
|
||||||
|
{"name": "Blur facts", "action": "blur", "match": {"layer": "fact"}}
|
||||||
|
],
|
||||||
|
"manual_overrides": {"feature:1": "show"},
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"rules": [],
|
||||||
|
"manual_overrides": {},
|
||||||
|
"orphaned_overrides": [],
|
||||||
|
},
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "fact:document:README.md",
|
||||||
|
"stableKey": "fact:document:README.md",
|
||||||
|
"kind": "fact",
|
||||||
|
"layer": "fact",
|
||||||
|
"label": "README.md",
|
||||||
|
"displayState": "blur",
|
||||||
|
"reviewState": "accepted",
|
||||||
|
"freshnessState": "current",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"visualSize": 50,
|
||||||
|
"sourceReferences": [{"path": "README.md", "kind": "document"}],
|
||||||
|
},
|
||||||
|
"classes": "fact display-blur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "capability:1",
|
||||||
|
"stableKey": "capability:1",
|
||||||
|
"kind": "capability",
|
||||||
|
"layer": "capability",
|
||||||
|
"label": "Registry Capabilities",
|
||||||
|
"displayState": "show",
|
||||||
|
"reviewState": "accepted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "edge:fact:capability",
|
||||||
|
"stableKey": "edge:fact:capability",
|
||||||
|
"kind": "edge",
|
||||||
|
"layer": "dependency",
|
||||||
|
"label": "supports",
|
||||||
|
"source": "fact:document:README.md",
|
||||||
|
"target": "capability:1",
|
||||||
|
"edgeType": "supports",
|
||||||
|
"dependencyType": "supports",
|
||||||
|
"strength": "strong",
|
||||||
|
"edgeWidth": 5,
|
||||||
|
"sameLayer": False,
|
||||||
|
"displayState": "show",
|
||||||
|
},
|
||||||
|
"classes": "supports strong",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"hidden_elements": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
||||||
|
store = RegistryStore(tmp_path / "registry.sqlite3")
|
||||||
|
store.init_schema()
|
||||||
|
store.upsert_repository(
|
||||||
|
{
|
||||||
|
"slug": "railiance-fabric",
|
||||||
|
"name": "Railiance Fabric",
|
||||||
|
"state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.upsert_repository({"slug": "registered-only", "name": "Registered Only"})
|
||||||
|
store.add_snapshot(
|
||||||
|
"railiance-fabric",
|
||||||
|
{
|
||||||
|
"commit": "test-commit",
|
||||||
|
"generated_at": "2026-05-18T00:00:00Z",
|
||||||
|
"graph": build_graph([Path(".")]).to_export(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
class Handler(RegistryHandler):
|
||||||
|
pass
|
||||||
|
|
||||||
|
Handler.store = store
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
base_url = f"http://127.0.0.1:{server.server_port}"
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"{base_url}/exports/graph-explorer/manifest",
|
||||||
|
timeout=5,
|
||||||
|
) as response:
|
||||||
|
manifest = json.loads(response.read())
|
||||||
|
with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response:
|
||||||
|
payload = json.loads(response.read())
|
||||||
|
with urllib.request.urlopen(f"{base_url}/ui/graph-explorer", timeout=5) as response:
|
||||||
|
page = response.read().decode("utf-8")
|
||||||
|
content_type = response.headers["Content-Type"]
|
||||||
|
|
||||||
|
_validate_schema("graph-explorer-manifest.schema.yaml", manifest)
|
||||||
|
_validate_schema("graph-explorer-payload.schema.yaml", payload)
|
||||||
|
assert manifest["id"] == "railiance-fabric.registry-map"
|
||||||
|
assert payload["metrics"]["registered_only_repo_count"] == 1
|
||||||
|
assert content_type.startswith("text/html")
|
||||||
|
assert 'id="graph-canvas"' in page
|
||||||
|
assert "cytoscape.min.js" in page
|
||||||
|
assert "/exports/graph-explorer/manifest" in page
|
||||||
|
assert 'data-override="hide"' in page
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_schema(schema_name: str, payload: dict[str, object]) -> None:
|
||||||
|
validator = draft202012_validator(Path("schemas") / schema_name)
|
||||||
|
validator.validate(payload)
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Interactive Fabric Map"
|
title: "Interactive Fabric Map"
|
||||||
domain: railiance
|
domain: railiance
|
||||||
repo: railiance-fabric
|
repo: railiance-fabric
|
||||||
status: proposed
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -19,127 +19,212 @@ updated: "2026-05-18"
|
|||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Build a clever interactive auto-layouting map over the registered Fabric
|
Build a clever interactive auto-layouting map over the registered Fabric
|
||||||
entities so humans and agents can orient inside the infrastructure graph.
|
entities so humans and agents can orient inside the infrastructure graph, while
|
||||||
|
keeping the visualization layer reusable enough to serve other graph-producing
|
||||||
|
repos.
|
||||||
|
|
||||||
The current repo has static graph exports through JSON and Mermaid. That is
|
The current repo has static graph exports through JSON and Mermaid. That is
|
||||||
useful for snapshots and docs, but it does not yet provide a navigable
|
useful for snapshots and docs, but it does not yet provide a navigable
|
||||||
orientation surface for the registered ecosystem.
|
orientation surface for the registered ecosystem.
|
||||||
|
|
||||||
|
Repo-scoping has already implemented the first serious local graph explorer
|
||||||
|
shape through RREG-WP-0010 and RREG-WP-0011. Fabric should reuse those lessons
|
||||||
|
instead of building a parallel one-off map.
|
||||||
|
|
||||||
|
Reference implementation inputs:
|
||||||
|
|
||||||
|
- `repo-scoping/docs/adr-dependency-graph-visualization-framework.md` selects
|
||||||
|
Cytoscape.js for graph-native pan, zoom, selection, styling, filtering, and
|
||||||
|
path exploration.
|
||||||
|
- `repo-scoping/docs/dependency-visualization-exploration.md` defines the
|
||||||
|
layered graph model, display states, filter rules, manual overrides, and view
|
||||||
|
profiles.
|
||||||
|
- `repo-scoping/src/repo_scoping/core/service.py` exposes Cytoscape-compatible
|
||||||
|
graph payloads with stable keys, metadata-rich nodes and edges, deterministic
|
||||||
|
show/blur/hide visibility evaluation, profile application, hidden element
|
||||||
|
recovery data, and visual sizing hints.
|
||||||
|
- `repo-scoping/src/repo_scoping/web_ui/views.py` provides the current
|
||||||
|
interactive shell: profile controls, filter controls, manual overrides,
|
||||||
|
focus-by-depth, hover popups, selection details, and a layered preset layout.
|
||||||
|
- `repo-scoping/tests/test_web_api.py` verifies the graph endpoint, ad hoc
|
||||||
|
filters, profile CRUD and duplicate behavior, latest-profile defaulting, and
|
||||||
|
UI wiring.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
The first map should be an operational tool, not a landing page. It should open
|
The first Fabric map should be an operational tool, not a landing page. It
|
||||||
directly into the graph and make the current infrastructure legible through
|
should open directly into the graph and make the current infrastructure legible
|
||||||
stable grouping, filtering, and drill-down.
|
through stable grouping, filtering, drill-down, and saved perspectives.
|
||||||
|
|
||||||
The map should support:
|
The map should support:
|
||||||
|
|
||||||
- force or layered auto-layout with stable seeded positions
|
- force, cose, concentric, or layered auto-layout with stable seeded positions
|
||||||
- grouping by domain, repo, service, capability, interface, dependency, and
|
- grouping by domain, repo, service, capability, interface, dependency, and
|
||||||
unresolved status
|
unresolved status
|
||||||
- search across repo slugs, graph ids, names, capabilities, interfaces, and
|
- search across repo slugs, graph ids, names, capabilities, interfaces, and
|
||||||
library names
|
library names
|
||||||
- quick filters for active services, external interfaces, missing declarations,
|
- quick filters for review state, active services, external interfaces, missing
|
||||||
unresolved dependencies, blast radius, and dependency path
|
declarations, unresolved dependencies, blast radius, and dependency path
|
||||||
|
- display states of `show`, `blur`, and `hide`, with deterministic rule
|
||||||
|
precedence and manual overrides
|
||||||
- hover and selection detail panels with links back to registry endpoints,
|
- hover and selection detail panels with links back to registry endpoints,
|
||||||
State Hub repo ids, workplans, and source files where available
|
State Hub repo ids, workplans, and source files where available
|
||||||
- shareable map state through URL query parameters or a copied state blob
|
- saved view profiles with filter rules, manual overrides, timestamps,
|
||||||
|
duplicate/delete behavior, latest-profile defaulting, URL `profile_id`
|
||||||
|
loading, and orphaned override reporting
|
||||||
|
- shareable map state through URL query parameters, profile ids, or a copied
|
||||||
|
state blob
|
||||||
- graceful handling of registered-only repos that do not yet have local
|
- graceful handling of registered-only repos that do not yet have local
|
||||||
`fabric/` declarations
|
`fabric/` declarations
|
||||||
|
|
||||||
|
The reusable engine should be manifest-driven. Fabric and repo-scoping should be
|
||||||
|
able to wire the same interaction shell to different graph-producing services by
|
||||||
|
providing a manifest that describes:
|
||||||
|
|
||||||
|
- graph and profile endpoint URLs
|
||||||
|
- node and edge identity fields
|
||||||
|
- layer ordering and grouping fields
|
||||||
|
- visual encodings for kind, layer, review state, lifecycle, confidence,
|
||||||
|
strength, freshness, and unresolved status
|
||||||
|
- filterable fields and supported rule actions
|
||||||
|
- detail-panel fields and deep links
|
||||||
|
- available modes such as full graph, impact, selected path, neighborhood focus,
|
||||||
|
and onboarding gaps
|
||||||
|
- profile persistence capabilities for each host application
|
||||||
|
|
||||||
## Architecture Notes
|
## Architecture Notes
|
||||||
|
|
||||||
Keep the registry as the data source. The UI should consume registry exports and
|
Keep the registry as the data source. The UI should consume registry exports and
|
||||||
queries rather than reloading repo files directly.
|
queries rather than reloading repo files directly.
|
||||||
|
|
||||||
Likely implementation path:
|
Refined implementation path:
|
||||||
|
|
||||||
- add a map-oriented projection endpoint or CLI export that preserves existing
|
- define a host-neutral graph manifest and payload contract that can represent
|
||||||
graph ids while adding visual facets
|
both repo-scoping dependency graphs and Fabric ecosystem graphs
|
||||||
- serve a small local web UI from the registry service or a separate lightweight
|
- add a Fabric map projection endpoint or CLI export that preserves existing
|
||||||
static bundle
|
graph ids while adding visual facets expected by the engine
|
||||||
- use a proven browser graph/layout library instead of hand-rolling physics
|
- prototype a small local web UI as a reusable graph explorer shell, likely with
|
||||||
- keep layout state client-side first; persist only after the workflows prove
|
Cytoscape.js unless the transfer review finds a better fit for the
|
||||||
useful
|
cross-repo contract
|
||||||
|
- wire Fabric through an adapter manifest rather than hard-coding Fabric fields
|
||||||
|
throughout the UI
|
||||||
|
- verify that a repo-scoping adapter could consume the same shell with minimal
|
||||||
|
glue, using RREG-WP-0010 and RREG-WP-0011 behavior as the parity target
|
||||||
|
- decide whether the graph explorer should be extracted into a separate
|
||||||
|
repository after the manifest contract and first two adapters stabilize
|
||||||
|
|
||||||
|
Candidate extraction shape:
|
||||||
|
|
||||||
|
- working name: `graph-explorer-engine`
|
||||||
|
- reusable package: static graph explorer assets plus TypeScript or JSON schema
|
||||||
|
definitions for manifests, payloads, rules, profiles, and visual facets
|
||||||
|
- host adapters: Fabric registry adapter and repo-scoping dependency graph
|
||||||
|
adapter
|
||||||
|
- host responsibilities: authentication, storage, graph projection, profile
|
||||||
|
persistence, and deep-link targets
|
||||||
|
- engine responsibilities: layout, filtering, display-state evaluation if not
|
||||||
|
provided by host, hover and selection UI, profile control surface, focus
|
||||||
|
modes, and shareable state
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
### T01 - Map UX And Information Architecture
|
### T01 - Repo-Scoping Transfer Review
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T01
|
id: RAIL-FAB-WP-0008-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9844a9a7-f285-4523-a8d6-4ca62008ce08"
|
state_hub_task_id: "9844a9a7-f285-4523-a8d6-4ca62008ce08"
|
||||||
```
|
```
|
||||||
|
|
||||||
Define map views, entity visual grammar, navigation behaviors, and orientation
|
Review the repo-scoping graph implementation and turn RREG-WP-0010/RREG-WP-0011
|
||||||
affordances.
|
into concrete Fabric requirements and reusable-engine requirements.
|
||||||
|
|
||||||
Acceptance notes:
|
Acceptance notes:
|
||||||
|
|
||||||
- Decide initial visual grammar for repos, services, capabilities, interfaces,
|
- Identify which repo-scoping behaviors should be reused directly: Cytoscape
|
||||||
dependencies, bindings, libraries, and registered-only repos.
|
payload shape, layered layout, show/blur/hide display states, filter rules,
|
||||||
- Decide default grouping and default filters for first load.
|
manual overrides, view profiles, hover popups, focus depth, latest profile
|
||||||
- Decide whether the first implementation uses a force-directed, layered, or
|
defaulting, review-state filtering, confidence sizing, and strength sizing.
|
||||||
hybrid layout.
|
- Identify repo-scoping-specific pieces that should remain adapter logic:
|
||||||
|
fact/evidence/feature/capability/ability/scope layers, impact analysis, and
|
||||||
|
candidate-vs-accepted semantics.
|
||||||
|
- Decide the first Fabric visual grammar for repos, services, capabilities,
|
||||||
|
interfaces, dependencies, bindings, libraries, State Hub links, unresolved
|
||||||
|
gaps, and registered-only repos.
|
||||||
|
- Capture a short extraction recommendation before implementation starts.
|
||||||
|
|
||||||
### T02 - Layout-Ready Graph Projection
|
### T02 - Manifest And Adapter Contract
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T02
|
id: RAIL-FAB-WP-0008-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "cb0cc9f1-5225-47e5-8b47-a945c92e7168"
|
state_hub_task_id: "cb0cc9f1-5225-47e5-8b47-a945c92e7168"
|
||||||
```
|
```
|
||||||
|
|
||||||
Add or shape registry exports for map-friendly nodes, edges, groups, facets, and
|
Define the reusable graph explorer manifest, payload, filtering, and profile
|
||||||
stable identifiers.
|
contract that lets multiple repos mount the same visualization shell.
|
||||||
|
|
||||||
Acceptance notes:
|
Acceptance notes:
|
||||||
|
|
||||||
- Include registered repositories even when they have no graph snapshot yet.
|
- Define required and optional graph payload fields, including `id`,
|
||||||
- Include node type, domain, repo, lifecycle, unresolved flags, and edge type.
|
`stableKey`, `kind`, `layer`, label, description, source references,
|
||||||
- Preserve stable ids so layout state and deep links do not churn.
|
review/lifecycle state, confidence, freshness, unresolved state, ownership,
|
||||||
|
visual size, edge type, edge strength, same-layer hints, and deep links.
|
||||||
|
- Define a manifest schema for endpoints, layer order, grouping fields, style
|
||||||
|
tokens, detail fields, search fields, filterable fields, graph modes, profile
|
||||||
|
capabilities, and shareable state support.
|
||||||
|
- Decide whether display-state evaluation lives in the host service, the engine,
|
||||||
|
or both with a clear precedence rule.
|
||||||
|
- Include compatibility notes for repo-scoping's existing graph API so an
|
||||||
|
adapter can be added without forcing a repo-scoping rewrite.
|
||||||
|
|
||||||
### T03 - Interactive Auto-Layout Map UI
|
### T03 - Fabric Map Projection
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T03
|
id: RAIL-FAB-WP-0008-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3"
|
state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3"
|
||||||
```
|
```
|
||||||
|
|
||||||
Build a local interactive map with layout controls, filtering, search, zoom,
|
Add or shape Fabric registry exports for map-friendly nodes, edges, groups,
|
||||||
pan, and detail panels.
|
facets, unresolved gaps, and stable identifiers.
|
||||||
|
|
||||||
|
Acceptance notes:
|
||||||
|
|
||||||
|
- Include registered repositories even when they have no graph snapshot yet.
|
||||||
|
- Include node type, domain, repo, lifecycle, State Hub ids, unresolved flags,
|
||||||
|
declaration source links, and edge type.
|
||||||
|
- Preserve stable ids so layout state, profiles, and deep links do not churn.
|
||||||
|
- Export a manifest-backed graph payload that can be consumed by the reusable
|
||||||
|
shell without Fabric-specific UI branches.
|
||||||
|
|
||||||
|
### T04 - Interactive Graph Explorer Shell
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0008-T04
|
||||||
|
status: in_progress
|
||||||
|
priority: high
|
||||||
|
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the manifest-driven interactive map shell with layout controls, filtering,
|
||||||
|
search, zoom, pan, hover popups, selection details, focus modes, and profile
|
||||||
|
controls.
|
||||||
|
|
||||||
Acceptance notes:
|
Acceptance notes:
|
||||||
|
|
||||||
- The first screen is the actual map.
|
- The first screen is the actual map.
|
||||||
- Users can search, select, expand/collapse groups, and inspect details without
|
- Users can search, select, filter, blur, hide, focus by depth, inspect details,
|
||||||
leaving the page.
|
and recover hidden or over-filtered elements without leaving the page.
|
||||||
|
- The shell supports profile create/update/duplicate/delete where the host
|
||||||
|
manifest declares persistence support.
|
||||||
- The map remains useful with the current sparse state where many repos are
|
- The map remains useful with the current sparse state where many repos are
|
||||||
registered-only.
|
registered-only.
|
||||||
|
|
||||||
### T04 - Operational Orientation Workflows
|
### T05 - Fabric Orientation Workflows
|
||||||
|
|
||||||
```task
|
|
||||||
id: RAIL-FAB-WP-0008-T04
|
|
||||||
status: todo
|
|
||||||
priority: medium
|
|
||||||
state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add workflows for blast-radius exploration, dependency paths, unresolved gaps,
|
|
||||||
repo grouping, and current selection sharing.
|
|
||||||
|
|
||||||
Acceptance notes:
|
|
||||||
|
|
||||||
- Selecting an interface can show consumers and blast radius.
|
|
||||||
- Selecting a service can show its dependency path and provider chain.
|
|
||||||
- Registered-only repos are visible as onboarding gaps rather than noise.
|
|
||||||
|
|
||||||
### T05 - Verification And Documentation
|
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0008-T05
|
id: RAIL-FAB-WP-0008-T05
|
||||||
@@ -148,12 +233,37 @@ priority: medium
|
|||||||
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
|
state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67"
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the map against seeded registry data, document usage, and capture
|
Add Fabric-specific workflows for blast-radius exploration, dependency paths,
|
||||||
follow-on data quality gaps.
|
unresolved gaps, repo grouping, onboarding gaps, and shareable context.
|
||||||
|
|
||||||
|
Acceptance notes:
|
||||||
|
|
||||||
|
- Selecting an interface can show consumers and blast radius.
|
||||||
|
- Selecting a service can show its dependency path and provider chain.
|
||||||
|
- Registered-only repos are visible as onboarding gaps rather than noise.
|
||||||
|
- Saved views can separate current operational context from backlog or
|
||||||
|
onboarding cleanup.
|
||||||
|
|
||||||
|
### T06 - Extraction Decision, Verification, And Documentation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: RAIL-FAB-WP-0008-T06
|
||||||
|
status: todo
|
||||||
|
priority: medium
|
||||||
|
state_hub_task_id: "d5567337-efe7-4fe6-8379-9e5505e25f6f"
|
||||||
|
```
|
||||||
|
|
||||||
|
Decide whether to extract the graph explorer into a separate repository, verify
|
||||||
|
the Fabric implementation against seeded registry data, and document launch,
|
||||||
|
refresh, manifest, and adapter usage.
|
||||||
|
|
||||||
Acceptance notes:
|
Acceptance notes:
|
||||||
|
|
||||||
- Verify against the local registry after `registry/local-repos.yaml` onboarding.
|
- Verify against the local registry after `registry/local-repos.yaml` onboarding.
|
||||||
- Include at least one automated projection/API test and one UI smoke test.
|
- Include at least one automated projection/API test and one UI smoke test.
|
||||||
|
- Include an adapter parity checklist for repo-scoping against
|
||||||
|
RREG-WP-0010/RREG-WP-0011 behavior.
|
||||||
|
- If extraction is recommended, propose the new repo name, source boundaries,
|
||||||
|
package/public API, manifest schema ownership, and migration steps.
|
||||||
- Document how to launch the map and how to refresh the underlying registry
|
- Document how to launch the map and how to refresh the underlying registry
|
||||||
data.
|
data.
|
||||||
|
|||||||
Reference in New Issue
Block a user